blocks(pricing): un plan solo no se sostenía — la fila necesitaba un tope

Segunda fila de la ola F2b, y la que existía para comprobar una sospecha. El
README llevaba desde julio diciendo que el plan único «ya lo cubre el layout»,
diferido por eso. La ficha exigía medirlo antes de darlo por gratis, y no lo
cubría.

La causa está en la lista de pistas, no en cuántos planes hay: la fila es
repeat(auto-fill, minmax(17rem, 1fr)), y auto-fill RESERVA toda pista que quepa,
la ocupe alguien o no. Medido a 1280 sobre 992px de fila, un plan solo daba una
tarjeta de 315px pegada al borde inicial con 677px de vacío al lado; fijar
columns a 1 solo cambiaba eso por una tarjeta de 992px, que es un cartel. Meterlo
en un Container sm tampoco: a 640px vuelven a caber dos pistas.

Lo que faltaba era un tope sobre la FILA, y la parte no lo dejaba pasar: sus
props están cerradas a propósito desde A-94, porque el envoltorio esparce el
resto antes de las props que fija y un width del consumidor tipaba y se
descartaba en silencio. Así que se abre nombrando: maxWidth entra en el tipo y
viaja explícito al AutoGrid, que es exactamente lo que A-94 dejó dicho — el tipo
dice lo que la parte honra. El marginX auto va con él sin condición: sin tope la
fila ya llena la medida y auto no resuelve a nada.

El número lo pone la app. Un block que horneara uno estaría decidiendo cuánto
mide un plan; la demo pasa 28rem para uno y 44rem para dos, que es lo que las
referencias le dan a cada arreglo, y gana un control vivo de recuento que también
viaja en la URL de la vista previa.

Medido después: un plan 448px centrado, dos planes 704px en dos pistas de 340
centradas, y tres planes 992px idénticos a antes — el tope es opt-in y no toca el
caso que ya existía. A 375 es inerte, porque allí una sola pista ya toma el
ancho.

Queda anotada la lección en el README y en el handoff, porque no es de este
block: una fila «diferida» puede ser una medición equivocada y no un
aplazamiento. Nadie había mirado pricing con menos de tres planes.

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

@ -254,10 +254,9 @@ del ancho; él lo ve centrado y tiene razón), y **A-65/A-75 son el mismo patró
### d) La ola F2b — FIRMADA 2026-08-17, sin ejecutar
**La fuente es `PLAN-blocks.md` §F2b** — no dupliques aquí sus tablas. Cuatro
tramos, en orden: **A** variantes de suelo (~~V4 hero `form`~~ ✅ **HECHA
2026-08-17** → **entra por V8 single-price**, que SE MIDE antes de declararse
gratis → V5 cta split → V2 spotlight → V3
faq-lista → V1 `Pricing.Compare` en tanda propia) · **B** la matriz de
tramos, en orden: **A** variantes de suelo (~~V4 hero `form`~~ ✅ · ~~V8
single-price~~ ✅ **ambas HECHAS 2026-08-17** → **entra por V5 cta split** → V2
spotlight → V3 faq-lista → V1 `Pricing.Compare` en tanda propia) · **B** 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** tres blocks nuevos — `logo-cloud`
@ -292,6 +291,14 @@ block «Paridad OK».
ni tocando `data-theme` a mano (el boot es el dueño; forzar el atributo NO
repinta y da dos medidas idénticas que parecen «no hay diferencia»).
⚠️ **Y lo que dejó V8, que vale para TODA la ola**: **una fila «diferida» puede
ser una medición equivocada, no un aplazamiento.** El README de `pricing` decía
desde julio que el plan único «ya lo cubre el layout» y era falso — la rejilla
es `auto-fill` y RESERVA las pistas que caben aunque nadie las ocupe (un plan
solo: card de 315px con 677px de vacío al lado). Nadie había mirado ese block
con menos de tres planes. Antes de dar por buena una disposición vieja de
cualquier README, mídela.
### e) Cabos y fases
1. **Tres cabos concretos**:

@ -727,14 +727,14 @@ términos, y el primero es el que más trabajo hace:
### Tramo A — las variantes de suelo
| # | Block | Qué falta | Forma firmada | Coste |
| ------ | -------------- | --------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------- |
| **V1** | `pricing` | tabla comparativa features×planes | **`Pricing.Compare`** (parte, por el término 4). **Tanda propia con fase 0**: dos decisiones de diseño que la ficha no puede prejuzgar — (a) **quién declara las columnas** si `.Plans` y `.Compare` conviven (doble declaración de planes) y (b) **qué pasa en móvil** (las refs colapsan a lista-por-plan o a scroll) | **alto** |
| **V2** | `testimonials` | cita única en spotlight | **`layout="grid" \| "spotlight"`**. ⚠️ No es «misma anatomía» exacta: gana un slot `logo`, pierde la `Card` y `Items` pasa a un solo protagonista. Aun así `layout`, con el precedente de `hero layout="background"`, que también redefine partes. El hermano se reabre SÓLO si un día pide rotación / carousel | medio |
| **V3** | `faq` | lista estática 2/3 columnas | **`layout="accordion" \| "list"`**, con `.List` conmutando entre `Accordion` y una rejilla de Q&A siempre abiertas. **El punto fino es el TIPO**: hoy `FaqListProps = AccordionProps`, y bajo `list` no aplican ni esas props ni `value`/`disabled` de `.Item` → unión discriminada, jamás superficie descartada en silencio (A-94) | medio |
| **V4** | `hero` | `form-in-hero` | ✅ **HECHA 2026-08-17.** Slot `form`, el app compone `Form` + `Field` + `Form.Submit` entero (el hero NO toma el handle: tomarlo lo convertiría en un block que coordina). Aterriza en el sitio del CTA — tras la descripción, antes de `actions` — con medida propia `sm`; los dos conviven si llegan los dos. Detalle y medidas: README del block §«The sign-up slot» | bajo |
| **V5** | `cta` | split-with-media | tercer **`layout="split"`** + slot `media` (el nombre que ya usa `hero`); el `Mockup` existe. Es suelo: el dossier la lista | bajo |
| **V8** | `pricing` | single-price | **PRIMERO SE MIDE.** «Un solo `.Plan` y el layout ya lo sostiene» es una asunción sin verificar: en rejilla fluida un plan solo o se estira al container o se queda en su pista, y ninguna de las dos es la card centrada de las referencias — además `PricingPlansProps` es un `Pick` cerrado sin salida. Si no lo sostiene, deja de ser ~0 y vuelve como decisión | ~0 → ? |
| # | Block | Qué falta | Forma firmada | Coste |
| ------ | -------------- | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------- |
| **V1** | `pricing` | tabla comparativa features×planes | **`Pricing.Compare`** (parte, por el término 4). **Tanda propia con fase 0**: dos decisiones de diseño que la ficha no puede prejuzgar — (a) **quién declara las columnas** si `.Plans` y `.Compare` conviven (doble declaración de planes) y (b) **qué pasa en móvil** (las refs colapsan a lista-por-plan o a scroll) | **alto** |
| **V2** | `testimonials` | cita única en spotlight | **`layout="grid" \| "spotlight"`**. ⚠️ No es «misma anatomía» exacta: gana un slot `logo`, pierde la `Card` y `Items` pasa a un solo protagonista. Aun así `layout`, con el precedente de `hero layout="background"`, que también redefine partes. El hermano se reabre SÓLO si un día pide rotación / carousel | medio |
| **V3** | `faq` | lista estática 2/3 columnas | **`layout="accordion" \| "list"`**, con `.List` conmutando entre `Accordion` y una rejilla de Q&A siempre abiertas. **El punto fino es el TIPO**: hoy `FaqListProps = AccordionProps`, y bajo `list` no aplican ni esas props ni `value`/`disabled` de `.Item` → unión discriminada, jamás superficie descartada en silencio (A-94) | medio |
| **V4** | `hero` | `form-in-hero` | ✅ **HECHA 2026-08-17.** Slot `form`, el app compone `Form` + `Field` + `Form.Submit` entero (el hero NO toma el handle: tomarlo lo convertiría en un block que coordina). Aterriza en el sitio del CTA — tras la descripción, antes de `actions` — con medida propia `sm`; los dos conviven si llegan los dos. Detalle y medidas: README del block §«The sign-up slot» | bajo |
| **V5** | `cta` | split-with-media | tercer **`layout="split"`** + slot `media` (el nombre que ya usa `hero`); el `Mockup` existe. Es suelo: el dossier la lista | bajo |
| **V8** | `pricing` | single-price | ✅ **HECHA 2026-08-17 — y NO era gratis.** Medido: la fila es `auto-fill`, así que RESERVA las pistas que caben aunque nadie las ocupe → un plan solo daba 315px pegados al borde con 677px de vacío, y `columns={1}` lo cambiaba por una card de 992px. Resuelto con **`.Plans maxWidth`** (tope de fila, centrado, reenviado nombrado por A-94); el número lo pone el app. Detalle: README del block §«The row cap» | ~0 → bajo |
**Fuera de la ola, a F5 con disparador** — el dossier declara `stats-band`
**«Paridad OK»**, así que estas dos son _adoptables_, no suelo, y meterlas aquí
@ -2110,3 +2110,19 @@ type="button">` es una pista de MIME falsa) — el morfo lo declara
tampoco normaliza oklch aquí → medía NEGRO contra todo); la señal de que la
sonda es honesta es que la etiqueta del submit sale a 5.18:1, la cifra que el
repo ya documenta para `primary`.
- 2026-08-17 — **F2b · V8 `single-price` HECHA, y la ficha acertó al exigir que
se midiera ANTES**: «un solo `.Plan` y el layout ya lo sostiene» —la
disposición que el README del block llevaba desde julio— era falsa. La fila es
`repeat(auto-fill, minmax(17rem, 1fr))` y **`auto-fill` reserva toda pista que
quepa**, la ocupe alguien o no: medido a 1280 sobre 992px de fila, un plan solo
daba una card de 315px pegada al borde con 677px de vacío al lado, y
`columns={1}` sólo cambiaba eso por una card de 992px. Ninguna de las dos es la
card centrada de las referencias. Resuelto abriendo `.Plans` a **`maxWidth`**
(tope de la FILA, con `marginX="auto"` incondicional para centrarla),
reenviado NOMBRADO al `AutoGrid` — nunca por `{...rest}`, que es justo lo que
A-94 dejó dicho. El número lo pone el app: la demo pasa `28rem` para un plan y
`44rem` para dos. Medido después: 448px centrados con uno, 704px (dos pistas de 340) con dos, y **992px idénticos con tres** — el tope es opt-in y no toca el
caso que ya existía; a 375 es inerte. Control vivo nuevo en la demo (nº de
planes) y en la URL del preview. ⚠️ Lección que se queda: **una fila
«diferida» puede ser una medición equivocada, no un aplazamiento** — nadie
había mirado este block con menos de tres planes.

@ -8,18 +8,18 @@ monthly ↔ annual with the prices updating in step.
## Composition map
| Part | Composes | Notes |
| -------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
| root | bare `<section>` + `Section` + `Container` + `Stack` | `bind:period`; provides the period context |
| `.Header` | `Box` + `Stack` | the app's section `Heading` + `Text` |
| `.Switch` | `ToggleGroup` | writes the period; reads it back to stay in sync |
| `.Plans` | `AutoGrid` + `data-stagger` | fluid row of equal-height plan cards; the stagger rhythm is structural |
| `.Plan` | `Motion` (`trigger="viewport"`) wrapping a `Card` (outline) | IS the reveal (direct child of the staggered row); `featured` → accent colour; `badge` chip |
| `.PlanName` | `Heading` (level 3) | |
| `.PlanDescription` | `Text` (muted, sm) | one-line tagline |
| `.PlanPrice` | `Group` + `Text` | shows the `monthly` / `annual` snippet per the period; `suffix` beside it |
| `.PlanFeatures` / `.PlanFeature` | `Stack` / `Group` + `Icon.Check` + `Text` | the checklist; the check is decorative (aria-hidden) |
| `.PlanAction` | `Box` (`margin-block-start: auto`) | the CTA, pinned to the card's bottom so a row lines up |
| Part | Composes | Notes |
| -------------------------------- | ----------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| root | bare `<section>` + `Section` + `Container` + `Stack` | `bind:period`; provides the period context |
| `.Header` | `Box` + `Stack` | the app's section `Heading` + `Text` |
| `.Switch` | `ToggleGroup` | writes the period; reads it back to stay in sync |
| `.Plans` | `AutoGrid` + `data-stagger` | fluid row of equal-height plan cards; the stagger rhythm is structural. `maxWidth` caps and centres the row for short plan counts (V8) |
| `.Plan` | `Motion` (`trigger="viewport"`) wrapping a `Card` (outline) | IS the reveal (direct child of the staggered row); `featured` → accent colour; `badge` chip |
| `.PlanName` | `Heading` (level 3) | |
| `.PlanDescription` | `Text` (muted, sm) | one-line tagline |
| `.PlanPrice` | `Group` + `Text` | shows the `monthly` / `annual` snippet per the period; `suffix` beside it |
| `.PlanFeatures` / `.PlanFeature` | `Stack` / `Group` + `Icon.Check` + `Text` | the checklist; the check is decorative (aria-hidden) |
| `.PlanAction` | `Box` (`margin-block-start: auto`) | the CTA, pinned to the card's bottom so a row lines up |
**Landmark + headings**: a bare `<section>` with NO accessible name of its own,
so it is not an exposed landmark — the app names it by passing `aria-label` /
@ -75,10 +75,46 @@ the middle), the live monthly/annual toggle switching every price. Mini-page in
| Gap | Disposition |
| ------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Feature-comparison table** (features × plans matrix) | **scope-approval pending** — the dossier's recurring pricing gap. It is a table, not a row of cards, so likely a sibling (`pricing-table`) or a `.Compare` sub-block — a user decision, not a silent add |
| **Single-price variant** (one plan, centered) | **deferred** — one `.Plan` in `.Plans`; the layout already handles it |
| **Single-price variant** (one plan, centered) | **SHIPPED 2026-08-17** (F2b · V8) — as `.Plans maxWidth`. The old disposition here («the layout already handles it») was WRONG and is kept visible on purpose; see «The row cap» below |
| **Per-period savings note** ("2 meses gratis") | **app-land** — the app puts it in the `annualLabel` or a `.PlanDescription` |
| **Elevation on the featured plan** | **canon candidate** — `Card` exposes no elevation prop, and a block paints nothing of its own. Marking the recommended tier by depth (not just colour) needs the prop in `Card`; until then `featured` is colour only |
### The row cap (`.Plans maxWidth`) — 2026-08-17, plan F2b · V8
This row existed in the Gaps table as «deferred — one `.Plan` in `.Plans`; the
layout already handles it». **It did not.** Measured before touching anything,
at 1280 with the `lg` measure (992px of row):
| Arrangement | What it measured |
| ------------------------------- | ------------------------------------------------------------------- |
| one plan, as it was | card of **315px at the start edge, 677px of emptiness** beside it |
| one plan, `columns={1}` | one card **992px wide** — a poster, not a price card |
| one plan, row capped at `28rem` | card of **448px, centred** — the arrangement the references ship |
| one plan, inside `Container sm` | 640px still fits two tracks → card of 308px with 332px of emptiness |
The cause is in the track list, not in the plan count: the fluid row is
`repeat(auto-fill, minmax(17rem, 1fr))`, and **`auto-fill` RESERVES every track
that fits** whether or not a child occupies it. So the fix is a cap on the ROW,
which the part now takes as `maxWidth` and forwards to the `AutoGrid`
explicitly — never through `{...rest}`, because these props are closed on
purpose (A-94) and a promised surface has to be honoured. `marginX="auto"` goes
with it, unconditionally: with no cap the row already fills the measure and
`auto` resolves to nothing.
**The number is the app's.** A block that baked one would be deciding how wide a
plan looks; the demo passes `28rem` for one plan and `44rem` for two, which is
what the references give each.
**Measured after**: one plan 448px centred · two plans 704px (two 340px tracks),
centred · three plans 992px, byte-identical to before — the cap is opt-in and
changes nothing when it is absent · at 375 the cap is inert, since a single
track already takes the width.
## Found while composing
- _(none yet — recorded here as they surface.)_
- **A «deferred» disposition can be a wrong measurement, not a postponed one**
(2026-08-17). The single-price row said the layout already covered it, and the
layout did the opposite — reserving two empty tracks next to the only card.
Nobody had measured it: the block shipped with three plans and was only ever
looked at with three. Worth remembering when reading any other row of this
table.

@ -4,15 +4,26 @@
import { AutoGrid } from '$uix/eidos/components/auto-grid';
import type { PricingPlansProps } from './types';
let { minChildWidth = '17rem', columns, gap = 6, children }: PricingPlansProps = $props();
let {
minChildWidth = '17rem',
columns,
maxWidth,
gap = 6,
children
}: PricingPlansProps = $props();
</script>
<!-- `marginX="auto"` is unconditional: with no `maxWidth` the row already fills
the measure and `auto` resolves to nothing, and with one it is what centres
the capped row instead of parking it at the start edge. -->
<AutoGrid
minChildWidth={columns ? undefined : minChildWidth}
{columns}
{maxWidth}
{gap}
align="stretch"
width="100%"
marginX="auto"
data-stagger
>
{@render children?.()}

@ -46,6 +46,21 @@ export type PricingPlansProps = {
minChildWidth?: string;
/** Fixed column count instead of fluid. */
columns?: number;
/**
* Cap for the ROW, centred inside the section's measure. It is what a short
* plan count needs: the fluid track list is `auto-fill`, so it RESERVES the
* tracks that fit even when no plan occupies them — measured at 1280 with a
* single plan, the card sat at the start edge of a 992px row with 677px of
* emptiness beside it, and `columns={1}` only traded that for one 992px-wide
* card. With a cap, one plan reads as the centred card the references ship
* (measured 448px at `28rem`), and two read as a pair.
*
* The app owns the number: a block that baked one would be deciding how wide
* a plan looks. Passed to the row EXPLICITLY, never through `{...rest}` —
* this part's props are closed on purpose (A-94), and a surface that is
* promised has to be honoured.
*/
maxWidth?: BoxProps['maxWidth'];
/** Gap between plans (space scale). @default 6 */
gap?: number;
children?: Snippet;

@ -3,15 +3,21 @@
* Pricing 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 PricingSite from './PricingSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
// The billing toggle is the block's own live control — no demo chrome needed
// beyond the section's axes. Device widths drive the preview URL.
const previewSrc = '/blocks/pricing/preview';
// The billing toggle is the block's own live control; the plan COUNT is the
// app's, and it is what shows the single-price and two-plan arrangements the
// reference catalogs ship.
let planCount = $state<1 | 2 | 3>(3);
const previewSrc = $derived(`/blocks/pricing/preview?plans=${planCount}`);
</script>
<BlockDemo
@ -27,7 +33,27 @@
]}
>
{#snippet preview()}
<PricingSite />
<PricingSite {planCount} />
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">planes</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[String(planCount)]}
onValueChange={(v) => (planCount = (Number(v[0] ?? planCount) || 3) as 1 | 2 | 3)}
aria-label="número de planes"
>
{#each [1, 2, 3] as value (value)}
<ToggleGroup.Item value={String(value)}>{value}</ToggleGroup.Item>
{/each}
</ToggleGroup>
</Group>
</Wrap>
{/snippet}
{#snippet lede()}
@ -77,6 +103,13 @@
<DocRow term=".Switch">
<Code>monthlyLabel</Code> · <Code>annualLabel</Code> · <Code>aria-label</Code>.
</DocRow>
<DocRow term=".Plans">
<Code>minChildWidth</Code> (def. <Code>17rem</Code>) · <Code>columns</Code> ·
<Code>gap</Code> (def. <Code>6</Code>) · <Code>maxWidth</Code>. El último acota la FILA y la
centra en la medida de la sección: es lo que necesita un recuento corto de planes, porque la
rejilla fluida reserva las pistas que caben aunque estén vacías. El número lo pone la app —
un block que lo horneara estaría decidiendo cuánto mide un plan.
</DocRow>
<DocRow term=".Plan">
<Code>featured</Code> (bool) · <Code>badge</Code> (snippet).
</DocRow>
@ -116,8 +149,12 @@
hermano (<Code>pricing-table</Code>) — decisión tuya, no un añadido aquí.
</DocRow>
<DocRow term="Plan único">
<strong>Diferido</strong> — un solo <Code>.Plan</Code> en <Code>.Plans</Code>; el layout ya
lo cubre.
<strong>Hecho (V8, 2026-08-17)</strong> — y no como estaba anotado. «El layout ya lo cubre»
era falso: la fila es <Code>auto-fill</Code>, así que RESERVA las pistas que caben aunque
nadie las ocupe — medido a 1280, un plan solo daba una tarjeta de 315px pegada al borde con
677px de vacío al lado, y fijar <Code>columns</Code> a 1 solo cambiaba eso por una tarjeta de
992px. Se cubre con el tope de fila (<Code>.Plans maxWidth</Code>): prueba el control
<em>planes</em> de arriba.
</DocRow>
</Stack>
{/snippet}

@ -18,6 +18,8 @@
const uix = getActiveUix();
let { planCount = 3 }: { planCount?: 1 | 2 | 3 } = $props();
// The full semantic arc of choosing a plan. Pressing already emits
// `contact-activate` (the Button's own morfo event, for free by composition);
// what NO reference can express is the CONSEQUENCE — so the app emits the
@ -96,6 +98,25 @@
]
}
];
/**
* The single-price and two-plan arrangements the reference catalogs ship. With
* ONE plan the recommended tier is the one that stands alone; with two, the
* entry tier joins it.
*/
const shown = $derived(
planCount === 3 ? plans : planCount === 2 ? [plans[0], plans[1]] : [plans[1]]
);
/**
* The cap for the row, measured, not guessed: the fluid track list is
* `auto-fill`, so without it a single plan sits at the start edge of the
* measure with two empty tracks beside it (measured 315px of card against
* 992px of row at 1280). `28rem` is what the references give a lone price
* card; `44rem` holds a pair without letting either grow into a poster.
* Three plans fill the measure, so there is nothing to cap.
*/
const rowMaxWidth = $derived(planCount === 1 ? '28rem' : planCount === 2 ? '44rem' : undefined);
</script>
<Pricing>
@ -114,8 +135,8 @@
aria-label="Periodo de facturación"
/>
<Pricing.Plans>
{#each plans as plan (plan.name)}
<Pricing.Plans maxWidth={rowMaxWidth}>
{#each shown as plan (plan.name)}
<Pricing.Plan featured={plan.featured}>
{#snippet badge()}
{#if plan.featured}

@ -2,8 +2,14 @@
/**
* Standalone page for the pricing mini-site — same component the demo renders
* inline, served as its OWN document for the device-width frame.
*
* The plan count travels in the URL so the device frame shows the same
* arrangement the demo does.
*/
import { page } from '$app/state';
import PricingSite from '../PricingSite.svelte';
const planCount = $derived((Number(page.url.searchParams.get('plans') ?? 3) || 3) as 1 | 2 | 3);
</script>
<PricingSite />
<PricingSite {planCount} />

Loading…
Cancel
Save

Powered by TurnKey Linux.