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
parent
33031c2ad9
commit
313e4d52fa
@ -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;
|
||||
};
|
||||
@ -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…
Reference in new issue