feat(blocks): F2.6 `faq` — proxy fino del Accordion del canon

La sección de preguntas: una cabecera sobre un acordeón de P/R en columna
estrecha. Compound (`.Item` repite) y —la regla al envolver un componente
interactivo— un PROXY FINO del `Accordion` del canon: el block lee su API y la
pasa tal cual, no reinventa el disclosure.

`<Faq>` + `.Header` + `.List` + `.Item`.

- `.List` **ES** el `Accordion`: toda su API pasa sin gate — `type`
  (single/multiple), `bind:value`, `collapsible`, `variant`, `size`. Defaults de
  FAQ: single, collapsible (el abierto se puede cerrar), outline.
- `.Item` proxya `Accordion.Item > Header > Trigger`(snippet `question`) /
  `Content`(children) y autogenera el `value` (clave de estado) con
  `$props.id()` si no se pasa — la única conveniencia sobre el andamiaje.
- El teclado (flechas, Home/End, Enter/Espacio), `aria-expanded` y
  `aria-controls` salen del `Accordion`; el block no toca la a11y del disclosure.
  `Container` estrecho (`md`) para una columna legible.

Demo (`web/routes/blocks/faq/`): full-bleed + ruta `preview`, cinco preguntas en
una columna, con la cola «¿aún tienes dudas?» que la app pone tras `.List`.

Huecos a decisión del usuario (Gaps): la **lista estática 2/3 columnas** (6 de 7
en TW NO son acordeón, sino P/R siempre abiertas) — otra disposición, prop
`layout` o hermano.

Verificado en navegador: el acordeón abre/cierra, claro/oscuro × LTR/RTL.
`blocks:check` verde (7 blocks) · `svelte-check` sin errores propios.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
alpha-0.1-sec-dom
dev 3 months ago
parent b59332132f
commit 54b8b838e7

@ -8,12 +8,19 @@ commit `ae7b4f8c3`.
## Lo siguiente
**F2.6 `faq`** (ficha en `PLAN-blocks.md` §F2.6). Compone `Section` + `Container`
(medida estrecha) + `Heading` + `Accordion`. `.Item` = proxy fino de
`Accordion.Item` (LEER el README del Accordion antes — respetar su API real).
v1 = una columna; la brecha del dossier es la lista estática 2/3 columnas + la
cola «Still have questions?» (scope-approval). Después: stats-band → cta →
newsletter → site-footer → banner → team → contact → content-section.
**F2.7 `stats-band`** (ficha en `PLAN-blocks.md` §F2.7). Compone `Section` +
`Group`/`AutoGrid` + `Metrics` + `CountUp` + `Text`. `.Stat` = valor + etiqueta;
`CountUp` opt-in por prop. v1 = banda 2–4 stats; los formatos numéricos llegan ya
formateados o vía `FormatNumber` del app (como pricing, el block no formatea). El
dossier dice: ninguna ref puede shippear count-up (markup estático) → es nuestra
superación literal. LEER `Metrics`/`CountUp` antes. Después: cta → newsletter →
site-footer → banner → team → contact → content-section.
**F2.4 pricing · F2.5 testimonials · F2.6 faq HECHOS.** faq = **proxy fino** de un
componente del canon (`Accordion`): cuando un block envuelve un componente
interactivo, LEE su API y la pasa tal cual (`.List` ES el `Accordion`), no
reinventa el comportamiento — solo ahorra andamiaje con `.Item`. Mismo criterio
para stats-band con `Metrics`/`CountUp`.
**F2.4 `pricing` + F2.5 `testimonials` HECHOS.** pricing = primer compound CON
CONTEXTO (el `Switch` escribe el periodo, los `PlanPrice` lo leen; contexto

@ -1194,3 +1194,17 @@ Blocks diferidos: `scheduler` (bloqueado por `chronos`) · `logo-cloud`
disposición → candidato a hermano `testimonial-spotlight`. Verificado en
claro/oscuro/RTL, caras alineadas, 5 colores distintos. Gates: `blocks:check`
verde (6 blocks) · `svelte-check` sin errores propios. **Siguiente**: F2.6 `faq`.
- 2026-07-24 — **F2.6 `faq` HECHO**. Compound (`.Item` repite) y **proxy fino del
`Accordion` del canon** (regla del handoff: leer su API, no reinventar el
disclosure): `<Faq>` + `.Header` + `.List` + `.Item`. `.List` **ES** el
`Accordion` — toda su API pasa tal cual (`type`, `bind:value`, `collapsible`,
`variant`, `size`; defaults FAQ: single, collapsible, outline). `.Item` proxya
`Accordion.Item > Header > Trigger`(snippet `question`) / `Content`(children), y
autogenera el `value` con `$props.id()` si no se pasa. `Container` estrecho
(`md`). El teclado/ARIA del disclosure salen del `Accordion`, el block no los
toca. **Hueco a decisión del usuario**: la lista estática 2/3 columnas (6 de 7
en TW NO son acordeón) — otra disposición, prop `layout` o hermano. La cola
«¿aún tienes dudas?» es app-land (la demo la pone tras `.List`). Verificado: el
acordeón abre/cierra, claro/oscuro/RTL. Gates: `blocks:check` verde (7 blocks) ·
`svelte-check` sin errores propios. **Siguiente**: F2.7 `stats-band`.

@ -0,0 +1,52 @@
# Faq
## Function
The questions section: a header over an accordion of question/answer pairs, in a
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` | `Accordion` | **is** the accordion — its whole API passes through |
| `.Item` | `Accordion.Item` + `.Header` + `.Trigger` + `.Content` | thin proxy: `question` snippet → trigger, children → content |
## Why compound (a thin proxy)
Its `Item` **repeats**, so it earns a compound API like `feature-grid`. But the
behaviour is the canon `Accordion`'s, not the block's — the block **reads that
API and passes it straight through** (`type`, `bind:value`, `collapsible`,
`variant`, `size` on `.List`) rather than reinventing disclosure. `.Item` is the
one convenience: it spares the app the `Accordion.Item > Header > Trigger` /
`Content` scaffolding and auto-generates the item `value` when omitted.
## Decisions
**2026-07-24 — reference floor** (dossier §P1: Tailwind Plus «FAQs 7» ·
Untitled UI «FAQ 16» · Flowbite):
- **Adopted**: the accordion column (single, collapsible) — the interactive
format, with the canon `Accordion`'s keyboard + ARIA for free.
- **`.List` exposes the accordion tal cual**: `type='multiple'`, `bind:value`,
`variant`… are the app's to set; the block does not gate them.
- **Discarded**: reinventing the disclosure. `.Item` is a proxy, not a new
behaviour.
## Demo
`web/routes/blocks/faq/` — the block full-bleed, a single-column accordion of
questions. Mini-page in `FaqSite.svelte`, shared by both surfaces.
## 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 |
## Found while composing
- _(none yet — recorded here as they surface.)_

@ -0,0 +1,15 @@
<script lang="ts">
/** Faq.Header — the section title + description. The app supplies a `Heading`
* (level 2) and a `Text`. */
import { Box } from '$uix/eidos/components/box';
import { Stack } from '$uix/eidos/components/stack';
import type { FaqHeaderProps } from './types';
let { maxWidth = '48rem', children, ...rest }: FaqHeaderProps = $props();
</script>
<Box {maxWidth} width="100%" {...rest}>
<Stack gap={3}>
{@render children?.()}
</Stack>
</Box>

@ -0,0 +1,21 @@
<script lang="ts">
/**
* Faq.Item — one question, a thin proxy over `Accordion.Item`: the `question`
* snippet becomes the trigger, the children become the content. The `value`
* (the accordion's open-state key) is auto-generated when the app omits it.
*/
import { Accordion } from '$uix/eidos/components/accordion';
import type { FaqItemProps } from './types';
let { value, disabled = false, question, children }: FaqItemProps = $props();
const uid = $props.id();
const itemValue = $derived(value ?? `faq-${uid}`);
</script>
<Accordion.Item value={itemValue} {disabled}>
<Accordion.Header>
<Accordion.Trigger>{@render question?.()}</Accordion.Trigger>
</Accordion.Header>
<Accordion.Content>{@render children?.()}</Accordion.Content>
</Accordion.Item>

@ -0,0 +1,23 @@
<script lang="ts">
/**
* Faq.List — the canon `Accordion` itself. Its whole API passes through:
* `type` (single / multiple), `value` (bindable), `collapsible`, `variant`,
* `size`. Defaults suit an FAQ — single, collapsible (any open one can close),
* outline.
*/
import { Accordion } from '$uix/eidos/components/accordion';
import type { FaqListProps } from './types';
let {
value = $bindable([]),
type = 'single',
collapsible = true,
variant = 'outline',
children,
...rest
}: FaqListProps = $props();
</script>
<Accordion bind:value {type} {collapsible} {variant} {...rest}>
{@render children?.()}
</Accordion>

@ -0,0 +1,28 @@
<script lang="ts">
/**
* Faq — a header over an accordion of questions. A narrow single column.
* Compound: its `Item` repeats (the app maps over N questions), thinly
* proxying the canon `Accordion` — the block reads that API, never reinvents
* it (the whole accordion surface passes through `List`).
*
* B contract: composes canon components only — no `.css`, no morfo, no
* strings. The block places (Section · Container · Accordion); the app
* supplies the questions and answers.
*/
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import type { FaqProps } from './types';
let { container = 'md', size = 'lg', children, ...rest }: FaqProps = $props();
</script>
<section {...rest}>
<Section {size}>
<Container size={container}>
<Stack gap={8} align="stretch">
{@render children?.()}
</Stack>
</Container>
</Section>
</section>

@ -0,0 +1,45 @@
// Faq — a header over an accordion of questions. Compound (its `Item` repeats),
// a thin proxy over the canon `Accordion`.
//
// import { Faq } from '$blocks/faq';
//
// <Faq>
// <Faq.Header>
// <Heading level={2} align="center">Preguntas frecuentes</Heading>
// </Faq.Header>
// <Faq.List>
// <Faq.Item>
// {#snippet question()}¿Cómo empiezo?{/snippet}
// Conecta el SDK y ya estás midiendo.
// </Faq.Item>
// </Faq.List>
// </Faq>
//
// `Faq.List` IS the `Accordion` — its whole API (`type`, `bind:value`,
// `collapsible`, `variant`, `size`) passes through. Every visible string is the
// app's (B-7).
import FaqComponent from './faq.svelte';
import Header from './faq-header.svelte';
import List from './faq-list.svelte';
import Item from './faq-item.svelte';
type FaqNamespace = typeof FaqComponent & {
Header: typeof Header;
List: typeof List;
Item: typeof Item;
};
const Faq = FaqComponent as FaqNamespace;
Faq.Header = Header;
Faq.List = List;
Faq.Item = Item;
export { Faq };
export default Faq;
export type {
FaqProps,
FaqHeaderProps as HeaderProps,
FaqListProps as ListProps,
FaqItemProps as ItemProps
} from './types';

@ -0,0 +1,33 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { ContainerSize } from '$uix/eidos/components/container';
import type { SectionSize } from '$uix/eidos/components/section';
import type { BoxProps } from '$uix/eidos/components/box';
import type { AccordionProps } from '$uix/eidos/components/accordion';
export type FaqProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/** Content measure — narrow by default for a single readable column. @default 'md' */
container?: ContainerSize;
/** Block-axis padding. Passed straight to `Section`. @default 'lg' */
size?: SectionSize;
/** `Header` and `List` in composition order. */
children?: Snippet;
};
/** 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;
export type FaqItemProps = {
/** Stable value for the accordion's open state. Auto-generated when omitted. */
value?: string;
/** Disable this question. @default false */
disabled?: boolean;
/** The question — the accordion trigger's label. */
question?: Snippet;
/** The answer — the accordion content. */
children?: Snippet;
};

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

@ -0,0 +1,111 @@
<script lang="ts">
/**
* Faq demo. Full-bleed on the page; device widths from `./preview`.
*/
import { Stack } from '$uix/eidos/components/stack';
import { Text } from '$uix/eidos/components/text';
import { Code } from '$uix/eidos/components/code';
import BlockDemo from '../_lib/BlockDemo.svelte';
import FaqSite from './FaqSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
const previewSrc = '/blocks/faq/preview';
</script>
<BlockDemo
name="FAQ"
slug="$blocks/faq"
{previewSrc}
previewHeight="640px"
meta={[
{ key: 'compone', value: 'Section · Container · Accordion · Heading · Text' },
{ key: 'tipo', value: 'compound (proxy fino de Accordion)' },
{ key: 'partes', value: 'Header · List · Item' },
{ key: 'strings propias', value: '0' }
]}
>
{#snippet preview()}
<FaqSite />
{/snippet}
{#snippet lede()}
La sección de preguntas: una cabecera sobre un acordeón de preguntas y respuestas, en columna
estrecha. Es un <strong>proxy fino</strong> del <Code>Accordion</Code> del canon — el block lee
su API y la pasa tal cual, no reinventa el disclosure.
{/snippet}
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
El comportamiento es del <Code>Accordion</Code>; el block solo coloca y ahorra andamiaje.
</Text>
<Stack gap={3}>
<DocRow term=".List">
<strong>Es</strong> el <Code>Accordion</Code>: toda su API pasa tal cual —
<Code>type</Code> (single/multiple), <Code>bind:value</Code>, <Code>collapsible</Code>,
<Code>variant</Code>, <Code>size</Code>. Por defecto: single, collapsible, outline.
</DocRow>
<DocRow term=".Item">
Proxy de <Code>Accordion.Item</Code>: el snippet <Code>question</Code> es el trigger, los
children son el contenido. El <Code>value</Code> (clave de estado) se autogenera si no lo
pasas.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="Faq">
<Code>container</Code> (def. <Code>md</Code>, estrecho) · <Code>size</Code> (def.
<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>.
</DocRow>
<DocRow term=".Item">
<Code>value</Code> (autogenerado) · <Code>disabled</Code> · <Code>question</Code> (snippet) +
children (la respuesta).
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="Disclosure">
El teclado (flechas, Home/End, Enter/Espacio), <Code>aria-expanded</Code> y
<Code>aria-controls</Code> salen del <Code>Accordion</Code> del canon — el block no toca la
a11y del disclosure.
</DocRow>
<DocRow term="Encabezados">
La sección un <Code>h2</Code>; cada pregunta un <Code>h3</Code> (nivel del
<Code>Accordion.Item</Code>).
</DocRow>
</Stack>
{/snippet}
{#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.
</DocRow>
<DocRow term="Cola «¿Aún tienes dudas?»">
<strong>App-land</strong> — la app pone un <Code>Text</Code> + <Code>Link</Code>/<Code>Button</Code>
tras <Code>.List</Code> (como en esta demo); un slot propio solo si una demo lo pide.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="Proxy, no reinvención">
El disclosure es del <Code>Accordion</Code>. El block no reimplementa apertura ni teclado —
pasa la API tal cual. La única conveniencia es <Code>.Item</Code>, que ahorra el andamiaje
<Code>Item &gt; Header &gt; Trigger</Code> / <Code>Content</Code>.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,66 @@
<script lang="ts">
/**
* The faq mini-page: a single-column accordion of questions, plus a page floor
* with a support tail.
*/
import { Faq } from '$blocks/faq';
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Link } from '$uix/eidos/components/link';
const faqs = [
{
q: '¿Cómo empiezo a medir?',
a: 'Instalas un único SDK, envías tu primer evento y ya lo ves en el panel. Sin configurar pipelines ni esperar a un procesamiento nocturno.'
},
{
q: '¿Cobráis por asiento?',
a: 'No. El plan se elige por volumen de eventos, no por número de personas, así que puedes invitar a todo el equipo sin que suba la factura.'
},
{
q: '¿Se muestrean los eventos?',
a: 'Nunca. Guardamos todos los eventos, así que los números cuadran con tu base de datos hasta el último registro.'
},
{
q: '¿Puedo consultar periodos antiguos sin reprocesar?',
a: 'Sí. El motor mantiene los agregados al día, así que cualquier ventana temporal responde al instante, sin recomputar cohortes.'
},
{
q: '¿Ofrecéis SSO y auditoría?',
a: 'En el plan Enterprise: SAML, SCIM, registro de auditoría y residencia de datos, listos para tu equipo de seguridad.'
}
];
</script>
<Faq>
<Faq.Header>
<Heading level={2} align="center">Preguntas frecuentes</Heading>
<Text size="lg" color="muted" align="center">
Lo que casi todo el mundo pregunta antes de conectar la primera fuente de eventos.
</Text>
</Faq.Header>
<Faq.List>
{#each faqs as item (item.q)}
<Faq.Item>
{#snippet question()}{item.q}{/snippet}
{item.a}
</Faq.Item>
{/each}
</Faq.List>
</Faq>
<!-- A floor so the page reads as a page — the support tail. -->
<Section size="lg">
<Container size="md">
<Stack gap={2} align="center">
<Text weight="medium" align="center">¿Te queda alguna duda?</Text>
<Text color="muted" align="center">
Escríbenos y te respondemos el mismo día. <Link href="#soporte">Hablar con soporte</Link>.
</Text>
</Stack>
</Container>
</Section>

@ -0,0 +1,30 @@
<script lang="ts">
/**
* The preview is its OWN page — `@` resets the layout. Axes arrive via the URL
* so the demo's iframe can drive them.
*/
import { page } from '$app/state';
import BootUix, { type BootLanguage } from '../../_lib/BootUix.svelte';
import '@/uix/eidos/index.css';
let { children } = $props();
const mode = $derived(page.url.searchParams.get('mode') === 'dark' ? 'dark' : 'light');
const dir = $derived(page.url.searchParams.get('dir') === 'rtl' ? 'rtl' : 'ltr');
const language = $derived((page.url.searchParams.get('lang') ?? 'es') as BootLanguage);
</script>
<svelte:head>
<meta name="color-scheme" content="light dark" />
</svelte:head>
<BootUix {mode} {dir} {language}>
<div
data-theme={mode}
data-mode={mode}
{dir}
style="min-block-size: 100dvh; background: var(--color-surface-default); color: var(--color-content-primary);"
>
{@render children?.()}
</div>
</BootUix>

@ -0,0 +1,9 @@
<script lang="ts">
/**
* Standalone page for the faq mini-site — same component the demo renders
* inline, served as its OWN document for the device-width frame.
*/
import FaqSite from '../FaqSite.svelte';
</script>
<FaqSite />
Loading…
Cancel
Save

Powered by TurnKey Linux.