blocks(pricing): la tabla de comparación, y las palabras que una marca no dice

La brecha recurrente del dossier en pricing, y la última fila del primer tramo de
la ola.

Es una PARTE y no un hermano porque tiene que obedecer al periodo de la sección, y
sólo una parte puede leer ese contexto: B-10 le prohíbe a un hermano importar el
block que lo posee. Vivir dentro de la sección es además lo que permite que la app
meta el precio en una celda y cambie con el conmutador, sin que esta página cablee
nada.

La app construye la tabla y el block la compone. B-5 admite datos justo donde el
canon compuesto ya es data-driven, y la tabla del canon lo es: exige la instancia
que devuelve createTable. Así que la parte no inventa un formato de matriz propio
ni traduce nada de ida y vuelta, que es el acoplamiento que esa regla existe para
evitar. Las tarjetas y la tabla conviven, como en las referencias, alimentadas por
el mismo array de planes.

Dos cosas que el plan había esbozado y que construirlas demostró equivocadas, y
quedan escritas en vez de calladas. No hay un Record de ejes visuales por plan:
acentuar una columna es componer canon, que es justo lo que ya permite el snippet
de cabecera, y ese Record habría sido una segunda fuente de lo que la app declara
en su plan. Y el block no fija la columna: el motor es del app, y meter mano ahí
sería el block decidiendo cómo se comporta la tabla de otro.

Lo que sí posee es lo que la app no puede: el nombre accesible de la tabla y las
palabras que una marca de visto o de guion no dice en voz alta. Ese glifo lleva
todo el significado de la celda y es silencio para un lector de pantalla, así que
va oculto y la frase viaja en su etiqueta, desde un mapa exhaustivo con respaldo
en inglés y su test en las dos direcciones.

Y absorbe el cast del canon siendo genérico: la tabla tipa su ranura sin
parámetro mientras el motor sí lo devuelve, así que todo consumidor castea,
empezando por la demo del propio componente. Aquí se castea una vez a la entrada y
se deshace a la salida, para que el snippet de celda devuelva la fila con la forma
del app.

Medido a 1280 y 375: ocho filas por cuatro columnas, tabla nombrada, marcas que se
anuncian, y en móvil la tabla desplaza con la columna de características quieta y
la última columna alcanzable.

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

@ -256,8 +256,8 @@ del ancho; él lo ve centrado y tiene razón), y **A-65/A-75 son el mismo patró
**La fuente es `PLAN-blocks.md` §F2b** — no dupliques aquí sus tablas. Cuatro
tramos, en orden: **A** variantes de suelo (~~V4 hero `form`~~ ✅ · ~~V8
single-price~~ ✅ · ~~V5 cta split~~ ✅ · ~~V2 testimonials spotlight~~ ✅ ·
~~V3 faq-lista~~ ✅ **las cinco HECHAS 2026-08-17** → **queda V1
`Pricing.Compare`, en tanda propia**) · **B** la matriz de
~~V3 faq-lista~~ ✅ · ~~V1 `Pricing.Compare`~~ ✅ — **TRAMO A CERRADO 6/6 el
2026-08-17; entra por el tramo B**) · **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`

@ -2310,3 +2310,39 @@ type="button">` es una pista de MIME falsa) — el morfo lo declara
ocultas; list → 0 disparadores, **5 `h3`**, tres pistas de 309px, medida 1024 y
**todas las respuestas visibles sin pulsar**; a 375 cae a una columna
conservando ambas cosas. El acordeón queda idéntico.
- 2026-08-17 — **F2b · V1 `Pricing.Compare` HECHA — tramo A CERRADO (6/6)**. La
brecha recurrente del dossier en pricing. **PARTE y no hermano**: tiene que
obedecer al periodo de la sección y sólo una parte lee ese contexto (B-10 le
prohíbe a un hermano importar el block que lo posee) — vivir dentro de
`<Pricing>` es también lo que permite que la app meta un `PlanPrice` en una
celda y cambie con el conmutador, sin cablear nada. **La app construye la tabla
y el block la COMPONE**: B-5 admite datos justo donde el canon compuesto ya es
data-driven, y `Table` lo es (exige la instancia de `createTable`), así que
`.Compare` no inventa formato de matriz ni traduce. `.Plans` y `.Compare`
CONVIVEN, alimentados por el mismo array.
⚠️ **Dos cosas que el plan sketchó y construirlas demostró equivocadas**,
registradas y no calladas: (1) **sin `Record` por plan** — acentuar es componer
canon, que es lo que ya permite el snippet `columnHeader`; el `Record` habría
sido una segunda fuente de lo que la app declara en su `.Plan`; (2) **el block
NO pinnea**: el motor es del app y mutarlo desde el block sería decidir cómo se
comporta su tabla. La app declara `enablePinning` + `initialColumnPinning`.
**Posee** el nombre accesible de la tabla y las palabras que una marca ✓/— no
dice (`PRICING_COMPARE_VALUE`, `Record` exhaustivo con test en las dos
direcciones; el glifo va `aria-hidden`). **Y absorbe el cast del canon** siendo
genérico: `Table` tipa `TableInstance<unknown>` mientras `createTable<T>`
devuelve `TableInstance<T>` — todo consumidor castea, su propia demo incluida —,
así que `.Compare` castea UNA vez a la entrada y lo deshace a la salida para que
el snippet `cell` devuelva la fila en la forma del app.
**Dos defectos de CANON salieron de aquí** (`theming/changelog.md` §51): la
tabla **no tenía escape horizontal** —a 375 medía 420 en un contenedor de 327 y
93px eran inalcanzables, con `overflow: hidden` en los dos ejes— **ARREGLADO**
(`overflow-x: auto` + `overflow-y: hidden`, guard probado por mutación); y **la
columna fijada no aguanta en RTL** —se ancla con `left` FÍSICO y al desplazar
(`scrollLeft` −94) su borde pasó de 350 a 444 y salió de pantalla— **FILADO**,
porque es el eje de dirección y tiene doctrina propia. Además, `Table` **no
exportaba los tipos que su API exige**: el guard B-4 rechazó `$libs/datagrid`
por nombre y tenía razón — se corrige exponiéndolos desde el canon, no
ensanchando la frontera.
⚠️ **Nota de proceso, segunda reincidencia**: pasé prettier por
`docs/theming/changelog.md` otra vez y reformateó 400 líneas ajenas. Restaurado
y reaplicado a mano. **Ese fichero no se formatea.**

@ -21,6 +21,7 @@ monthly ↔ annual with the prices updating in step.
| `.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 |
| `.PlanReason` | `Text` (sm, secondary) | the sentence for a plan that cannot be chosen; renders NOTHING when it can. Owns the id `.PlanAction` points `aria-describedby` at |
| `.Compare` | the canon `Table` (engine from the app's `createTable`) | the features×plans matrix. Owns the table's accessible NAME and the words a ✓ / — cell does not say |
**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` /
@ -162,6 +163,59 @@ Same token, no new one, `pointer-events` untouched. Guarded by
`components/card/disabled-attenuation.test.ts` and proved by mutation. Detail:
`theming/changelog.md` §50.
### The comparison table (`.Compare`) — 2026-08-17, plan F2b · V1
The dossier's recurring gap for `pricing`, and the last row of the wave's first
stretch. The two decisions the plan deliberately left open:
- **A PART, not a sibling block.** It has to obey the section's billing period,
and only a part can read that context — B-10 forbids a sibling from importing
the block that owns it. Living inside `<Pricing>` is also what lets the app drop
a `Pricing.PlanPrice` into a cell and have it switch with the toggle: the
coordination comes from where it sits, not from wiring.
- **The app builds the table; the block composes it.** B-5 allows data-shaped
input «only where the composed canon component is already data-driven», and the
canon `Table` is exactly that — it requires the `createTable({ data, columns })`
instance. So `.Compare` invents no matrix format and translates nothing. `.Plans`
and `.Compare` coexist (the references show both), fed by the SAME array of
plans, so a plan's state is declared once and reaches both views.
⚠️ **Two things the plan sketched and building them proved wrong**, both recorded
rather than quietly dropped:
- **No per-plan `Record` of visual axes.** The plan had `.Compare` take
`{ featured, state }` keyed by column id so it could accent the recommended
column. But a block paints no CSS: accenting means composing canon components,
which is what the `columnHeader` snippet already lets the app do with its own
`Badge` and `Text`. The `Record` would have been a second source for something
the app already declares on its `.Plan`.
- **The block does not pin the feature column itself.** Pinning is engine state,
and the engine belongs to the app; reaching in to mutate it would be the block
deciding how the app's table behaves. The app declares `enablePinning` +
`initialColumnPinning` — the demo shows it — and gets the sticky column the
references have on narrow screens.
**What it owns**: the table's accessible name (`label`), and the words a ✓ / —
cell does not say out loud. That mark carries the whole meaning of the cell and
is silence to a screen reader — the row header names the feature, the column
header names the plan, and the value between them would be nothing. So the marks
are `aria-hidden` decoration and `PRICING_COMPARE_VALUE` (exhaustive `Record`,
idlangref with English fallback) supplies «Included» / «Not included».
**It also absorbs the canon's cast.** `Table` types its slot as
`TableInstance<unknown>` while `createTable<T>` returns `TableInstance<T>`, and
they are invariant in `columns`, so every typed consumer casts — the canon's own
demo included. `.Compare` is generic and casts once on the way in, and undoes it
on the way out so the `cell` snippet hands the row back in the app's own shape.
**Measured** (1280 and 375): 8 rows × 4 columns, table named, marks reading
«Incluido» / «No incluido» · at 375 the table is 420px in a 327px container and
**scrolls**, with the pinned feature column staying put (x=25 before and after a
95px scroll) and the last column reachable. Two canon defects came out of this and
are fixed or filed in `theming/changelog.md` §51: the table had no horizontal
escape at all (fixed), and the pinned column does not hold in RTL (filed — the
offset is physical, and that is the direction axis).
## Found while composing
- **A «deferred» disposition can be a wrong measurement, not a postponed one**

@ -46,6 +46,7 @@ import PlanFeatures from './pricing-plan-features.svelte';
import PlanFeature from './pricing-plan-feature.svelte';
import PlanAction from './pricing-plan-action.svelte';
import PlanReason from './pricing-plan-reason.svelte';
import Compare from './pricing-compare.svelte';
type PricingNamespace = typeof PricingComponent & {
Header: typeof Header;
@ -59,6 +60,7 @@ type PricingNamespace = typeof PricingComponent & {
PlanFeature: typeof PlanFeature;
PlanAction: typeof PlanAction;
PlanReason: typeof PlanReason;
Compare: typeof Compare;
};
const Pricing = PricingComponent as PricingNamespace;
@ -73,12 +75,13 @@ Pricing.PlanFeatures = PlanFeatures;
Pricing.PlanFeature = PlanFeature;
Pricing.PlanAction = PlanAction;
Pricing.PlanReason = PlanReason;
Pricing.Compare = Compare;
export { Pricing };
export default Pricing;
export type { PricingPlanState } from './plan-state';
export { canChoosePlan, PRICING_PLAN_REASON } from './plan-state';
export { canChoosePlan, PRICING_PLAN_REASON, PRICING_COMPARE_VALUE } from './plan-state';
export type {
PricingProps,
@ -93,5 +96,6 @@ export type {
PricingPlanFeaturesProps as PlanFeaturesProps,
PricingPlanFeatureProps as PlanFeatureProps,
PricingPlanActionProps as PlanActionProps,
PricingPlanReasonProps as PlanReasonProps
PricingPlanReasonProps as PlanReasonProps,
PricingCompareProps as CompareProps
} from './types';

@ -1,5 +1,10 @@
import { describe, expect, it } from 'vitest';
import { canChoosePlan, PRICING_PLAN_REASON, type PricingPlanState } from './plan-state';
import {
canChoosePlan,
PRICING_COMPARE_VALUE,
PRICING_PLAN_REASON,
type PricingPlanState
} from './plan-state';
const ALL: PricingPlanState[] = ['available', 'current', 'selected'];
@ -40,3 +45,22 @@ describe('pricing plan state', () => {
expect(Object.keys(PRICING_PLAN_REASON).sort()).toEqual([...ALL].sort());
});
});
describe('pricing comparison cell words', () => {
it('covers BOTH booleans — a glyph says nothing to a screen reader', () => {
expect(Object.keys(PRICING_COMPARE_VALUE).sort()).toEqual(['false', 'true']);
});
it('every word is an idlangref under the block namespace with a fallback', () => {
for (const ref of Object.values(PRICING_COMPARE_VALUE)) {
expect(ref.startsWith('#?'), ref).toBe(true);
const [key, fallback] = ref.slice(2).split('|');
expect(key.startsWith('blocks.pricing.'), key).toBe(true);
expect(fallback?.length, `${key} needs a fallback`).toBeGreaterThan(0);
}
});
it('the two cases do not share a sentence', () => {
expect(PRICING_COMPARE_VALUE.true).not.toBe(PRICING_COMPARE_VALUE.false);
});
});

@ -50,3 +50,18 @@ export const PRICING_PLAN_REASON: Record<PricingPlanState, string | null> = {
current: '#?blocks.pricing.reason.current|This is your current plan',
selected: null
};
/**
* The words a comparison cell needs and its glyph cannot give.
*
* A ✓ or a — in a features×plans matrix carries the whole meaning of the cell and
* says NOTHING to a screen reader: the row header names the feature and the
* column header names the plan, but the value between them is a decoration. So
* the block owns these two, by the same rule and in the same shape as
* `PRICING_PLAN_REASON` — idlangref with an English fallback, and a `Record`
* keyed by the boolean so neither case can be forgotten.
*/
export const PRICING_COMPARE_VALUE: Record<'true' | 'false', string> = {
true: '#?blocks.pricing.compare.included|Included',
false: '#?blocks.pricing.compare.notIncluded|Not included'
};

@ -0,0 +1,107 @@
<script lang="ts" generics="TData">
/**
* Pricing.Compare — the features×plans matrix.
*
* A PART and not a sibling block: it has to obey the section's billing period,
* and only a part can read that context (B-10 forbids a sibling from importing
* the block that owns it). Living inside `<Pricing>` is also what lets the app
* drop a `Pricing.PlanPrice` into a cell and have it switch with the toggle —
* the coordination comes for free from where it sits.
*
* It composes the canon `Table`, which is data-driven by design: B-5 allows
* `items={…}`-shaped input EXACTLY where the composed canon component already
* is, so the app builds its `createTable({ data, columns })` like any other
* table in the ecosystem and hands the instance over. The block invents no
* matrix format of its own and translates nothing.
*
* What it owns is the part the app cannot: the accessible NAME of the table and
* the words a ✓ / — cell does not say out loud.
*/
import { Table } from '$uix/eidos/components/table';
import { Text } from '$uix/eidos/components/text';
import * as Icon from '$uix/eidos/components/icon';
import { ActiveEidos } from '$uix/eidos';
import { PRICING_COMPARE_VALUE } from './plan-state';
import type { TableInstance, TableRow } from '$uix/eidos/components/table';
import type { PricingCompareProps } from './types';
let {
table,
label,
size = 'md',
variant = 'outline',
columnHeader,
cell,
...rest
}: PricingCompareProps<TData> = $props();
const eidos = ActiveEidos.require();
/**
* The one cast, absorbed here so no app writes it. The canon types its `table`
* slot as `TableInstance<unknown>` while `createTable<T>` returns
* `TableInstance<T>`, and the two are invariant in `columns` — its own demo
* casts at the call site. Registered in the README as a canon candidate: the
* fix is for `TableProps` to carry the parameter.
*
* It is undone on the way OUT too: the `cell` snippet hands the row back in the
* consumer's own shape, so the app types `row.original` without a second cast.
* The friction stops here, which is the point of a block absorbing it.
*/
const instance = $derived(table as unknown as TableInstance);
</script>
<Table table={instance} {label} {size} {variant} block hoverable {...rest}>
<Table.Header>
{#each instance.headers as header (header.id)}
<Table.ColumnHeader {header}>
{#if columnHeader}
{@render columnHeader({ header })}
{:else}
{header.label}
{/if}
</Table.ColumnHeader>
{/each}
</Table.Header>
<Table.Body>
{#each instance.rows as row (row.id)}
<Table.Row {row}>
{#each row.cells as c, index (c.id)}
<Table.Cell cell={c} colIndex={index}>
{#if cell}
{@render cell({
value: c.value,
columnId: c.column.id,
row: row as unknown as TableRow<TData>
})}
{:else if typeof c.value === 'boolean'}
<!--
The whole meaning of the cell is in the glyph, and a glyph says
NOTHING to a screen reader: the row header names the feature and
the column header names the plan, but the value between them
would be silence. So the mark is `aria-hidden` decoration and the
block supplies the words — its own, because they are not content
the app wrote (B-7 covers the app's words, not the ones a mark
owes).
-->
<Text
size="sm"
color={c.value ? 'primary' : 'muted'}
aria-label={eidos.langs.t(PRICING_COMPARE_VALUE[c.value ? 'true' : 'false'])}
>
{#if c.value}
<Icon.Check size="xs" aria-hidden="true" />
{:else}
<Icon.Minus size="xs" aria-hidden="true" />
{/if}
</Text>
{:else}
<Text size="sm">{c.value}</Text>
{/if}
</Table.Cell>
{/each}
</Table.Row>
{/each}
</Table.Body>
</Table>

@ -10,6 +10,7 @@ import type { StackProps } from '$uix/eidos/components/stack';
import type { ComponentColorProp } from '$uix/eidos/lib/types';
import type { BillingPeriod } from './context';
import type { PricingPlanState } from './plan-state';
import type { TableProps, TableHeader, TableInstance, TableRow } from '$uix/eidos/components/table';
export type { BillingPeriod, PricingPlanState };
@ -147,3 +148,55 @@ export type PricingPlanActionProps = Omit<BoxProps, 'children'> & {
/** Wraps a small `Text` — the sentence for a plan that cannot be chosen. Renders
* nothing when the plan is choosable. */
export type PricingPlanReasonProps = Omit<TextProps, 'children' | 'id'>;
/**
* The features×plans matrix — `Pricing.Compare`.
*
* It takes a `TableInstance` and not a matrix of its own, and that is the whole
* design decision. B-5 allows data-shaped input «only where the composed canon
* component is already data-driven», and the canon `Table` is exactly that: it
* requires the instance `createTable({ data, columns })` returns. So the app
* builds its table like any other in the ecosystem and the block composes it —
* inventing a second matrix format here would mean translating the app's data
* into it and back out, which is the coupling B-5 exists to prevent.
*
* ⚠️ Two things this part deliberately does NOT do, decided while building it:
*
* - **It does not take a per-plan `Record` of visual axes.** The plan sketched
* one (`{ featured, state }` keyed by column id) so the block could accent the
* recommended column. But a block paints no CSS, so accenting means composing
* canon components — which is what the `columnHeader` snippet already lets the
* app do, with its own `Badge` and `Text`. The `Record` would have been a
* second source for something the app already declares on its `.Plan`.
* - **It does not pin the feature column itself.** Pinning is engine state
* (`enablePinning` + `initialColumnPinning`), and the engine belongs to the
* app; reaching in to mutate it from the block would be the block deciding how
* the app's table behaves. The app declares it — the demo shows how — and the
* README says so.
*/
export type PricingCompareProps<TData = unknown> = Omit<TableProps, 'table' | 'children'> & {
/**
* The instance from `createTable({ data, columns })`. Required.
*
* Generic on purpose: the canon `Table` types this slot as
* `TableInstance<unknown>`, so every typed consumer casts —its own demo does
* `table as unknown as TableInstance`. This part carries the type parameter and
* absorbs that cast ONCE, on the way in, so an app never writes it.
*/
table: TableInstance<TData>;
/**
* Accessible name for the table. A comparison matrix with no name is a grid
* of marks a screen reader cannot place — the canon `Table` falls back to a
* generic «Data table», which here would say nothing about what is compared.
*/
label?: string;
/** Header cell content. Defaults to the column's own label. */
columnHeader?: Snippet<[{ header: TableHeader }]>;
/**
* Body cell content. The block paints booleans (a mark plus the words it owes)
* and plain values by default; pass this to compose anything else — a
* `Pricing.PlanPrice` in the price row reads the section's period from
* context and switches with the toggle, because `Compare` lives inside it.
*/
cell?: Snippet<[{ value: unknown; columnId: string; row: TableRow<TData> }]>;
};

@ -18,6 +18,10 @@ export const blocksLangs = {
es: 'Es tu plan actual.',
en: 'This is your current plan.'
}
},
compare: {
included: { es: 'Incluido', en: 'Included' },
notIncluded: { es: 'No incluido', en: 'Not included' }
}
},
newsletter: {

@ -18,9 +18,10 @@
let planCount = $state<1 | 2 | 3>(3);
let lift = $state(false);
let scenario = $state<'new' | 'upgrade'>('new');
let compare = $state(false);
const previewSrc = $derived(
`/blocks/pricing/preview?plans=${planCount}&lift=${lift}&scenario=${scenario}`
`/blocks/pricing/preview?plans=${planCount}&lift=${lift}&scenario=${scenario}&compare=${compare}`
);
</script>
@ -37,7 +38,7 @@
]}
>
{#snippet preview()}
<PricingSite {planCount} {lift} {scenario} />
<PricingSite {planCount} {lift} {scenario} {compare} />
{/snippet}
{#snippet controls()}
@ -58,6 +59,21 @@
</ToggleGroup>
</Group>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">tabla comparativa</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[compare ? 'sí' : 'no']}
onValueChange={(v) => (compare = (v[0] ?? 'no') === 'sí')}
aria-label="mostrar la tabla de comparación"
>
<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">escenario</Text>
<ToggleGroup
@ -163,6 +179,17 @@
<Code>monthly</Code> · <Code>annual</Code> · <Code>suffix</Code> (snippets). Sin
<Code>annual</Code>, el <Code>monthly</Code> vale para ambos periodos.
</DocRow>
<DocRow term=".Compare">
<Code>table</Code> (la instancia de <Code>createTable</Code>, requerida) ·
<Code>label</Code> · <Code>columnHeader</Code> y <Code>cell</Code> (snippets). La app construye
su tabla como cualquier otra del ecosistema y el block la COMPONE: B-5 admite datos justo donde
el canon compuesto ya es data-driven, y <Code>Table</Code> lo es. El block no inventa un formato
de matriz ni traduce nada; lo que sí posee es el nombre accesible de la tabla y las palabras que
una marca ✓/— no dice —
<Code>aria-hidden</Code> en el glifo y la frase en el <Code>aria-label</Code>. Fijar la
columna de características la declara la app en su <Code>createTable</Code>
(<Code>enablePinning</Code> + <Code>initialColumnPinning</Code>): el motor es suyo.
</DocRow>
<DocRow term=".PlanAction · .PlanReason">
El snippet de <Code>.PlanAction</Code> RECIBE el estado derivado (<Code>canChoose</Code>,
<Code>describedBy</Code>), así que la app compone su propio <Code>Button</Code> sin volver a deducir
@ -198,9 +225,11 @@
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Tabla de comparación">
<strong>Scope-approval pendiente</strong> — la brecha recurrente del dossier en pricing: una
matriz features × planes. Es una tabla, no una fila de tarjetas, así que probablemente un
hermano (<Code>pricing-table</Code>) — decisión tuya, no un añadido aquí.
<strong>Hecho (V1, 2026-08-17)</strong> — <Code>.Compare</Code>, y como PARTE, no como
hermano: tiene que obedecer al periodo de la sección y sólo una parte puede leer ese
contexto (B-10 le prohíbe a un hermano importar el block que lo posee). Enciende el control
<em>tabla comparativa</em>: el conmutador mensual/anual mueve las tarjetas y la tabla a la
vez.
</DocRow>
<DocRow term="Plan único">
<strong>Hecho (V8, 2026-08-17)</strong> — y no como estaba anotado. «El layout ya lo cubre»

@ -11,18 +11,26 @@
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Badge } from '$uix/eidos/components/badge';
import { Group } from '$uix/eidos/components/group';
import { Button } from '$uix/eidos/components/button';
import { Link } from '$uix/eidos/components/link';
import { tick } from 'svelte';
import { getActiveUix } from '$active-uix';
import { createTable, type ColumnDef } from '$libs/datagrid';
const uix = getActiveUix();
let {
planCount = 3,
lift = false,
scenario = 'new'
}: { planCount?: 1 | 2 | 3; lift?: boolean; scenario?: 'new' | 'upgrade' } = $props();
scenario = 'new',
compare = false
}: {
planCount?: 1 | 2 | 3;
lift?: boolean;
scenario?: 'new' | 'upgrade';
compare?: boolean;
} = $props();
// The full semantic arc of choosing a plan. Pressing already emits
// `contact-activate` (the Button's own morfo event, for free by composition);
@ -103,6 +111,49 @@
}
];
/**
* La matriz de comparación. La construye la APP con `createTable`, como
* cualquier otra tabla del ecosistema: el block compone la tabla del canon, no
* inventa un formato propio (B-5 admite datos justo donde el canon compuesto ya
* es data-driven, y `Table` lo es).
*
* Las columnas son los planes, y salen del MISMO array que alimenta las
* tarjetas: el estado de un plan se declara una vez y viaja a las dos vistas.
*/
type CompareRow = {
feature: string;
Starter: boolean | string;
Pro: boolean | string;
Enterprise: boolean | string;
};
const compareRows: CompareRow[] = [
{ feature: 'Eventos al mes', Starter: '10k', Pro: 'Ilimitados', Enterprise: 'Ilimitados' },
{ feature: 'Proyectos', Starter: '1', Pro: 'Ilimitados', Enterprise: 'Ilimitados' },
{ feature: 'Retención de datos', Starter: '7 días', Pro: '12 meses', Enterprise: 'A medida' },
{ feature: 'Cohortes y embudos', Starter: false, Pro: true, Enterprise: true },
{ feature: 'Alertas y automatizaciones', Starter: false, Pro: true, Enterprise: true },
{ feature: 'SSO/SAML + SCIM', Starter: false, Pro: false, Enterprise: true },
{ feature: 'Residencia de datos', Starter: false, Pro: false, Enterprise: true },
{ feature: 'SLA 99,9 %', Starter: false, Pro: false, Enterprise: true }
];
/**
* `enablePinning` + `initialColumnPinning` los declara la APP, no el block:
* el motor es suyo y un block que lo mutara estaría decidiendo cómo se comporta
* la tabla de otro. Es lo que mantiene la columna de características a la vista
* cuando la tabla scrollea en horizontal.
*/
const compareColumns: ColumnDef<CompareRow>[] = [
{ id: 'feature', accessorKey: 'feature', header: 'Características', enablePinning: true },
...plans.map((p) => ({ id: p.name, accessorKey: p.name, header: p.name }))
];
const compareTable = createTable<CompareRow>({
data: () => compareRows,
columns: compareColumns,
initialColumnPinning: { left: ['feature'] }
});
/**
* 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
@ -207,6 +258,27 @@
</Pricing.Plan>
{/each}
</Pricing.Plans>
{#if compare}
<!--
La tabla convive con las tarjetas, como en las referencias, y se alimenta
del MISMO array de planes. El precio de su fila compone `PlanPrice`, que
lee el periodo del contexto de la sección: el conmutador mueve las dos
vistas a la vez sin que esta página cablee nada.
-->
<Pricing.Compare table={compareTable} label="Comparación de planes por características">
{#snippet columnHeader({ header })}
{#if header.id === 'Pro'}
<Group gap={2} align="center" justify="start">
<Text size="sm" weight="semibold" color="primary">{header.label}</Text>
<Badge size="xs" variant="soft" color="primary">Más popular</Badge>
</Group>
{:else}
<Text size="sm" weight="medium">{header.label}</Text>
{/if}
{/snippet}
</Pricing.Compare>
{/if}
</Pricing>
<!-- A floor so the page reads as a page. -->

@ -12,6 +12,7 @@
const planCount = $derived((Number(page.url.searchParams.get('plans') ?? 3) || 3) as 1 | 2 | 3);
const lift = $derived(page.url.searchParams.get('lift') === 'true');
const scenario = $derived((page.url.searchParams.get('scenario') ?? 'new') as 'new' | 'upgrade');
const compare = $derived(page.url.searchParams.get('compare') === 'true');
</script>
<PricingSite {planCount} {lift} {scenario} />
<PricingSite {planCount} {lift} {scenario} {compare} />

Loading…
Cancel
Save

Powered by TurnKey Linux.