diff --git a/docs/process/CONTINUE-blocks.md b/docs/process/CONTINUE-blocks.md index 02cc5f792..949c27a9a 100644 --- a/docs/process/CONTINUE-blocks.md +++ b/docs/process/CONTINUE-blocks.md @@ -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` diff --git a/docs/process/PLAN-blocks.md b/docs/process/PLAN-blocks.md index 6ec0ff583..591dc7102 100644 --- a/docs/process/PLAN-blocks.md +++ b/docs/process/PLAN-blocks.md @@ -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. diff --git a/src/uix/blocks/faq/README.md b/src/uix/blocks/faq/README.md index d98bae4d5..7af7430ac 100644 --- a/src/uix/blocks/faq/README.md +++ b/src/uix/blocks/faq/README.md @@ -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` + `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` + `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 `
` 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 diff --git a/src/uix/blocks/faq/context.ts b/src/uix/blocks/faq/context.ts new file mode 100644 index 000000000..cb44b564b --- /dev/null +++ b/src/uix/blocks/faq/context.ts @@ -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(KEY)?.layout ?? 'accordion'; +} diff --git a/src/uix/blocks/faq/faq-item.svelte b/src/uix/blocks/faq/faq-item.svelte index 8c1a3b9f1..c82e13f5e 100644 --- a/src/uix/blocks/faq/faq-item.svelte +++ b/src/uix/blocks/faq/faq-item.svelte @@ -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()); - - - {@render question?.()} - - {@render children?.()} - +{#if layout === 'list'} + + + + {@render question?.()} + {@render children?.()} + + +{:else} + + + {@render question?.()} + + {@render children?.()} + +{/if} diff --git a/src/uix/blocks/faq/faq-list.svelte b/src/uix/blocks/faq/faq-list.svelte index beacd0279..5cdbf3744 100644 --- a/src/uix/blocks/faq/faq-list.svelte +++ b/src/uix/blocks/faq/faq-list.svelte @@ -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; + } + }); - - - {@render children?.()} - - +{#if props.layout === 'list'} + + + {@render props.children?.()} + +{:else} + {@const { layout: _layout, children, value = [], ...rest } = props} + + + {@render children?.()} + + +{/if} diff --git a/src/uix/blocks/faq/types.ts b/src/uix/blocks/faq/types.ts index 47efffd32..a3b7789b0 100644 --- a/src/uix/blocks/faq/types.ts +++ b/src/uix/blocks/faq/types.ts @@ -17,15 +17,53 @@ export type FaqProps = Omit, '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; diff --git a/web/routes/blocks/faq/+page.svelte b/web/routes/blocks/faq/+page.svelte index e526c468c..459748752 100644 --- a/web/routes/blocks/faq/+page.svelte +++ b/web/routes/blocks/faq/+page.svelte @@ -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}`); {#snippet preview()} - + + {/snippet} + + {#snippet controls()} + + + layout + (layout = (v[0] ?? layout) as typeof layout)} + aria-label="disposición de las preguntas" + > + accordion + list + + + {/snippet} {#snippet lede()} @@ -61,12 +85,21 @@ lg). - Todo el API del Accordion: type, bind:value, - collapsible, variant, size. + layout ('accordion' | 'list', def. accordion) y, bajo + accordion, todo el API del Accordion: type, + bind:value, collapsible, variant, + size. El tipo es una unión discriminada: bajo + list ese API ni se ofrece, 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 + minChildWidth/columns/gap. value (autogenerado) · disabled · question (snippet) + - children (la respuesta). + children (la respuesta). Las dos primeras son del arreglo accordion y bajo + list no aplican — misma convención que el backdrop del hero. El + Item 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. {/snippet} @@ -88,9 +121,11 @@ {#snippet gaps()} - Scope-approval pendiente — 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 - layout o hermano. + Hecho (V3, 2026-08-17) — .List layout="list": rejilla de P/R + siempre abiertas, la mayoría del formato (6 de 7 en TW no son acordeón). 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 pone dentro del disparador + y esa semántica es suya. Pruébalo con el control de arriba. App-land — la app pone un Text + Link/ - + + Preguntas frecuentes @@ -43,7 +48,7 @@ - + {#each faqs as item (item.q)} {#snippet question()}{item.q}{/snippet} diff --git a/web/routes/blocks/faq/preview/+page.svelte b/web/routes/blocks/faq/preview/+page.svelte index abf0726a9..79f300fa4 100644 --- a/web/routes/blocks/faq/preview/+page.svelte +++ b/web/routes/blocks/faq/preview/+page.svelte @@ -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' + ); - +