feat(blocks): F2.7 `stats-band` — la cifra que se cuenta sola

La fila de números que respalda lo que la página acaba de afirmar. Compound
(`.Stat` repite, sin contexto): `<StatsBand>` + `.Stat` + `.Value` + `.Label`.

- **Compone, no reinventa**: la semántica de KPI es el `Metrics` del canon, leído
  de su API y pasado tal cual (precedente `faq`/`Accordion`). `Metrics` es
  surfaceless, que es exactamente lo que una banda necesita: no son tarjetas de
  panel. Un delta, un icono o un sparkline son `Metrics.Delta`/`.Icon`/`.Chart`
  que la app compone dentro — el block no los re-expone.
- **La superación literal del dossier**: `<StatsBand.Value count={12500} />`
  compone `CountUp`, así que la cifra se cuenta sola al entrar en pantalla.
  NINGUNA referencia puede shipearlo: todas entregan markup estático. El formato
  locale-aware y el salto directo bajo reduced-motion vienen del `CountUp`, no
  de aquí.
- **`count` es opt-in, nunca default**: una cifra que se anima sin que el lector
  lo pida es ruido, y algunas no son contables («99,98 %»). Sin `count`, la
  cifra la pone la app por children.
- **Ni una palabra ni un separador salen del block** (B-7): las palabras por
  children, el formato por `uix.format` dentro del `CountUp`.
- Escalonado estructural: `data-stagger` en la banda y cada `.Stat` es un
  `Motion trigger="viewport"`, así que las cifras aterrizan una tras otra sin un
  solo milisegundo escrito a mano.

Verificado en navegador (con la banda bajo el pliegue, para cazar el conteo desde
el primer fotograma): 4 cifras con índices estructurales 0,1,2,3 contando
—9377→11.678 · 255→318 · 36→45—, el porcentaje quieto por no ser contable, y el
separador de millares por locale (`11.678`). Cero errores de página.
`blocks:check` verde (8 blocks) · `svelte-check` sin errores propios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 2 months ago
parent c39170abbf
commit 4e785d32bf

@ -1,7 +1,7 @@
# CONTINUE — F2 blocks de sitio (handoff, act. 2026-07-29)
Estado al parar: **F1 CERRADA (8/8)** y **F2 7/14** — site-header · hero ·
feature-grid · feature-split · pricing · testimonials · faq. Encima de eso se dio
Estado al parar: **F1 CERRADA (8/8)** y **F2 8/14** — site-header · hero ·
feature-grid · feature-split · pricing · testimonials · faq · stats-band. Encima de eso se dio
una **pasada de calidad** (`PLAN-blocks-quality.md`): movimiento en los 7 blocks,
vídeo, intents semánticos y el arco `contact → commit`; más tres arreglos de
canon (`Grid align`, herencia de `--flex-*`/`--grid-*`, archetype del
@ -16,55 +16,16 @@ Todo commiteado y pusheado a `gita/alpha-0.1-sec-dom`.
## Lo siguiente
**F2.7 `stats-band`** (ficha en `PLAN-blocks.md` §F2.7). Compone `Section` +
`Group`/`AutoGrid` + `Metrics` + `CountUp` + `Text`. `.Stat` = valor + etiqueta;
`CountUp` opt-in por prop. v1 = banda 2–4 stats; los formatos numéricos llegan ya
formateados o vía `FormatNumber` del app (como pricing, el block no formatea). El
dossier dice: ninguna ref puede shippear count-up (markup estático) → es nuestra
superación literal. LEER `Metrics`/`CountUp` antes. Después: cta → newsletter →
site-footer → banner → team → contact → content-section.
**F2.4 pricing · F2.5 testimonials · F2.6 faq HECHOS.** faq = **proxy fino** de un
componente del canon (`Accordion`): cuando un block envuelve un componente
interactivo, LEE su API y la pasa tal cual (`.List` ES el `Accordion`), no
reinventa el comportamiento — solo ahorra andamiaje con `.Item`. Mismo criterio
para stats-band con `Metrics`/`CountUp`.
**F2.4 `pricing` + F2.5 `testimonials` HECHOS.** pricing = primer compound CON
CONTEXTO (el `Switch` escribe el periodo, los `PlanPrice` lo leen; contexto
reactivo vía `context.ts`, getter sobre `$bindable`, como `CardGroup`; el block
NUNCA formatea moneda). testimonials = compound SIN contexto (`.Item` repite).
Lecciones nuevas para los que vienen:
- **Escalas donor activadas** = `green/indigo/orange/plum/teal`. Una escala NO
activada (`cyan/ruby/amber/jade`) en `color` cae en SILENCIO a `primary`. Usa
las activadas.
- **`Card` NO tiene variant `surface`** (solo `soft/solid/outline/ghost`); `soft
neutral` es casi invisible en claro → usa `outline` para tarjetas que deben
leerse.
**F2.1 site-header · F2.2 hero · F2.3 feature-grid · F2.3b feature-split HECHOS**,
**+ primitivo canon `Mockup`** (`$uix/eidos/components/mockup`: `chrome` browser/
phone/plain + `url`) — úsalo para toda media enmarcada en los blocks que vienen
(pricing screenshots, testimonials, etc.), NO falsees un screenshot por demo.
Regla de forma del
tier (NO re-decidir): **compound solo cuando las partes COORDINAN o SE REPITEN**
(feature-grid.Item, pricing.Plan, faq.Item — partes que el app itera); **slots
de snippet para secciones de layout** (hero, site-header, cta, stats-band).
Lecciones de feature-grid que ahorran sangre en los compound que vienen:
- **Sub-componentes de bloque extienden los props del canon que envuelven**
(`BoxProps`, `StackProps`, `HeadingProps`…), **nunca `HTMLAttributes`** — el
`style: string|null` del atributo HTML crudo choca al hacer spread en el canon
(+ "union type too complex").
- **`.Items`/`.List` como envoltorio de la rejilla** deja la cabecera fuera sin
hacks de `grid-column`.
- **Chips de icono: `Surface variant="solid"`**, no `soft` (el soft en claro es
casi blanco, oklch 0.99, invisible).
- **Un slot de snippet cuyo nombre sea un atributo HTML** (`title`, `background`,
`media`…) colisiona → `string & Snippet`. Solución: `Omit<HTMLAttributes,
'…' | 'title'>` o nombra el slot distinto (`backdrop`). Pasó en `hero`.
- **svelte-check filtrado por ruta con backslashes**: escapa bien o filtra por
nombre de fichero (`rg -i "HeroSite"`), no por `routes\\blocks\\…` — un patrón
mal escapado oculta errores propios.
**F2.8 `cta`** (ficha en `PLAN-blocks.md` §F2.8). Compone `Section` + `Surface`
(el CTA se distingue por ACABADO, y eso ya es vocabulario del framework) +
`Heading` + `Text` + `Group` de `Button`s. Slots de snippet (es una sección de
layout, no partes que se repitan). Del dossier: paridad con el centrado; añadir
el arreglo **justified** (texto izq., botones dcha.) y valorar split-with-media.
Después: newsletter → site-footer → banner → team → contact → content-section.
**F2.7 `stats-band` HECHO** — compound sobre `Metrics` + `CountUp`. El `count` es
opt-in y el block no formatea ni escribe nada: el separador lo pone
`uix.format` dentro del `CountUp`. Es la superación literal del dossier.
## Deuda a decisión tuya
@ -144,7 +105,7 @@ galería leen esa única fuente) y `npm run blocks:check`.
## Estado de gates al parar
- `npm run blocks:check` verde (**7 blocks**).
- `npm run blocks:check` verde (**8 blocks**).
- `svelte-check` sin errores propios (la deuda restante es ajena).
- `vitest src/uix/eidos`: **3 fallos AJENOS** — `components/barcode/` sin
trackear (solo README) rompe el contrato de API de componentes. No es nuestro.

@ -1208,3 +1208,18 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
«¿aún tienes dudas?» es app-land (la demo la pone tras `.List`). Verificado: el
acordeón abre/cierra, claro/oscuro/RTL. Gates: `blocks:check` verde (7 blocks) ·
`svelte-check` sin errores propios. **Siguiente**: F2.7 `stats-band`.
- 2026-07-29 — **F2.7 `stats-band` HECHO**. Compound (`.Stat` repite, sin
contexto): `<StatsBand>` + `.Stat` + `.Value` + `.Label`. Compone el `Metrics`
del canon para la semántica de KPI —surfaceless, que es lo correcto: una banda
no son tarjetas de panel— y `CountUp` para la cifra que se cuenta sola al
entrar en pantalla: **la superación literal del dossier**, porque ninguna
referencia puede shipearla (todas entregan markup estático). `count` es
OPT-IN, nunca default (una cifra que se anima sin que el lector lo pida es
ruido, y «99,98 %» no es contable). El block NO formatea ni escribe: el
separador de millares sale de `uix.format` DENTRO del `CountUp` y las palabras
entran por children (B-7). Escalonado estructural (`data-stagger` en la banda,
cada `.Stat` un `Motion trigger="viewport"`). Verificado en navegador: 4 cifras
con índices 0,1,2,3 contando (9377→11.678 · 255→318 · 36→45), el porcentaje
quieto, separador por locale, cero errores de página. Gates: `blocks:check`
verde (8 blocks) · `svelte-check` sin errores propios. **Siguiente**: F2.8 `cta`.

@ -0,0 +1,71 @@
# StatsBand
## Function
The row of figures that backs the claims a page just made: 2–4 numbers with
their labels, usually between the features and the pricing. Short, factual, and
the one section where the number itself is the content.
## Composition map
| Part | Composes | Notes |
| --- | --- | --- |
| root | bare `<section>` + `Section` + `Container` + `AutoGrid` + `data-stagger` | fluid by `minChildWidth` (default `12rem`) or fixed `columns`; the stagger rhythm is structural |
| `.Stat` | `Motion` (`trigger="viewport"`) wrapping `Metrics` | IS the reveal, so it must stay the band's direct child to get its index; `align="center"` by default (a band is centred figures, not dashboard tiles) |
| `.Value` | `Metrics.Value` (+ `CountUp` when `count` is set) | the figure |
| `.Label` | `Metrics.Label` | what the figure counts |
The KPI semantics (label / value / delta) are the canon `Metrics`'s — the block
adds none of its own. `Metrics` is surfaceless, which is exactly right here: a
band is not a row of tiles.
## Why compound
`.Stat` **repeats** — the app maps over its figures — so it earns a compound API,
like `feature-grid` and `testimonials`. The parts do not coordinate, so there is
no context.
## The one-up: `count`
`<StatsBand.Value count={12500} />` composes `CountUp`: the figure counts up when
the band scrolls into view. **No reference can ship this** — they all ship static
markup, which is why the dossier calls it "nuestra superación literal". Both the
locale-aware formatting and the reduced-motion jump-to-final come from `CountUp`,
not from this block.
Omit `count` and the app renders the value itself through `children` (already
formatted, or composed with `FormatNumber`). **The block never formats a number
and never ships a word** (B-7).
## Decisions
**2026-07-29 — reference floor** (dossier §P1: Tailwind Plus «Stats 8» ·
Untitled UI «Metrics 16» · Flowbite):
- **Adopted**: the horizontal band of 2–4 figures — the format every reference
ships, and the dossier's own verdict is "paridad OK" on the skeleton.
- **Composed, not reinvented**: the figure/label pair is the canon `Metrics`,
read from its API and passed through (the `faq`/`Accordion` precedent). A delta
chip, an icon or a sparkline are `Metrics.Delta` / `.Icon` / `.Chart` the app
drops in — the block does not re-expose them.
- **`count` is opt-in**, never a default: a figure that animates when the reader
did not ask for motion is noise, and some figures ("99,98 %") are not counted
at all.
## Demo
`web/routes/blocks/stats-band/` — the block full-bleed on the page, with live
control of the column count and the count-up, and the device widths served from
`preview/`. Mini-page in `StatsBandSite.svelte`, shared by both surfaces.
## Gaps
| Gap | Disposition |
| --- | --- |
| **Split-with-image** (the band beside a photo) | **deferred** — a different arrangement (2 columns), recurring in the refs; enters as a `layout` or a sibling when a demo asks |
| **Timeline / stepped** variant | **deferred** — the canon `Timeline` exists; it is a different section, not a band |
| **Delta / trend per figure** | **app-land** — `Metrics.Delta` composes into `.Stat` today; the block re-exposing it would only add surface |
## Found while composing
- _(none yet — recorded here as they surface.)_

@ -0,0 +1,46 @@
// StatsBand — the row of figures that backs the claims a page just made.
// Compound (its `Stat` repeats), composing the canon `Metrics` for the KPI
// semantics and `CountUp` for the figure that counts itself up.
//
// import { StatsBand } from '$blocks/stats-band';
//
// <StatsBand>
// <StatsBand.Stat>
// <StatsBand.Value count={12500} />
// <StatsBand.Label>Eventos por segundo</StatsBand.Label>
// </StatsBand.Stat>
// <StatsBand.Stat>
// <StatsBand.Value>99,98 %</StatsBand.Value>
// <StatsBand.Label>Disponibilidad</StatsBand.Label>
// </StatsBand.Stat>
// </StatsBand>
//
// `count` is the one-up: no reference can ship a count-up, because they all ship
// static markup. Omit it and the app renders the value itself (B-7 — every word
// and every number is the app's). The stagger is structural: `data-stagger` on
// the band, each `Stat` a viewport `Motion`.
import StatsBandComponent from './stats-band.svelte';
import Stat from './stats-band-stat.svelte';
import Value from './stats-band-value.svelte';
import Label from './stats-band-label.svelte';
type StatsBandNamespace = typeof StatsBandComponent & {
Stat: typeof Stat;
Value: typeof Value;
Label: typeof Label;
};
const StatsBand = StatsBandComponent as StatsBandNamespace;
StatsBand.Stat = Stat;
StatsBand.Value = Value;
StatsBand.Label = Label;
export { StatsBand };
export default StatsBand;
export type {
StatsBandProps,
StatsBandStatProps as StatProps,
StatsBandValueProps as ValueProps,
StatsBandLabelProps as LabelProps
} from './types';

@ -0,0 +1,12 @@
<script lang="ts">
/** StatsBand.Label — what the figure counts, inside the canon `Metrics.Label`.
* The words are the app's. */
import { Metrics } from '$uix/eidos/components/metrics';
import type { StatsBandLabelProps } from './types';
let { children, ...rest }: StatsBandLabelProps = $props();
</script>
<Metrics.Label {...rest}>
{@render children?.()}
</Metrics.Label>

@ -0,0 +1,22 @@
<script lang="ts">
/**
* StatsBand.Stat — one figure. IS the viewport `Motion`, so it must stay the
* DIRECT child of the `[data-stagger]` band for the foundation to hand it its
* structural index; inside, the canon `Metrics` gives the KPI semantics
* (label / value / delta) without this block inventing any.
*
* `align` defaults to `center`: a band reads as a row of centred figures,
* unlike a dashboard tile, which is start-aligned.
*/
import { Metrics } from '$uix/eidos/components/metrics';
import { Motion } from '$uix/eidos/components/motion';
import type { StatsBandStatProps } from './types';
let { align = 'center', size = 'lg', children, ...rest }: StatsBandStatProps = $props();
</script>
<Motion trigger="viewport">
<Metrics {align} {size} {...rest}>
{@render children?.()}
</Metrics>
</Motion>

@ -0,0 +1,26 @@
<script lang="ts">
/**
* StatsBand.Value — the figure itself, inside the canon `Metrics.Value`.
*
* With `count`, it composes `CountUp`: the number counts up when the band
* scrolls into view. That is the tier's literal one-up — a static markup dump
* (which is what every reference ships) CANNOT do it. Locale-aware formatting
* and the reduced-motion jump-to-final both come from `CountUp`, not from here.
*
* Without `count`, the app renders the value itself through `children` (already
* formatted, or composed with `FormatNumber`). The block never formats.
*/
import { Metrics } from '$uix/eidos/components/metrics';
import { CountUp } from '$uix/eidos/components/count-up';
import type { StatsBandValueProps } from './types';
let { count, countOptions, children, ...rest }: StatsBandValueProps = $props();
</script>
<Metrics.Value value={count} {...rest}>
{#if count !== undefined}
<CountUp to={count} {...countOptions} />
{:else}
{@render children?.()}
{/if}
</Metrics.Value>

@ -0,0 +1,47 @@
<script lang="ts">
/**
* StatsBand — the row of figures that backs the claims a page just made.
* Compound: its `Stat` repeats (the app maps over 2–4 of them).
*
* B contract: composes canon components only — no `.css`, no morfo, no
* strings. The block places (Section · Container · AutoGrid); the app supplies
* every word and every number.
*/
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { AutoGrid } from '$uix/eidos/components/auto-grid';
import type { StatsBandProps } from './types';
let {
container = 'lg',
size = 'lg',
columns,
minChildWidth = '12rem',
gap = 8,
children,
...rest
}: StatsBandProps = $props();
</script>
<section {...rest}>
<Section {size}>
<Container size={container}>
<!--
`data-stagger` is the foundation's structural rhythm: it writes each
stat's `--motion-stagger-index` from `:nth-child`, and every `Stat` (a
viewport `Motion`) reads it. The figures land one after another as the
band is reached — no delays written by hand.
-->
<AutoGrid
minChildWidth={columns ? undefined : minChildWidth}
{columns}
{gap}
align="start"
width="100%"
data-stagger
>
{@render children?.()}
</AutoGrid>
</Container>
</Section>
</section>

@ -0,0 +1,46 @@
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 { MetricsProps, MetricsLabelProps, MetricsValueProps } from '$uix/eidos/components/metrics';
import type { CountUpProps } from '$uix/eidos/components/count-up';
// Sub-parts extend the props of the canon component they wrap, never
// `HTMLAttributes`: the raw attribute surface clashes with the canon's refined
// `style`/`class` when spread through (the feature-grid lesson).
export type StatsBandProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/** Content measure. Passed straight to `Container`. @default 'lg' */
container?: ContainerSize;
/** Block-axis padding. Passed straight to `Section`. @default 'lg' */
size?: SectionSize;
/** Fixed column count. Omit to let the band fit as many as the measure allows. */
columns?: number;
/** Minimum inline-size per stat before wrapping. @default '12rem' */
minChildWidth?: string;
/** Gap between stats (space scale). @default 8 */
gap?: number;
/** The `Stat`s. */
children?: Snippet;
};
/** Wraps a `Metrics` (surfaceless). `align` defaults to `center` — a band reads
* as a row of centred figures, unlike a dashboard tile. */
export type StatsBandStatProps = MetricsProps;
export type StatsBandValueProps = Omit<MetricsValueProps, 'children'> & {
/**
* The figure to COUNT UP to when the band scrolls into view — the one thing a
* static markup dump cannot ship. Formatting is locale-aware (`uix.format`)
* and reduced-motion jumps straight to the final value, both from `CountUp`.
* Omit it and the app renders the value itself through `children`.
*/
count?: number;
/** Everything `CountUp` takes (`from`, `duration`, `notation`, `style`…) when
* `count` is set. `to` comes from `count`. */
countOptions?: Omit<CountUpProps, 'to'>;
/** The value as the app renders it — used when `count` is not set. */
children?: Snippet;
};
/** Wraps a `Metrics.Label`. */
export type StatsBandLabelProps = MetricsLabelProps;

@ -26,7 +26,7 @@ export const BLOCK_CATALOG: BlockGroup[] = [
{ slug: 'pricing', label: 'Pricing', shipped: true },
{ slug: 'testimonials', label: 'Testimonials', shipped: true },
{ slug: 'faq', label: 'FAQ', shipped: true },
{ slug: 'stats-band', label: 'Stats band', shipped: false },
{ slug: 'stats-band', label: 'Stats band', shipped: true },
{ slug: 'cta', label: 'CTA', shipped: false },
{ slug: 'newsletter', label: 'Newsletter', shipped: false },
{ slug: 'site-footer', label: 'Site footer', shipped: false },

@ -0,0 +1,190 @@
<script lang="ts">
/**
* StatsBand 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 StatsBandSite from './StatsBandSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
let columns = $state(0); // 0 = fluido
let withCount = $state(true);
let dir = $state<'ltr' | 'rtl'>('ltr');
const previewSrc = $derived(
`/blocks/stats-band/preview?columns=${columns}&count=${withCount}&dir=${dir}`
);
</script>
<BlockDemo
name="Stats band"
slug="$blocks/stats-band"
{previewSrc}
previewHeight="620px"
meta={[
{ key: 'compone', value: 'Section · Container · AutoGrid · Metrics · CountUp · Motion' },
{ key: 'tipo', value: 'compound (.Stat repite)' },
{ key: 'superación', value: 'count-up (ninguna ref puede)' },
{ key: 'strings propias', value: '0' }
]}
>
{#snippet preview()}
<StatsBandSite {columns} {withCount} />
{/snippet}
{#snippet lede()}
La fila de cifras que respalda lo que la página acaba de afirmar. Compone el <Code>Metrics</Code>
del canon para la semántica de KPI y <Code>CountUp</Code> para la cifra que se cuenta sola al
entrar en pantalla — <strong>lo único de este tier que ninguna referencia puede shipear</strong>,
porque todas entregan markup estático.
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">columnas</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[String(columns)]}
onValueChange={(v) => (columns = Number(v[0] ?? columns))}
aria-label="columnas"
>
<ToggleGroup.Item value="0">fluido</ToggleGroup.Item>
{#each [2, 3, 4] 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">count-up</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[withCount ? 'sí' : 'no']}
onValueChange={(v) => (withCount = (v[0] ?? 'sí') === 'sí')}
aria-label="contar las cifras"
>
<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 block coloca y escalona; la semántica de KPI es del <Code>Metrics</Code> del canon y el
conteo, del <Code>CountUp</Code>. Cifras y palabras son de la app.
</Text>
<Stack gap={3}>
<DocRow term="raíz">
<Code>&lt;section&gt;</Code> + <Code>Section</Code> + <Code>Container</Code> +
<Code>AutoGrid</Code> con <Code>data-stagger</Code>: fluido por
<Code>minChildWidth</Code> o con <Code>columns</Code> fijas.
</DocRow>
<DocRow term=".Stat">
<Code>Motion trigger="viewport"</Code> envolviendo un <Code>Metrics</Code>. ES el reveal,
así que debe seguir siendo hijo directo de la banda para recibir su índice estructural.
</DocRow>
<DocRow term=".Value">
<Code>Metrics.Value</Code>; con <Code>count</Code> compone <Code>CountUp</Code>. El
formato es locale-aware y reduced-motion salta al valor final — ambas cosas del
<Code>CountUp</Code>, no del block.
</DocRow>
<DocRow term=".Label"><Code>Metrics.Label</Code>. Las palabras, de la app.</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="StatsBand">
<Code>container</Code> (def. <Code>lg</Code>) · <Code>size</Code> (def. <Code>lg</Code>) ·
<Code>columns</Code> o <Code>minChildWidth</Code> (def. <Code>12rem</Code>) ·
<Code>gap</Code> (def. <Code>8</Code>).
</DocRow>
<DocRow term=".Stat">
Todo lo de <Code>Metrics</Code>: <Code>align</Code> (def. <Code>center</Code> — una banda
son cifras centradas, no tarjetas de panel), <Code>size</Code> (def. <Code>lg</Code>),
<Code>live</Code>…
</DocRow>
<DocRow term=".Value">
<Code>count</Code> (number, opt-in) + <Code>countOptions</Code> (todo lo de
<Code>CountUp</Code> menos <Code>to</Code>). Sin <Code>count</Code>, la cifra la pone la app
por children.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Movimiento">
El conteo respeta <Code>prefers-reduced-motion</Code>: salta directo al valor final, sin
animar. Lo resuelve el <Code>CountUp</Code>, no el block.
</DocRow>
<DocRow term="Cifras">
El formato numérico sale de <Code>uix.format</Code> (separadores por idioma), así que la
cifra se lee correcta en cada locale sin que la app la reescriba.
</DocRow>
<DocRow term="Estructura">
Sin encabezados: una banda son datos, no una sección con título. Si la página necesita uno,
lo pone alrededor.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Split con imagen">
<strong>Diferido</strong> — la banda junto a una foto: otra disposición (2 columnas), que
recurre en las refs. Entra como <Code>layout</Code> o hermano cuando una demo lo pida.
</DocRow>
<DocRow term="Timeline / stepped">
<strong>Diferido</strong> — el <Code>Timeline</Code> del canon existe; es otra sección, no
una banda.
</DocRow>
<DocRow term="Delta / tendencia por cifra">
<strong>App-land</strong> — <Code>Metrics.Delta</Code> se compone dentro del
<Code>.Stat</Code> hoy; re-exponerlo solo añadiría superficie.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="El count-up es opt-in, nunca por defecto">
Una cifra que se anima sin que el lector lo pida es ruido, y algunas no son contables
(«99,98 %»). Por eso <Code>count</Code> se pide explícitamente.
</DocRow>
<DocRow term="El block no formatea ni escribe">
Ni una palabra ni un separador decimal salen de aquí: las palabras por children, el formato
por <Code>uix.format</Code> dentro del <Code>CountUp</Code>.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,57 @@
<script lang="ts">
/**
* The stats-band mini-page: the band with real product figures, plus enough
* page around it to read as a page — so the demo can render it FULL-BLEED and
* the `preview` route can serve it as a standalone document.
*/
import { StatsBand } from '$blocks/stats-band';
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';
let {
columns = 0,
withCount = true
}: {
/** 0 = fluid (minChildWidth); otherwise a fixed column count. */
columns?: number;
withCount?: boolean;
} = $props();
// `count` drives CountUp; `display` is what the app renders when it does not
// count (a figure like "99,98 %" is not a countable integer).
const stats = [
{ count: 12500, display: '12.500', label: 'Eventos por segundo' },
{ count: 340, display: '340', label: 'Equipos de producto' },
{ count: undefined, display: '99,98 %', label: 'Disponibilidad medida' },
{ count: 48, display: '48', label: 'Integraciones nativas' }
];
</script>
<StatsBand columns={columns || undefined}>
{#each stats as stat (stat.label)}
<StatsBand.Stat>
{#if withCount && stat.count !== undefined}
<StatsBand.Value count={stat.count} />
{:else}
<StatsBand.Value>{stat.display}</StatsBand.Value>
{/if}
<StatsBand.Label>{stat.label}</StatsBand.Label>
</StatsBand.Stat>
{/each}
</StatsBand>
<!-- A floor so the page reads as a page. -->
<Section size="lg">
<Container size="lg">
<Stack gap={4}>
<Heading level={2}>Los números salen del mismo motor que el panel</Heading>
<Text color="muted" measure="wide">
No son cifras de folleto: se recalculan con los mismos agregados que consulta cualquier
cliente, así que lo que ves aquí es lo que verías dentro.
</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,14 @@
<script lang="ts">
/**
* Standalone page for the stats-band mini-site — same component the demo
* renders inline, served as its OWN document for the device-width frame.
*/
import { page } from '$app/state';
import StatsBandSite from '../StatsBandSite.svelte';
const params = $derived(page.url.searchParams);
const columns = $derived(Number(params.get('columns') ?? 0) || 0);
const withCount = $derived(params.get('count') !== 'false');
</script>
<StatsBandSite {columns} {withCount} />
Loading…
Cancel
Save

Powered by TurnKey Linux.