blocks(article-grid): la banda editorial, y el elemento que el canon no podía dar

Lo que las referencias llaman blog. Sus dos preguntas de fase cero quedan
resueltas, y ninguna necesitó tocar el canon.

El nombre. Blog nombra una sección entera de un sitio, y esto es la fila de
artículos recientes dentro de otra página; el tier nombra por función de página y
el elemento que renderiza es un article, así que article-grid hace coincidir
nombre, función y markup. La palabra blog sobrevive donde un consumidor la busca:
la primera línea del readme y la etiqueta del catálogo.

El artículo semántico. Un teaser tiene que ser un article o se lee como una caja
de texto suelto, pero la tarjeta del canon es un div fijo y eso parecía obligar a
elegir entre añadirle un eje de elemento o declarar un hueco. Construirlo enseñó
la tercera vía, que no cuesta nada: el bloque renderiza el article y compone la
tarjeta dentro. El tier lleva desde su primer bloque renderizando sus propios
elementos de seccionado, así que el elemento es del bloque y la superficie sigue
siendo del canon. La regla amuralla al primitivo, no a la composición.

Y tres decisiones más que sí son de diseño. La tarjeta entera no es un enlace,
porque el teaser lleva además un chip y una firma y anidar interactivos dentro de
un enlace es marcado inválido: es justo donde acaban las referencias que hacen
clicable toda la tarjeta, así que el enlace vive en el título y hay exactamente
uno por teaser. El realce al puntero viene encendido, al revés que en precios,
porque un teaser sí es algo a lo que se va. Y el bloque no formatea fechas: las
compone la app con el componente del canon.

Dos errores míos en la tanda, los dos cazados mirando la captura y no la sonda. El
ancho mínimo por defecto sólo cabía dos veces en la medida, cuando las referencias
lideran con tres columnas; medido y corregido, ahora da tres, dos y una según el
ancho. Y el segundo intento de arreglarlo no llegó a aplicarse sin que me diera
cuenta, porque lancé el reemplazo sin aserción y el formateador había juntado las
props en una línea: culpé a la caché del servidor antes de mirar el fichero.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 33031c2ad9
commit 313e4d52fa

@ -260,7 +260,7 @@ single-price~~ ✅ · ~~V5 cta split~~ ✅ · ~~V2 testimonials spotlight~~ ✅
2026-08-17; entra por el tramo B**) · **B** ✅ **CERRADO 2026-08-17** (15 matrices + guard encendido y probado en rojo) — la matriz de
equivalencia variante-a-variante en el README de cada block, con recetas
demostradas en demo (el entregable central: responde a la brecha de
cardinalidad sin competir en dumps) · **C** (~~`logo-cloud`~~ ✅ **HECHO 2026-08-18**, con el eje `data-ink` en la fundación) **C** tres blocks nuevos — `logo-cloud`
cardinalidad sin competir en dumps) · **C** (~~`logo-cloud`~~ ✅ con el eje `data-ink` en la fundación · ~~`blog`~~ ✅ como **`article-grid`** — el nombre se decidió en su fase 0 — · queda `cookie-consent`, tanda propia con fase 0 legal) **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.

@ -2401,3 +2401,28 @@ type="button">` es una pista de MIME falsa) — el morfo lo declara
LLEGAN a la hoja y no pintan nada; anotado en el propio generador. (2) El hueco
**`visually-hidden`** sale aquí por TERCERA vez en la ola (`newsletter`,
`site-footer`, `logo-cloud`): es candidato de canon con tres consumidores reales.
- 2026-08-18 — **F2b · C · N2 `article-grid` HECHO** (lo que las refs llaman
«blog»). Sus **dos decisiones de fase 0, resueltas**: (1) **el nombre** — «blog»
nombra una sección de SITIO y esto es la fila de artículos recientes DENTRO de
otra página; el tier nombra por función de página y el elemento que renderiza es
un `<article>`, así que `article-grid` hace coincidir nombre, función y markup.
«Blog» sobrevive donde se busca: primera línea del README y etiqueta del
catálogo. (2) **`<article>` vs F21 — sin tocar canon**: el plan reservaba elegir
entre añadir `as` a `Card` o declarar gap, y construirlo enseñó una tercera vía
sin coste: **el block renderiza el `<article>` y compone el `Card` DENTRO**. El
tier ya renderiza sus propios elementos de seccionado (`<footer>`, `<header>`,
`<section>`), así que el elemento es del block y la superficie del canon. F21
amuralla al primitivo, no a la composición.
Además: **la tarjeta entera NO es un enlace** —el teaser lleva chip y firma, y
anidar interactivos en un enlace es marcado inválido, que es donde acaban las
refs que hacen clicable toda la tarjeta—, el enlace vive en el título (1 por
teaser, medido); **`lift` viene ENCENDIDO** al revés que en `pricing`, porque un
teaser SÍ es algo a lo que se va; y **el block no formatea fechas** (la app
compone `RelativeTime`, verificado: «anteayer», «hace 6 días»).
⚠️ **Dos errores míos en esta tanda, los dos cazados MIRANDO la captura y no la
sonda**: el `minChildWidth` por defecto (`20rem`) sólo cabía DOS veces en la
medida `lg` —`(992+32)/(320+32) = 2.9`— cuando las refs lideran con tres; a
`18rem` mide 3/2/1 pistas en 1280/768/375. Y el segundo intento de arreglarlo
**no se aplicó y no me enteré**: lancé el `replace` sin `assert` y prettier había
juntado el destructuring en una línea. Culpé a la caché del dev server antes de
mirar el fichero. **Todo `replace` lleva `assert`.**

@ -0,0 +1,109 @@
# ArticleGrid
## Function
The editorial band: the latest articles, as teasers. What the reference catalogs
call **«Blog»** — named here for the PAGE function instead, because a blog is a
whole section of a site while this is the row of recent posts sitting inside
another page. The element it renders is an `<article>`, so the name and the
markup agree.
## Composition map
| Part | Composes | Notes |
| ----------------- | ------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| root | bare `<section>` + `Section` + `Container` + `Stack` | places the header and the row |
| `.Header` | `Box` + `Stack` | the app's section `Heading` + lead |
| `.Articles` | `AutoGrid` + `data-stagger` | fluid row of equal-height teasers; the stagger rhythm is structural. `minChildWidth` is `18rem` **because it was measured**: `20rem` only fits twice in the `lg` measure |
| `.Article` | a real `<article>` wrapping `Motion` + `Card` (outline) | IS the reveal, so it stays the row's direct child; `featured` → accent; `lift` ON by default |
| `.ArticleMedia` | `AspectRatio` (16/9) | so a row of teasers crops alike whatever the app drops in |
| `.ArticleMeta` | `Group` | category · date · reading time — the app composes `RelativeTime` / `FormatDate` |
| `.ArticleTitle` | `Heading` (level 3) | the app's `Link` goes INSIDE it |
| `.ArticleExcerpt` | `Text` `as="p"` (muted, sm) | the opening lines |
| `.ArticleAuthor` | `Group` + `Stack` | the `avatar` slot + name, pinned to the card's bottom |
**Landmark + headings**: each teaser is a real `<article>`, so assistive tech can
jump from one to the next. The root `<section>` does **not** name itself — the app
passes `aria-labelledby` pointing at its own heading if it wants an exposed
region. `.ArticleTitle` emits `h3` and takes `level` to move under a different
parent; the section's own heading is the app's `h2`.
## Coordination
_Position under the 2026-07-31 doctrine ([`architecture/blocks.md`](../../../../docs/architecture/blocks.md) §«Coordination»)._
**Owns nothing.** Its parts repeat, they do not talk to each other: same shape as
`feature-grid` and `testimonials`. Articles, dates and links are the app's.
## Decisions
**2026-08-18 — the two open questions of its fase 0** (plan F2b, stretch C):
- **The name.** «Blog» is the term the references use and it is ambiguous: a blog
is a section of a SITE, and this is a row inside another page. The tier names by
page function (`feature-grid`, `stats-band`, `logo-cloud`), and the element this
renders is an `<article>` — so `article-grid` makes name, function and markup
agree. «Blog» survives where a consumer looks for it: this first line and the
catalog label («Article grid (blog)»).
- **`<article>` vs F21, and it needed no canon change.** A teaser must be an
`<article>` or it reads to assistive tech as a box of loose text — but the canon
`Card` is a fixed `<div>` (F21: no layout primitive changes element). The plan
reserved a decision between adding `as` to `Card` or filing a gap; building it
showed a third way that costs nothing: **the block renders the `<article>`
itself and composes the `Card` inside**. The tier already renders its own
sectioning elements bare — `site-footer` a `<footer>`, `site-header` a
`<header>`, everyone a `<section>` — so the element is the block's and the
surface stays the canon's. No `as`, no gap.
- **The whole card is NOT a link**, which is where most references end up. A
teaser also carries a category chip and a byline, and nesting interactive
elements inside a link is invalid markup. The link lives in the title, so there
is exactly one per teaser.
- **`lift` defaults ON here**, the opposite of `pricing`: a teaser IS a thing you
go to, so the card answering the pointer is honest rather than a false promise.
It still does not make the card a control.
- **The block formats no dates.** `RelativeTime` / `FormatDate` are composed by
the app inside `.ArticleMeta` — the same rule that keeps currency out of
`pricing`.
## Demo
`web/routes/blocks/article-grid/` — six teasers with real editorial copy, a
category chip, a relative date and a byline, with live control of the count, the
media and the byline. Mini-page in `ArticleGridSite.svelte`.
## Equivalencias
_La matriz del tramo B: cada variante que las referencias shippean, frente a la
composición nuestra que la logra. **Una receta que no se ha visto en el navegador
NO entra en esta tabla**._
| Variante de la referencia | Refs | Receta | Demostrada en | Estado |
| ----------------------------------- | ----------------------------------------- | ---------------------------------------------- | ------------------------ | ----------------------------------------------------------------------------------------------------------------------- |
| Rejilla de teasers con imagen | TW (Blog 7) · Untitled · Flowbite · Prime | `.Articles` + `.Article` con el slot `media` | por defecto | cubierta |
| Sin imagen (sólo texto) | TW · Untitled | omitir el slot `media` | control `media` | cubierta |
| Chip de categoría | TW · Untitled | `.ArticleMeta` con el `Badge` de la app | por defecto | cubierta |
| Fecha relativa («hace 2 días») | Untitled · Flowbite | `.ArticleMeta` con el `RelativeTime` del canon | por defecto | cubierta |
| Firma con avatar | TW · Untitled | `.ArticleAuthor` con el slot `avatar` | control `firma` | cubierta |
| 2 · 3 columnas | todas | `.Articles columns` o `minChildWidth` | control `artículos` | cubierta |
| Artículo destacado | TW · Untitled | `.Article featured` (acento del `Card`) | por defecto (el primero) | cubierta |
| Un destacado grande + tres pequeños | TW · Untitled | — | — | gap: `article-grid-featured-layout` (rejilla asimétrica; otra disposición, entra como `layout` cuando una demo la pida) |
| Lista horizontal (foto al lado) | TW | — | — | gap: `article-grid-row-layout` (es lo que `feature-split` resuelve en otra clave) |
| Paginación o filtro por categoría | Flowbite · Prime | — | — | no se ofrece: es estado de datos del app, misma frontera que el buscador de `faq` |
## Gaps
| Gap | Disposition |
| --------------------------------------------- | --------------------------------------------------------------------------------------------------- |
| **Featured layout** (one large + three small) | **deferred** — an asymmetric grid is a different arrangement; enters as a `layout` when a demo asks |
| **Horizontal row** (image beside the copy) | **deferred** — `feature-split` already solves that shape in another key |
| **Pagination / category filter** | **app-land** — data state, which the block does not invent |
| **Cover images** | **app-land** — no photography in the framework; the demo composes tinted `Surface` placeholders |
## Found while composing
- **A block can render the semantic element the canon cannot.** F21 says no
layout primitive changes element, and that looked like it blocked `<article>`
here. It did not: the element is the BLOCK's job (the tier has rendered
`<footer>`, `<header>` and `<section>` bare since F2.1) and the `Card` is the
surface inside it. Worth remembering the next time F21 looks like a wall — it
only walls the primitive, not the composition.

@ -0,0 +1,17 @@
<script lang="ts">
/** ArticleGrid.ArticleAuthor — the byline, pinned to the card's bottom edge
* (`margin-block-start: auto`) so a row of teasers lines its authors up
* regardless of excerpt length. Same shape as `testimonials.Author`. */
import { Group } from '$uix/eidos/components/group';
import { Stack } from '$uix/eidos/components/stack';
import type { ArticleGridArticleAuthorProps } from './types';
let { avatar, children, ...rest }: ArticleGridArticleAuthorProps = $props();
</script>
<Group gap={3} align="center" justify="start" marginTop="auto" {...rest}>
{@render avatar?.()}
<Stack gap={0} align="start">
{@render children?.()}
</Stack>
</Group>

@ -0,0 +1,16 @@
<script lang="ts">
/** ArticleGrid.ArticleExcerpt — the opening lines. */
import { Text } from '$uix/eidos/components/text';
import type { ArticleGridArticleExcerptProps } from './types';
let {
size = 'sm',
color = 'muted',
children,
...rest
}: ArticleGridArticleExcerptProps = $props();
</script>
<Text as="p" {size} {color} wrap="pretty" {...rest}>
{@render children?.()}
</Text>

@ -0,0 +1,15 @@
<script lang="ts">
/** ArticleGrid.ArticleMedia — the teaser's image box. An `AspectRatio` so a row
* of teasers crops alike, whatever the app drops inside. */
import { AspectRatio } from '$uix/eidos/components/aspect-ratio';
import { Box } from '$uix/eidos/components/box';
import type { ArticleGridArticleMediaProps } from './types';
let { ratio = 16 / 9, children }: ArticleGridArticleMediaProps = $props();
</script>
<Box width="100%">
<AspectRatio {ratio}>
{@render children?.()}
</AspectRatio>
</Box>

@ -0,0 +1,13 @@
<script lang="ts">
/** ArticleGrid.ArticleMeta — category, date, reading time. The block never
* formats a date: the app composes `FormatDate` / `RelativeTime` inside, the
* same way `pricing` never formats currency. */
import { Group } from '$uix/eidos/components/group';
import type { ArticleGridArticleMetaProps } from './types';
let { gap = 3, children, ...rest }: ArticleGridArticleMetaProps = $props();
</script>
<Group {gap} align="center" justify="start" {...rest}>
{@render children?.()}
</Group>

@ -0,0 +1,13 @@
<script lang="ts">
/** ArticleGrid.ArticleTitle — the teaser's heading. The app puts its `Link`
* inside: the card as a whole is not a link, because a teaser also holds a
* category chip and an author, and a link inside a link is invalid markup. */
import { Heading } from '$uix/eidos/components/heading';
import type { ArticleGridArticleTitleProps } from './types';
let { level = 3, size = 'sm', children, ...rest }: ArticleGridArticleTitleProps = $props();
</script>
<Heading {level} {size} wrap="pretty" {...rest}>
{@render children?.()}
</Heading>

@ -0,0 +1,38 @@
<script lang="ts">
/**
* ArticleGrid.Article — one teaser.
*
* A real `<article>` wrapping the canon `Card`, not a `Card` pretending to be
* one: the canon primitive is a fixed `<div>` (F21) and a teaser that is not an
* `<article>` reads to assistive tech as a box of loose text. The tier already
* renders its own sectioning elements bare, so the element is the block's and
* the surface stays the canon's — no `as` prop needed anywhere.
*
* `lift` is ON here, unlike in `pricing`: a teaser IS a thing you go to, so the
* pointer answering is honest. It still does not make the card a control — the
* title holds the app's `Link`, and nesting a link inside a link is what the
* references end up with when they make the whole card clickable.
*/
import { Card } from '$uix/eidos/components/card';
import { Stack } from '$uix/eidos/components/stack';
import { Motion } from '$uix/eidos/components/motion';
import type { ArticleGridArticleProps } from './types';
let { featured = false, lift = true, media, children }: ArticleGridArticleProps = $props();
</script>
<!-- The Article IS the viewport Motion and a DIRECT child of the staggered row,
so the foundation hands it its structural index and the teasers arrive one
after another. -->
<Motion trigger="viewport">
<article style="height: 100%">
<Card variant="outline" color={featured ? 'primary' : 'neutral'} size="lg" height="100%" {lift}>
<Stack gap={4} height="100%" align="start">
{#if media}
{@render media()}
{/if}
{@render children?.()}
</Stack>
</Card>
</article>
</Motion>

@ -0,0 +1,19 @@
<script lang="ts">
/** ArticleGrid.Articles — the responsive row the teasers lay out in. Fluid by
* default; the cards stretch to equal height so a row lines its authors up. */
import { AutoGrid } from '$uix/eidos/components/auto-grid';
import type { ArticleGridArticlesProps } from './types';
let { minChildWidth = '18rem', columns, gap = 8, children }: ArticleGridArticlesProps = $props();
</script>
<AutoGrid
minChildWidth={columns ? undefined : minChildWidth}
{columns}
{gap}
align="stretch"
width="100%"
data-stagger
>
{@render children?.()}
</AutoGrid>

@ -0,0 +1,15 @@
<script lang="ts">
/** ArticleGrid.Header — the section's own heading and lead. The app owns the
* words and the level; the block only caps the measure. */
import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import type { ArticleGridHeaderProps } from './types';
let { maxWidth = '48rem', children, ...rest }: ArticleGridHeaderProps = $props();
</script>
<Box {maxWidth} {...rest}>
<Stack gap={3} align="start">
{@render children?.()}
</Stack>
</Box>

@ -0,0 +1,30 @@
<script lang="ts">
/**
* ArticleGrid — the editorial band: the latest articles, as teasers.
*
* NAMED for the page function, not for the site section. The references call
* this «Blog», but a blog is a whole section of a site while this is the row of
* recent posts INSIDE another page — and the tier names by page function
* (`feature-grid`, `stats-band`, `logo-cloud`). The element it renders is an
* `<article>`, so the name and the markup agree. «Blog» stays in the first line
* of the README and in the catalog label, which is what a consumer searches for.
*
* B contract: canon components only — no `.css`, no morfo, no strings.
*/
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import type { ArticleGridProps } from './types';
let { containerSize = 'lg', sectionSize = 'xl', children, ...rest }: ArticleGridProps = $props();
</script>
<section {...rest}>
<Section size={sectionSize}>
<Container size={containerSize}>
<Stack gap={10} align="stretch">
{@render children?.()}
</Stack>
</Container>
</Section>
</section>

@ -0,0 +1,72 @@
// ArticleGrid — the editorial band: the latest articles as teasers. What the
// reference catalogs call «Blog».
//
// import { ArticleGrid } from '$blocks/article-grid';
// import { RelativeTime } from '$uix/eidos/components/relative-time';
//
// <ArticleGrid>
// <ArticleGrid.Header>
// <Heading level={2}>Del blog</Heading>
// </ArticleGrid.Header>
// <ArticleGrid.Articles>
// <ArticleGrid.Article>
// {#snippet media()}
// <ArticleGrid.ArticleMedia><Image src="…" alt="" /></ArticleGrid.ArticleMedia>
// {/snippet}
// <ArticleGrid.ArticleMeta>
// <Badge size="xs">Producto</Badge>
// <RelativeTime date={post.date} />
// </ArticleGrid.ArticleMeta>
// <ArticleGrid.ArticleTitle><Link href={post.href}>{post.title}</Link></ArticleGrid.ArticleTitle>
// <ArticleGrid.ArticleExcerpt>{post.excerpt}</ArticleGrid.ArticleExcerpt>
// </ArticleGrid.Article>
// </ArticleGrid.Articles>
// </ArticleGrid>
//
// The block formats no dates — the app composes `FormatDate` / `RelativeTime`,
// the same way `pricing` formats no currency (B-7).
import ArticleGridComponent from './article-grid.svelte';
import Header from './article-grid-header.svelte';
import Articles from './article-grid-articles.svelte';
import Article from './article-grid-article.svelte';
import ArticleMedia from './article-grid-article-media.svelte';
import ArticleMeta from './article-grid-article-meta.svelte';
import ArticleTitle from './article-grid-article-title.svelte';
import ArticleExcerpt from './article-grid-article-excerpt.svelte';
import ArticleAuthor from './article-grid-article-author.svelte';
type ArticleGridNamespace = typeof ArticleGridComponent & {
Header: typeof Header;
Articles: typeof Articles;
Article: typeof Article;
ArticleMedia: typeof ArticleMedia;
ArticleMeta: typeof ArticleMeta;
ArticleTitle: typeof ArticleTitle;
ArticleExcerpt: typeof ArticleExcerpt;
ArticleAuthor: typeof ArticleAuthor;
};
const ArticleGrid = ArticleGridComponent as ArticleGridNamespace;
ArticleGrid.Header = Header;
ArticleGrid.Articles = Articles;
ArticleGrid.Article = Article;
ArticleGrid.ArticleMedia = ArticleMedia;
ArticleGrid.ArticleMeta = ArticleMeta;
ArticleGrid.ArticleTitle = ArticleTitle;
ArticleGrid.ArticleExcerpt = ArticleExcerpt;
ArticleGrid.ArticleAuthor = ArticleAuthor;
export { ArticleGrid };
export default ArticleGrid;
export type {
ArticleGridProps,
ArticleGridHeaderProps as HeaderProps,
ArticleGridArticlesProps as ArticlesProps,
ArticleGridArticleProps as ArticleProps,
ArticleGridArticleMediaProps as ArticleMediaProps,
ArticleGridArticleMetaProps as ArticleMetaProps,
ArticleGridArticleTitleProps as ArticleTitleProps,
ArticleGridArticleExcerptProps as ArticleExcerptProps,
ArticleGridArticleAuthorProps as ArticleAuthorProps
} from './types';

@ -0,0 +1,95 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ContainerSize } from '$uix/eidos/components/container';
import type { SectionSize } from '$uix/eidos/components/section';
import type { BoxProps } from '$uix/eidos/components/box';
import type { HeadingProps } from '$uix/eidos/components/heading';
import type { TextProps } from '$uix/eidos/components/text';
import type { GroupProps } from '$uix/eidos/components/group';
import type { AutoGridProps } from '$uix/eidos/components/auto-grid';
export type ArticleGridProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/** Content measure. Passed straight to `Container`. @default 'lg' */
containerSize?: ContainerSize;
/** Block-axis padding. Passed straight to `Section`. @default 'xl' */
sectionSize?: SectionSize;
/** `Header` and `Articles` in composition order. */
children?: Snippet;
};
/** Wraps a `Box` (measure-cap). The app's section `Heading` + `Text` go inside. */
export type ArticleGridHeaderProps = BoxProps;
/** Wraps the responsive row. Fluid by default; the cards stretch to equal height. */
export type ArticleGridArticlesProps = {
/**
* Minimum article inline-size before wrapping. Measured, not picked: at the
* `lg` measure (992px of row) with the default gap, `20rem` only fits TWICE
* — `(992 + 32) / (320 + 32) = 2.9` — and the references lead with three.
* `18rem` fits three and still wraps to two at `md`.
* @default '18rem'
*/
minChildWidth?: AutoGridProps['minChildWidth'];
/** Fixed column count instead of fluid. */
columns?: number;
/** Gap between articles (space scale). @default 8 */
gap?: number;
children?: Snippet;
};
/**
* One article.
*
* Renders a real `<article>` and composes a `Card` INSIDE it — not the other way
* round. The canon `Card` is a fixed `<div>` (F21: no layout primitive changes
* element), and a teaser that is not an `<article>` reads to assistive tech as a
* box of loose text. The tier already renders its own sectioning elements bare
* (`site-footer` a `<footer>`, `site-header` a `<header>`), so this needs no `as`
* prop in the canon and no gap: the element is the block's, the surface is the
* canon's.
*/
export type ArticleGridArticleProps = {
/** Emphasise this article (accent border). @default false */
featured?: boolean;
/** Let the card lift under the pointer — forwarded to `Card`'s own axis. @default true */
lift?: boolean;
/** The media slot, above the copy. The app composes `Image` / `AspectRatio`. */
media?: Snippet;
/** `ArticleMeta` / `ArticleTitle` / `ArticleExcerpt` / `ArticleAuthor`. */
children?: Snippet;
};
/** Wraps an `AspectRatio` so a row of teasers crops its images alike. */
export type ArticleGridArticleMediaProps = {
/** Aspect ratio of the media box. @default 16 / 9 */
ratio?: number;
children?: Snippet;
};
/**
* The metadata row — category chip, date, reading time. The app composes them:
* dates go through `FormatDate` / `RelativeTime`, because a block formats no
* dates, the same way `pricing` formats no currency.
*/
export type ArticleGridArticleMetaProps = GroupProps;
/**
* Wraps a `Heading` (level 3 by default).
*
* The app puts its `Link` INSIDE. The whole card is deliberately not a link: a
* teaser holds a category chip and an author link too, and nesting interactive
* elements inside a link is invalid markup — the references that make the card
* clickable end up with a link inside a link.
*/
export type ArticleGridArticleTitleProps = HeadingProps;
/** Wraps a muted `Text` — the teaser's opening lines. */
export type ArticleGridArticleExcerptProps = TextProps;
/** Wraps a `Group`, pinned to the card's bottom so a row of teasers lines its
* authors up. The app drops an `Avatar` in the `avatar` slot. */
export type ArticleGridArticleAuthorProps = Omit<GroupProps, 'children'> & {
/** The author's face — the app composes `Avatar`. */
avatar?: Snippet;
children?: Snippet;
};

@ -28,6 +28,7 @@ export const BLOCK_CATALOG: BlockGroup[] = [
{ slug: 'faq', label: 'FAQ', shipped: true },
{ slug: 'stats-band', label: 'Stats band', shipped: true },
{ slug: 'logo-cloud', label: 'Logo cloud', shipped: true },
{ slug: 'article-grid', label: 'Article grid (blog)', shipped: true },
{ slug: 'cta', label: 'CTA', shipped: true },
{ slug: 'newsletter', label: 'Newsletter', shipped: true },
{ slug: 'site-footer', label: 'Site footer', shipped: true },

@ -0,0 +1,199 @@
<script lang="ts">
/**
* ArticleGrid 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 ArticleGridSite from './ArticleGridSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
let count = $state<2 | 3 | 6>(3);
let withMedia = $state(true);
let withAuthor = $state(true);
const previewSrc = $derived(
`/blocks/article-grid/preview?count=${count}&media=${withMedia}&author=${withAuthor}`
);
</script>
<BlockDemo
name="Article grid"
slug="$blocks/article-grid"
{previewSrc}
previewHeight="820px"
meta={[
{ key: 'compone', value: 'Section · Container · AutoGrid · Card · AspectRatio · Avatar' },
{ key: 'landmark', value: 'article por teaser' },
{ key: 'tipo', value: 'compound (.Article repite)' },
{ key: 'strings propias', value: '0' }
]}
>
{#snippet preview()}
<ArticleGridSite {count} {withMedia} {withAuthor} />
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">artículos</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[String(count)]}
onValueChange={(v) => (count = (Number(v[0] ?? count) || 3) as 2 | 3 | 6)}
aria-label="número de artículos"
>
{#each [2, 3, 6] as value (value)}
<ToggleGroup.Item value={String(value)}>{value}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">media</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[withMedia ? 'sí' : 'no']}
onValueChange={(v) => (withMedia = (v[0] ?? 'sí') === 'sí')}
aria-label="imagen del teaser"
>
<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">firma</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[withAuthor ? 'sí' : 'no']}
onValueChange={(v) => (withAuthor = (v[0] ?? 'sí') === 'sí')}
aria-label="firma del autor"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
</Wrap>
{/snippet}
{#snippet lede()}
La banda editorial: los últimos artículos, como teasers. Lo que las referencias llaman
<em>blog</em> — aquí nombrado por su función de PÁGINA, porque un blog es una sección entera de
un sitio y esto es la fila de artículos recientes DENTRO de otra página. El elemento que
renderiza es un <Code>article</Code>, así que el nombre y el markup coinciden.
{/snippet}
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
El block coloca; los artículos, las fechas y los enlaces son de la app.
</Text>
<Stack gap={3}>
<DocRow term=".Article">
Un <Code>article</Code> DE VERDAD envolviendo la <Code>Card</Code> del canon, no una
<Code>Card</Code> fingiendo serlo: el primitivo es un <Code>div</Code> fijo (F21) y un teaser
que no es <Code>article</Code> se lee como una caja de texto suelto. El tier ya renderiza sus
propios elementos de seccionado —<Code>site-footer</Code> un
<Code>footer</Code>, <Code>site-header</Code> un <Code>header</Code>—, así que el elemento
es del block y la superficie sigue siendo del canon: sin <Code>as</Code> en el canon y sin gap.
</DocRow>
<DocRow term=".ArticleTitle">
El enlace de la app va DENTRO del título. La tarjeta entera no es un enlace a propósito:
el teaser lleva además un chip de categoría y una firma, y anidar interactivos dentro de
un enlace es marcado inválido — es donde acaban las referencias que hacen clicable toda la
tarjeta.
</DocRow>
<DocRow term=".ArticleMeta">
Categoría, fecha, tiempo de lectura. <strong>El block no formatea fechas</strong>: la app
compone <Code>RelativeTime</Code>/<Code>FormatDate</Code>, la misma regla que mantiene la
moneda fuera de <Code>pricing</Code>.
</DocRow>
<DocRow term="realce">
<Code>lift</Code> viene ENCENDIDO aquí, al revés que en <Code>pricing</Code>: un teaser SÍ
es algo a lo que se va, así que el puntero respondiendo es honesto. Sigue sin convertir la
tarjeta en control.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="ArticleGrid">
<Code>containerSize</Code> (def. <Code>lg</Code>) · <Code>sectionSize</Code> (def.
<Code>xl</Code>).
</DocRow>
<DocRow term=".Articles">
<Code>minChildWidth</Code> (def. <Code>20rem</Code>) · <Code>columns</Code> ·
<Code>gap</Code> (def. <Code>8</Code>).
</DocRow>
<DocRow term=".Article">
<Code>featured</Code> (bool) · <Code>lift</Code> (bool, def. <Code>true</Code>) ·
<Code>media</Code> (snippet).
</DocRow>
<DocRow
term=".ArticleMedia · .ArticleMeta · .ArticleTitle · .ArticleExcerpt · .ArticleAuthor"
>
<Code>.ArticleMedia</Code> toma <Code>ratio</Code> (def. 16/9) y
<Code>.ArticleAuthor</Code> el slot <Code>avatar</Code>; el resto son conveniencias sobre
<Code>Heading</Code>/<Code>Text</Code>/<Code>Group</Code>.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Elemento">
Cada teaser es un <Code>article</Code>, así que la tecnología asistiva puede saltar de uno a
otro. La sección no se nombra sola: la app le pasa <Code>aria-labelledby</Code> apuntando a su
propio encabezado si la quiere como región.
</DocRow>
<DocRow term="Encabezados">
<Code>.ArticleTitle</Code> emite <Code>h3</Code> y acepta <Code>level</Code> para moverse bajo
otro padre. La sección la encabeza la app con su <Code>h2</Code>.
</DocRow>
<DocRow term="Un solo enlace por teaser">
El de su título. No hay enlace envolvente que anide interactivos.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Artículo destacado grande">
<strong>Diferido</strong> — la variante «uno grande + tres pequeños» recurre en las refs; es
otra disposición (rejilla asimétrica) y entra como <Code>layout</Code> cuando una demo la pida.
</DocRow>
<DocRow term="Lista horizontal">
<strong>Diferido</strong> — foto a un lado y texto al otro por fila; es lo mismo que ya
resuelve <Code>feature-split</Code> en otra clave.
</DocRow>
<DocRow term="Paginación / filtro por categoría">
<strong>App-land</strong> — es estado de datos, y el block no lo inventa (misma frontera que
el buscador de <Code>faq</Code>).
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="Por qué no se llama blog">
Un blog es una sección entera de un sitio; esto es la fila de artículos recientes dentro de
otra página, y el tier nombra por función de página (<Code>feature-grid</Code>,
<Code>stats-band</Code>, <Code>logo-cloud</Code>). «Blog» se queda en la primera línea del
README y en la etiqueta del catálogo, que es lo que un consumidor busca.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,172 @@
<script lang="ts">
/**
* The article-grid mini-page: six teasers with real editorial copy, a category
* chip, a relative date and a byline.
*
* The DATE is the app's: `RelativeTime` of the canon, because a block formats
* no dates — the same rule that keeps currency out of `pricing`.
*/
import { ArticleGrid } from '$blocks/article-grid';
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Badge } from '$uix/eidos/components/badge';
import { Link } from '$uix/eidos/components/link';
import { Avatar } from '$uix/eidos/components/avatar';
import { Surface } from '$uix/eidos/components/surface';
import { RelativeTime } from '$uix/eidos/components/relative-time';
let {
count = 3,
withMedia = true,
withAuthor = true
}: { count?: 2 | 3 | 6; withMedia?: boolean; withAuthor?: boolean } = $props();
type Post = {
title: string;
excerpt: string;
category: string;
days: number;
author: string;
initials: string;
color: string;
featured: boolean;
};
const posts: Post[] = [
{
title: 'Por qué dejamos de muestrear eventos',
excerpt:
'El muestreo abarata el almacenamiento y encarece cada decisión: cuando el número no cuadra, nadie se fía del panel.',
category: 'Producto',
days: 2,
author: 'Ana Ruiz',
initials: 'AR',
color: 'indigo',
featured: true
},
{
title: 'Cohortes al día sin reprocesar nada',
excerpt:
'Mantener los agregados vivos cuesta más al escribir y devuelve el tiempo entero al consultar.',
category: 'Ingeniería',
days: 6,
author: 'Marco Vidal',
initials: 'MV',
color: 'teal',
featured: false
},
{
title: 'Lo que aprendimos rompiendo el pipeline',
excerpt:
'Una noche sin ingesta enseña más sobre tu arquitectura que seis meses de diagramas.',
category: 'Ingeniería',
days: 13,
author: 'Lucía Peña',
initials: 'LP',
color: 'orange',
featured: false
},
{
title: 'Precios por volumen, no por asiento',
excerpt:
'Cobrar por persona castiga justo lo que quieres: que todo el equipo mire los datos.',
category: 'Negocio',
days: 21,
author: 'Diego Sáez',
initials: 'DS',
color: 'green',
featured: false
},
{
title: 'Auditoría y residencia de datos, en serio',
excerpt: 'Lo que un equipo de seguridad pregunta de verdad cuando revisa una herramienta.',
category: 'Seguridad',
days: 30,
author: 'Sofía Marín',
initials: 'SM',
color: 'plum',
featured: false
},
{
title: 'Una alerta que cazó una fuga de conversión',
excerpt: 'La historia de un umbral bien puesto y de las cuatro horas que ahorró.',
category: 'Producto',
days: 45,
author: 'Ana Ruiz',
initials: 'AR',
color: 'indigo',
featured: false
}
];
const shown = $derived(posts.slice(0, count));
const dateOf = (days: number) => new Date(Date.parse('2026-08-18T09:00:00Z') - days * 864e5);
</script>
<ArticleGrid>
<ArticleGrid.Header>
<Heading level={2}>Del blog</Heading>
<Text size="lg" color="muted">
Lo que cambiamos en el producto y el porqué detrás de cada decisión.
</Text>
</ArticleGrid.Header>
<ArticleGrid.Articles>
{#each shown as post (post.title)}
<ArticleGrid.Article featured={post.featured}>
{#snippet media()}
{#if withMedia}
<ArticleGrid.ArticleMedia>
<!-- Marcador compuesto con el canon: ninguna foto en el framework. -->
<Surface variant="solid" color={post.color} gradient rounded height="100%" />
</ArticleGrid.ArticleMedia>
{/if}
{/snippet}
<ArticleGrid.ArticleMeta>
<Badge size="xs" variant="soft" color={post.featured ? 'primary' : 'neutral'}>
{post.category}
</Badge>
<!-- La fecha la formatea la APP con el canon: el block no formatea fechas. -->
<Text size="xs" color="muted">
<RelativeTime value={dateOf(post.days)} />
</Text>
</ArticleGrid.ArticleMeta>
<ArticleGrid.ArticleTitle>
<!-- El enlace va DENTRO del título: la tarjeta entera no es un enlace,
porque el teaser lleva también un chip y una firma, y un enlace
dentro de otro es marcado inválido. -->
<Link href="#articulo">{post.title}</Link>
</ArticleGrid.ArticleTitle>
<ArticleGrid.ArticleExcerpt>{post.excerpt}</ArticleGrid.ArticleExcerpt>
{#if withAuthor}
<ArticleGrid.ArticleAuthor>
{#snippet avatar()}
<Avatar size="sm" variant="solid" color={post.color}>
<Avatar.Fallback>{post.initials}</Avatar.Fallback>
</Avatar>
{/snippet}
<Text size="sm" weight="medium">{post.author}</Text>
</ArticleGrid.ArticleAuthor>
{/if}
</ArticleGrid.Article>
{/each}
</ArticleGrid.Articles>
</ArticleGrid>
<!-- A floor so the page reads as a page. -->
<Section size="lg">
<Container size="md">
<Stack gap={3} align="center">
<Text color="muted" align="center">
¿Quieres el resumen cada martes? <Link href="#boletin">Suscríbete al boletín</Link>.
</Text>
</Stack>
</Container>
</Section>

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

@ -0,0 +1,15 @@
<script lang="ts">
/**
* Standalone page for the article-grid mini-site — same component the demo
* renders inline, served as its OWN document for the device-width frame.
*/
import { page } from '$app/state';
import ArticleGridSite from '../ArticleGridSite.svelte';
const params = $derived(page.url.searchParams);
const count = $derived((Number(params.get('count') ?? 3) || 3) as 2 | 3 | 6);
const withMedia = $derived(params.get('media') !== 'false');
const withAuthor = $derived(params.get('author') !== 'false');
</script>
<ArticleGridSite {count} {withMedia} {withAuthor} />
Loading…
Cancel
Save

Powered by TurnKey Linux.