blocks(faq): la lista estática, y un tipo que deja de prometer lo que no da

La brecha del dossier para este block, y la mayoría del formato: seis de siete en
Tailwind Plus no son acordeón, sino una rejilla de preguntas y respuestas siempre
abiertas.

El eje vive en la LISTA, no en la raíz. Primero porque la lista es lo único que
cambia —la cabecera se lee igual—, y segundo porque esa colocación es la que
permite que el tipo diga la verdad: pasa a ser una unión discriminada. Bajo
acordeón atraviesa toda la superficie del Accordion del canon; bajo lista no se
ofrece ninguna de esas props, porque una rejilla estática no tiene estado abierto
que enlazar, nada que colapsar ni modo simple o múltiple. Ofrecer una superficie y
descartarla en silencio es justo lo que A-94 dejó dicho que no se hace, y aquí es
donde iba a morder después.

El ítem lee la disposición del contexto: no puede discriminar sobre la unión de la
lista sin obligar a la app a repetir el arreglo en cada pregunta. Su value y su
disabled quedan documentados como del acordeón, la misma convención que el fondo
del hero y la media del cta.

Y bajo lista la pregunta es un encabezado de verdad, porque ahí ES el encabezado
de su respuesta; bajo acordeón el canon la mete dentro del disparador y esa
semántica es suya, así que el block no inventa una segunda.

Medido a 1280: en acordeón, cinco disparadores, ningún encabezado, medida de
setecientos sesenta y ocho y respuestas ocultas hasta pulsar; en lista, ningún
disparador, cinco encabezados, tres pistas de trescientos nueve, medida de mil
veinticuatro y todas las respuestas visibles sin tocar nada. A 375 cae a una
columna conservando ambas cosas. El acordeón queda idéntico.

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

@ -255,9 +255,9 @@ 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~~ ✅ **las
cuatro HECHAS 2026-08-17** → **entra por V3 faq-lista** → V1 `Pricing.Compare` en
tanda propia) · **B** la matriz de
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
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`

@ -2210,3 +2210,23 @@ type="button">` es una pista de MIME falsa) — el morfo lo declara
(morfo, langs y `eidos/generated` tocados). Medido con todo en stash: la base
real sin nadie es 60. **No volver a hacer `git stash --include-untracked` en este
árbol**: arrastra el trabajo sin commitear de la otra sesión.
- 2026-08-17 — **F2b · V3 `faq` lista estática HECHA**. La brecha del dossier y la
mayoría del formato: **6 de 7 en TW no son acordeón**. Entra como
**`.List layout="accordion" | "list"`**, y el sitio del eje importa: vive en la
LISTA, no en la raíz, porque la lista es lo único que cambia —el header se lee
igual— y porque esa colocación es la que permite que **el TIPO diga la verdad**.
`FaqListProps` pasa a ser **unión discriminada**: bajo `accordion` atraviesa
toda la superficie del `Accordion` del canon; bajo `list` **no se ofrece
ninguna**, porque una rejilla estática no tiene estado abierto que enlazar, nada
que colapsar ni modo simple/múltiple. Es A-94 aplicado donde iba a morder
después. El `Item` lee la disposición del CONTEXTO —no puede discriminar sobre
la unión de la lista sin obligar a la app a repetir el arreglo en cada
pregunta— y sus `value`/`disabled` quedan documentados como del arreglo
`accordion`, la misma convención que el `backdrop` del `hero` y el `media` del
`cta`. **Bajo `list` la pregunta es un `h3` de verdad**: en una lista estática ES
el encabezado de su respuesta, mientras que bajo `accordion` el canon la mete en
el disparador y esa semántica es suya — el block no inventa una segunda. Medido
a 1280: accordion → 5 disparadores, 0 encabezados, medida 768, respuestas
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.

@ -7,12 +7,12 @@ narrow readable column. Usually near the end of a page, often after pricing.
## Composition map
| Part | Composes | Notes |
| --------- | ------------------------------------------------------------- | ------------------------------------------------------------ |
| root | bare `<section>` + `Section` + `Container` (narrow) + `Stack` | places the header and the list |
| `.Header` | `Box` + `Stack` | the app's section `Heading` + `Text` |
| `.List` | `Motion` (`trigger="viewport"`) wrapping the `Accordion` | **is** the accordion — its whole API passes through |
| `.Item` | `Accordion.Item` + `.Header` + `.Trigger` + `.Content` | thin proxy: `question` snippet → trigger, children → content |
| Part | Composes | Notes |
| --------- | ------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------- |
| root | bare `<section>` + `Section` + `Container` (narrow) + `Stack` | places the header and the list |
| `.Header` | `Box` + `Stack` | the app's section `Heading` + `Text` |
| `.List` | `Motion` wrapping the `Accordion` (accordion) · `AutoGrid` + `data-stagger` (list) | **owns the `layout`** and shares it with the `Item`s. Under `accordion` the whole accordion API passes through; under `list` it is not even offered |
| `.Item` | `Accordion.Item` + `.Header` + `.Trigger` + `.Content` (accordion) · `Motion` + `Heading` h3 + `Text` (list) | thin proxy under `accordion`; under `list` the question IS a heading and the answer the text below it |
**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` /
@ -64,12 +64,44 @@ Untitled UI «FAQ 16» · Flowbite):
`web/routes/blocks/faq/` — the block full-bleed, a single-column accordion of
questions. Mini-page in `FaqSite.svelte`, shared by both surfaces.
### The static list — 2026-08-17, plan F2b · V3
The dossier's gap here, and the majority format: **6 of 7 in Tailwind Plus are
NOT accordions**. What was decided:
- **The axis lives on `.List`, not on the root**, because the list is the only
thing it changes — the header reads the same either way. That placement is also
what lets the TYPE tell the truth.
- **`FaqListProps` is a discriminated union.** Under `accordion` the canon
`Accordion`'s surface passes through (`type`, bindable `value`, `collapsible`,
`variant`, `size`); under `list` **none of it is offered**, because a static
grid has no open state to bind, nothing to collapse and no single/multiple
mode. Offering it and dropping it in silence is the A-94 lesson, applied where
it would have bitten next.
- **The `Item` reads the arrangement from context.** It cannot discriminate on
the list's own union without the app repeating the arrangement on every single
question. Its `value` / `disabled` stay on the type and are documented as
belonging to `accordion` — the same convention `hero`'s `backdrop` and `cta`'s
`media` already follow.
- **Under `list` the question is a real `h3`.** In a static list it IS the
heading of its answer; under `accordion` the canon puts it inside a trigger and
owns that semantics itself. The block does not invent a second one.
- **The measure is the app's call**: the demo widens the container to `lg` for
the grid, because two or three columns of questions do not fit in a single
reading column. The block does not force it.
**Measured** (1280): accordion → `data-accordion` present, 5 triggers, 0 headings,
container 768, answers hidden until pressed. List → no accordion, **0 triggers,
5 `h3`**, three 309px tracks, container 1024, **every answer visible without
pressing**. At 375 it collapses to one column and keeps both. The accordion
arrangement is unchanged.
## Gaps
| Gap | Disposition |
| ------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| **Static 2/3-column list** (questions + answers laid out, no disclosure) | **scope-approval pending** — the dossier's FAQ gap: 6 of 7 in TW are NOT accordions. It is a different arrangement (a grid of Q&A, always open), so likely a `layout` prop or a sibling — a user decision, not a silent add |
| **"Still have questions?" tail** (a CTA to support below the list) | **app-land** — the app puts a `Text` + `Link`/`Button` after `.List`; a dedicated slot is only worth it if a demo asks |
| Gap | Disposition |
| ------------------------------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------------- |
| **Static 2/3-column list** (questions + answers laid out, no disclosure) | **SHIPPED 2026-08-17** (F2b · V3) — `.List layout="list"`. See «The static list» below |
| **"Still have questions?" tail** (a CTA to support below the list) | **app-land** — the app puts a `Text` + `Link`/`Button` after `.List`; a dedicated slot is only worth it if a demo asks |
## Found while composing

@ -0,0 +1,30 @@
import { getContext, setContext } from 'svelte';
import type { FaqLayout } from './types';
/**
* What `Faq.List` shares with its `Item`s: the arrangement.
*
* The axis lives on the LIST, not on the root, because the list is the only
* thing it changes — the header reads the same either way. That is also what
* lets the type tell the truth: `FaqListProps` is a discriminated union, so
* under `list` the accordion's surface (`type`, `collapsible`, bindable `value`)
* is not even offered, instead of being offered and dropped (A-94).
*
* The `Item` cannot discriminate the same way — it would mean repeating the
* arrangement on every question — so it reads it from here.
*/
export interface FaqContext {
/** `accordion` (the default) or `list`. A getter, so reads track the source. */
readonly layout: FaqLayout;
}
const KEY = Symbol('uix.faq');
export function setFaqContext(ctx: FaqContext): void {
setContext(KEY, ctx);
}
/** The arrangement an `Item` should assume — `accordion` when used stand-alone. */
export function faqLayout(): FaqLayout {
return getContext<FaqContext | undefined>(KEY)?.layout ?? 'accordion';
}

@ -6,14 +6,39 @@
* generates one, so the block does not duplicate that.
*/
import { Accordion } from '$uix/eidos/components/accordion';
import { Stack } from '$uix/eidos/components/stack';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Motion } from '$uix/eidos/components/motion';
import { faqLayout } from './context';
import type { FaqItemProps } from './types';
let { value, disabled = false, question, children }: FaqItemProps = $props();
const layout = $derived(faqLayout());
</script>
<Accordion.Item {value} {disabled}>
<Accordion.Header>
<Accordion.Trigger>{@render question?.()}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>{@render children?.()}</Accordion.Content>
</Accordion.Item>
{#if layout === 'list'}
<!--
Static: the question is a real HEADING and the answer the text under it — no
trigger, no open state, nothing to press. `value` and `disabled` do not
apply here and the type says so.
The question is an `h3` because in a static list it IS the heading of its
answer; under `accordion` the canon puts it inside a trigger and owns that
semantics itself.
-->
<Motion trigger="viewport">
<Stack gap={2} align="start">
<Heading level={3} size="sm">{@render question?.()}</Heading>
<Text color="muted" wrap="pretty">{@render children?.()}</Text>
</Stack>
</Motion>
{:else}
<Accordion.Item {value} {disabled}>
<Accordion.Header>
<Accordion.Trigger>{@render question?.()}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>{@render children?.()}</Accordion.Content>
</Accordion.Item>
{/if}

@ -6,21 +6,51 @@
* outline.
*/
import { Accordion } from '$uix/eidos/components/accordion';
import { AutoGrid } from '$uix/eidos/components/auto-grid';
import { Motion } from '$uix/eidos/components/motion';
import { setFaqContext } from './context';
import type { FaqListProps } from './types';
let {
value = $bindable([]),
type = 'single',
collapsible = true,
variant = 'outline',
children,
...rest
}: FaqListProps = $props();
let props: FaqListProps = $props();
const layout = $derived(props.layout ?? 'accordion');
// The arrangement reaches the `Item`s from here: they cannot discriminate on
// this union without the app repeating the arrangement on every question.
setFaqContext({
get layout() {
return layout;
}
});
</script>
<Motion trigger="viewport">
<Accordion bind:value {type} {collapsible} {variant} {...rest}>
{@render children?.()}
</Accordion>
</Motion>
{#if props.layout === 'list'}
<!--
The static grid the reference catalogs lead with: every answer visible, no
disclosure. `data-stagger` rides here because there IS a set to deal in, and
each `Item` is its own `Motion` — same shape as `feature-grid`.
-->
<AutoGrid
minChildWidth={props.columns ? undefined : (props.minChildWidth ?? '18rem')}
columns={props.columns}
gap={props.gap ?? 8}
align="start"
width="100%"
data-stagger
>
{@render props.children?.()}
</AutoGrid>
{:else}
{@const { layout: _layout, children, value = [], ...rest } = props}
<Motion trigger="viewport">
<Accordion
value={value as string[]}
type={rest.type ?? 'single'}
collapsible={rest.collapsible ?? true}
variant={rest.variant ?? 'outline'}
{...rest}
>
{@render children?.()}
</Accordion>
</Motion>
{/if}

@ -17,15 +17,53 @@ export type FaqProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/** Wraps a `Box` (measure-cap, centered). `maxWidth` defaults to `48rem`. */
export type FaqHeaderProps = BoxProps;
/** Wraps the canon `Accordion` — the whole accordion API (`type`, `value`
* bindable, `collapsible`, `variant`, `size`) passes straight through. */
export type FaqListProps = AccordionProps;
/**
* How the questions are laid out.
*
* `accordion` is the disclosure list — one answer at a time. `list` is the
* static grid the reference catalogs actually lead with: every answer visible,
* two or three columns, nothing to press. The dossier counted 6 of 7 in Tailwind
* Plus as NOT accordions, which is why this arrangement exists at all.
*/
export type FaqLayout = 'accordion' | 'list';
/**
* The list, as a DISCRIMINATED UNION — and that is the whole point of the type.
*
* Under `accordion` the canon `Accordion`'s surface passes straight through
* (`type`, bindable `value`, `collapsible`, `variant`, `size`). Under `list`
* there is no accordion at all, so **none of that is offered**: a static grid has
* no open state to bind, nothing to collapse and no single/multiple mode.
* Offering it and dropping it in silence is the one thing that does not hold —
* the A-94 lesson, applied where it would have bitten next.
*/
export type FaqListProps =
| ({ layout?: 'accordion' } & AccordionProps)
| {
layout: 'list';
/** Minimum question inline-size before wrapping. @default '18rem' */
minChildWidth?: string;
/** Fixed column count instead of fluid. */
columns?: number;
/** Gap between questions (space scale). @default 8 */
gap?: number;
children?: Snippet;
};
export type FaqItemProps = {
/** Stable value for the accordion's open state. `Accordion.Item` generates one
* when omitted. */
/**
* Stable value for the accordion's open state. `Accordion.Item` generates one
* when omitted.
*
* Belongs to the `accordion` arrangement and is ignored under `list`, the same
* way `hero`'s `backdrop` is ignored outside `background` — a static answer has
* no open state to key. It stays on the type because the `Item` cannot
* discriminate on the list's own union without the app repeating the
* arrangement on every single question.
*/
value?: string;
/** Disable this question. @default false */
/** Disable this question — `accordion` only: there is nothing to press in a
* static list. @default false */
disabled?: boolean;
/** The question — the accordion trigger's label. */
question?: Snippet;

@ -6,10 +6,15 @@
import { Text } from '$uix/eidos/components/text';
import { Code } from '$uix/eidos/components/code';
import BlockDemo from '../_lib/BlockDemo.svelte';
import { Group } from '$uix/eidos/components/group';
import { Wrap } from '$uix/eidos/components/wrap';
import { ToggleGroup } from '$uix/eidos/components/toggle-group';
import FaqSite from './FaqSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
const previewSrc = '/blocks/faq/preview';
let layout = $state<'accordion' | 'list'>('accordion');
const previewSrc = $derived(`/blocks/faq/preview?layout=${layout}`);
</script>
<BlockDemo
@ -25,7 +30,26 @@
]}
>
{#snippet preview()}
<FaqSite />
<FaqSite {layout} />
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">layout</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[layout]}
onValueChange={(v) => (layout = (v[0] ?? layout) as typeof layout)}
aria-label="disposición de las preguntas"
>
<ToggleGroup.Item value="accordion">accordion</ToggleGroup.Item>
<ToggleGroup.Item value="list">list</ToggleGroup.Item>
</ToggleGroup>
</Group>
</Wrap>
{/snippet}
{#snippet lede()}
@ -61,12 +85,21 @@
<Code>lg</Code>).
</DocRow>
<DocRow term=".List">
Todo el API del <Code>Accordion</Code>: <Code>type</Code>, <Code>bind:value</Code>,
<Code>collapsible</Code>, <Code>variant</Code>, <Code>size</Code>.
<Code>layout</Code> (<Code>'accordion' | 'list'</Code>, def. <Code>accordion</Code>) y, bajo
<Code>accordion</Code>, todo el API del <Code>Accordion</Code>: <Code>type</Code>,
<Code>bind:value</Code>, <Code>collapsible</Code>, <Code>variant</Code>,
<Code>size</Code>. El tipo es una <strong>unión discriminada</strong>: bajo
<Code>list</Code> ese API <em>ni se ofrece</em>, porque una rejilla estática no tiene estado
abierto que enlazar, nada que colapsar ni modo simple/múltiple. Ofrecerlo y descartarlo en
silencio es lo que A-94 dejó dicho que no se hace. Ahí manda
<Code>minChildWidth</Code>/<Code>columns</Code>/<Code>gap</Code>.
</DocRow>
<DocRow term=".Item">
<Code>value</Code> (autogenerado) · <Code>disabled</Code> · <Code>question</Code> (snippet) +
children (la respuesta).
children (la respuesta). Las dos primeras son del arreglo <Code>accordion</Code> y bajo
<Code>list</Code> no aplican — misma convención que el <Code>backdrop</Code> del hero. El
<Code>Item</Code> no puede discriminar sobre la unión de la lista sin obligar a la app a repetir
la disposición en cada pregunta, así que la lee del contexto.
</DocRow>
</Stack>
{/snippet}
@ -88,9 +121,11 @@
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Lista estática 2/3 columnas">
<strong>Scope-approval pendiente</strong> — la brecha del dossier: 6 de 7 en TW NO son
acordeón, sino una rejilla de P/R siempre abiertas. Otra disposición → prop
<Code>layout</Code> o hermano.
<strong>Hecho (V3, 2026-08-17)</strong> — <Code>.List layout="list"</Code>: rejilla de P/R
siempre abiertas, la mayoría del formato (6 de 7 en TW no son acordeón). Bajo
<Code>list</Code> la pregunta es un <Code>h3</Code> de verdad —en una lista estática ES el encabezado
de su respuesta—, mientras que bajo <Code>accordion</Code> el canon la pone dentro del disparador
y esa semántica es suya. Pruébalo con el control de arriba.
</DocRow>
<DocRow term="Cola «¿Aún tienes dudas?»">
<strong>App-land</strong> — la app pone un <Code>Text</Code> + <Code>Link</Code>/<Code

@ -11,6 +11,9 @@
import { Text } from '$uix/eidos/components/text';
import { Link } from '$uix/eidos/components/link';
let { layout = 'accordion' as 'accordion' | 'list' }: { layout?: 'accordion' | 'list' } =
$props();
const faqs = [
{
q: '¿Cómo empiezo a medir?',
@ -35,7 +38,9 @@
];
</script>
<Faq>
<!-- La lista estática necesita más medida que el acordeón: dos o tres columnas de
preguntas no caben en la columna de lectura de una sola. -->
<Faq containerSize={layout === 'list' ? 'lg' : 'md'}>
<Faq.Header>
<Heading level={2} align="center">Preguntas frecuentes</Heading>
<Text size="lg" color="muted" align="center">
@ -43,7 +48,7 @@
</Text>
</Faq.Header>
<Faq.List>
<Faq.List {layout}>
{#each faqs as item (item.q)}
<Faq.Item>
{#snippet question()}{item.q}{/snippet}

@ -3,7 +3,12 @@
* Standalone page for the faq mini-site — same component the demo renders
* inline, served as its OWN document for the device-width frame.
*/
import { page } from '$app/state';
import FaqSite from '../FaqSite.svelte';
const layout = $derived(
(page.url.searchParams.get('layout') ?? 'accordion') as 'accordion' | 'list'
);
</script>
<FaqSite />
<FaqSite {layout} />

Loading…
Cancel
Save

Powered by TurnKey Linux.