diff --git a/docs/process/CONTINUE-blocks.md b/docs/process/CONTINUE-blocks.md index cc71130ae..fef7fce03 100644 --- a/docs/process/CONTINUE-blocks.md +++ b/docs/process/CONTINUE-blocks.md @@ -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**: diff --git a/docs/process/PLAN-blocks.md b/docs/process/PLAN-blocks.md index 60c8e3e55..1bf053a5d 100644 --- a/docs/process/PLAN-blocks.md +++ b/docs/process/PLAN-blocks.md @@ -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. diff --git a/src/uix/blocks/pricing/README.md b/src/uix/blocks/pricing/README.md index bc6a6ed30..0bd08341d 100644 --- a/src/uix/blocks/pricing/README.md +++ b/src/uix/blocks/pricing/README.md @@ -8,18 +8,18 @@ monthly ↔ annual with the prices updating in step. ## Composition map -| Part | Composes | Notes | -| -------------------------------- | ----------------------------------------------------------- | ------------------------------------------------------------------------------------------- | -| root | bare `
` + `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` + `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 `
` 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. diff --git a/src/uix/blocks/pricing/pricing-plans.svelte b/src/uix/blocks/pricing/pricing-plans.svelte index 5745a6c68..982b759b7 100644 --- a/src/uix/blocks/pricing/pricing-plans.svelte +++ b/src/uix/blocks/pricing/pricing-plans.svelte @@ -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(); + {@render children?.()} diff --git a/src/uix/blocks/pricing/types.ts b/src/uix/blocks/pricing/types.ts index 5111f1efb..ce147f6c0 100644 --- a/src/uix/blocks/pricing/types.ts +++ b/src/uix/blocks/pricing/types.ts @@ -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; diff --git a/web/routes/blocks/pricing/+page.svelte b/web/routes/blocks/pricing/+page.svelte index 15dc201b4..ac375f88e 100644 --- a/web/routes/blocks/pricing/+page.svelte +++ b/web/routes/blocks/pricing/+page.svelte @@ -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}`); {#snippet preview()} - + + {/snippet} + + {#snippet controls()} + + + planes + (planCount = (Number(v[0] ?? planCount) || 3) as 1 | 2 | 3)} + aria-label="número de planes" + > + {#each [1, 2, 3] as value (value)} + {value} + {/each} + + + {/snippet} {#snippet lede()} @@ -77,6 +103,13 @@ monthlyLabel · annualLabel · aria-label. + + minChildWidth (def. 17rem) · columns · + gap (def. 6) · maxWidth. 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. + featured (bool) · badge (snippet). @@ -116,8 +149,12 @@ hermano (pricing-table) — decisión tuya, no un añadido aquí. - Diferido — un solo .Plan en .Plans; el layout ya - lo cubre. + Hecho (V8, 2026-08-17) — y no como estaba anotado. «El layout ya lo cubre» + era falso: la fila es auto-fill, 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 columns a 1 solo cambiaba eso por una tarjeta de + 992px. Se cubre con el tope de fila (.Plans maxWidth): prueba el control + planes de arriba. {/snippet} diff --git a/web/routes/blocks/pricing/PricingSite.svelte b/web/routes/blocks/pricing/PricingSite.svelte index 3b4a1f4cf..7609d0d53 100644 --- a/web/routes/blocks/pricing/PricingSite.svelte +++ b/web/routes/blocks/pricing/PricingSite.svelte @@ -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); @@ -114,8 +135,8 @@ aria-label="Periodo de facturación" /> - - {#each plans as plan (plan.name)} + + {#each shown as plan (plan.name)} {#snippet badge()} {#if plan.featured} diff --git a/web/routes/blocks/pricing/preview/+page.svelte b/web/routes/blocks/pricing/preview/+page.svelte index 43570e6ec..399eef7e2 100644 --- a/web/routes/blocks/pricing/preview/+page.svelte +++ b/web/routes/blocks/pricing/preview/+page.svelte @@ -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); - +