F2.9. Slots de snippet (eyebrow · title · description · field · submit · note ·
children), `center` y `justified`, y `panel` como interruptor real: el panel de
marca es la cara de las referencias, pero el control, la ayuda y el error están
calibrados para la superficie de página, así que apagarlo es un estado de primera
y no un fallback.
La fila es `Grid templateColumns={{ base: '1fr', sm: '1fr auto' }}`: el campo se
queda la pista libre y la acción abraza su contenido —eso es lo que hace que un
alta se lea como UN gesto— y bajo `sm` pasa a una columna, porque un botón al lado
de un campo de correo deja inservibles a los dos.
El block NO valida y NO emite sema. `Form` posee el runtime, el esquema, dirty/
touched, la agregación de errores y el foco al primer error; `Field` posee el
cableado ARIA; el morfo del `Form` ya declara `commit-submit` y `signal-invalid`.
El app posee esquema, valores y handler. No hay ni un `if` sobre un correo aquí.
## Superación del dossier: la etiqueta
El único hueco que el dossier nombraba era la nota de privacidad, y está. Pero la
diferencia de verdad es la etiqueta: las referencias shipean la fila escondiéndola
con `sr-only` o dejando solo un placeholder. El canon tiene `Field floatingLabel`
—arranca dentro del control y sube al borde al enfocar o rellenar—, así que la fila
queda alineada CON etiqueta real y asociada. Verificado con pulsaciones de teclado
de verdad: 10px dentro en reposo → −11px sobre el borde al enfocar y al rellenar.
## Tres hallazgos de canon más, medidos
- **La fundación de eidos no trae reset de modelo de caja y lo asume del app.**
`[data-field-control]` declara `inline-size: 100%` + padding, así que bajo
`content-box` el control mide 30px más que su contenedor: el campo se metía por
debajo del botón de envío. Campo 480 / control 510 en la galería frente a
502 / 502 en los docs de componentes, que sí resetean (igual que
`web/routes/active/styles.css`). Arreglado en app-land con
`web/routes/blocks/_lib/reset.css`, con A/B sobre los 10 previews y 5 páginas de
shell: cambia el newsletter y NADA más. Hay que importarlo dos veces porque la
galería arranca UIX en línea en vez de pasar por `BootUix` — deuda del arnés,
anotada en el handoff.
- **`onValidSubmit` es un no-op silencioso** cuando se pasa un `form` ya
construido: el componente solo lo reenvía al `createForm` que hace él mismo. El
envío validaba, limpiaba el error y no anunciaba nada. Por eso el block no
expone el prop: el handler va en el `createForm` del app.
- **Los mensajes de SIUM son idlangref.** La vía correcta es
`uix.langs.t(issue.message, issue.params)` —verificado, sale «Debe ser una
dirección de correo válida»—, pero la demo de docs del propio `Form` parte la
cadena a mano tras el `|`, así que el único ejemplo del repo enseña el patrón
equivocado y siempre muestra inglés.
Los tres quedan en los gaps del README y en `PLAN-blocks-quality.md` §6
(F18/F19/F20), sin tocar nada fuera del tier.
Verificado en navegador el arco completo: correo inválido → error traducido con
`role="alert"`, `aria-invalid`, `aria-describedby` y foco al primer error; correo
válido → confirmación del app (`Callout` afirmativo con la dirección) y error
limpio. Claro/oscuro/RTL, tres colores, `panel` sí/no, 420px. Cero errores de
página. Gates: `blocks:check` verde (10 blocks) · `svelte-check` sin errores
propios · prettier limpio.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
alpha-0.1-sec-dom
parent
25f8126e0a
commit
f69cf76cfd
@ -0,0 +1,129 @@
|
||||
# Newsletter
|
||||
|
||||
## Function
|
||||
|
||||
The sign-up section: a title, a supporting line, one email field and its action,
|
||||
plus the privacy note under them. Either the closing beat of a page (`center`) or
|
||||
a mid-page arrangement with the copy beside the form (`justified`).
|
||||
|
||||
Separate from `cta` because the form brings a11y wiring and states (invalid,
|
||||
submitting) a pure CTA does not have — and because the app has to own a schema
|
||||
and a handler here.
|
||||
|
||||
## Composition map
|
||||
|
||||
| Slot | Composes | Notes |
|
||||
| ------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- |
|
||||
| root | bare `<section aria-labelledby>` | landmark; `id` names it from the title the block renders |
|
||||
| padding · measure | `Section` + `Container` | `size` / `container` |
|
||||
| entrance | `Motion` (`trigger="viewport"`, `scale-fade`) | one entrance for the whole section — a sign-up is a single ask |
|
||||
| panel | `Surface` (always `solid` · `color` · `gradient` · `rounded`) | `panel={false}` drops it and the form returns to the page surface |
|
||||
| layout | `Stack` (center) · `Grid` 2-col (justified) | |
|
||||
| form root | `Form` (`variant="plain"`) | `form` (required) + `schema` forwarded verbatim; nothing else |
|
||||
| the row | `Grid templateColumns={{ base: '1fr', sm: '1fr auto' }}` | field takes the free track, action hugs; one column below `sm` |
|
||||
| `title` | `Heading` at `level` (default 2) | owns the landmark `id`; on-solid ink on the panel |
|
||||
| `description` | `Text` `as="p"` (`60ch` measure) | a `span` would ignore `align` — `text-align` is inert on an inline box |
|
||||
| `field` · `submit` | — (app: `Field` / `Form.Submit`) | separate slots so the block can arrange the row |
|
||||
| `note` | `Text` `as="p"` `size="sm"` | quieter by SIZE, same ink: `muted` would vanish on a solid panel |
|
||||
| measure of the form | `Container size="sm"` (center only) | a field line as wide as the prose is unusable |
|
||||
|
||||
## What it does NOT do
|
||||
|
||||
**It does not validate.** `Form` owns the runtime, the Standard Schema / SIUM
|
||||
validation, dirty/touched, error aggregation and first-error focus; `Field` owns
|
||||
the label↔control↔error ARIA wiring. The app owns the schema, the values and the
|
||||
handler — all three live in its `createForm`. The block forwards the handle and
|
||||
arranges a row: there is not one `if` about an email address in this file. If a
|
||||
sign-up ever needs new behaviour it goes to `$libs/forms` → soma `Form` → morfo,
|
||||
which is the decision the Form README already wrote down.
|
||||
|
||||
**It emits no semantic event.** `Form`'s morfo already declares `commit-submit`
|
||||
(fulfill) and `signal-invalid` (risk). A block emitting its own would give one
|
||||
submit two voices.
|
||||
|
||||
## Form: snippet slots, not sub-components
|
||||
|
||||
The tier's rule is that a compound API is earned when parts **repeat**
|
||||
(`feature-grid.Item`) or **coordinate** (`pricing.Switch` ↔ `PlanPrice`). A
|
||||
sign-up's parts do neither. _(The plan sketched `.Title`/`.Description`/`.Form`;
|
||||
this is the same registered deviation as `hero` and `cta`.)_
|
||||
|
||||
`field` and `submit` are two slots rather than one, because the block's whole
|
||||
layout job is the relationship between them.
|
||||
|
||||
## Decisions
|
||||
|
||||
**2026-07-30 — reference floor** (dossier: Tailwind Plus «Newsletter» 6 ·
|
||||
Flowbite Blocks · Untitled UI):
|
||||
|
||||
- **Adopted**: the centred panel with an inline row (parity) **and the privacy
|
||||
note slot** — the one gap the dossier names for this block (_"Añadir slot de
|
||||
nota de privacidad bajo el field (convergente)"_).
|
||||
- **Adopted**: the side-by-side arrangement as `layout="justified"`, reusing
|
||||
`cta`'s vocabulary so the two read as one family.
|
||||
- **Surpassed — the label**: the references ship the inline row by hiding the
|
||||
label with `sr-only` (or dropping it for a bare placeholder). The canon has
|
||||
`Field floatingLabel`: the label starts inside the control and floats to the
|
||||
border on focus or fill, so the row stays aligned **with** a real, associated,
|
||||
visible label. Nothing in the catalogs can ship that from a static dump.
|
||||
- **Not adopted — the panel by default is a switch, not a law.** `panel={true}`
|
||||
gives the brand band the references show, but the control, helper and error
|
||||
inks are calibrated for the page surface, so `panel={false}` is a first-class
|
||||
state and not a fallback.
|
||||
- **Discarded — success feedback.** The Form README defers it to the consumer;
|
||||
the demo shows it with a `Callout` in `children`.
|
||||
- **It does not compose `cta`** (B-10: no block imports another). The panel
|
||||
composition is a deliberate duplication — same shape, same defaults, two files.
|
||||
|
||||
## Demo
|
||||
|
||||
`web/routes/blocks/newsletter/` — the block full-bleed on the page, with live
|
||||
control of layout, panel, colour, finish and direction, and the device widths
|
||||
served from `preview/`. Mini-page in `NewsletterSite.svelte`, shared by both
|
||||
surfaces: it owns the SIUM schema, `createForm` (with
|
||||
`validationBehaviour: 'progressive'` — a field that starts empty must not be
|
||||
flagged on first paint) and what subscribing means.
|
||||
|
||||
## Gaps
|
||||
|
||||
| Gap | Disposition |
|
||||
| -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| **Confirmation after sign-up** | **app-land** — `Form` already emits `commit-submit`; what subscribing means belongs to whoever receives the values |
|
||||
| **More than one field** (name + email) | **deferred** — the row is single-field by design; two fields want `Form.ErrorSummary` and another arrangement |
|
||||
| **`VisuallyHidden` primitive** | **canon** — there is none, so the references' "input only, label for AT" row cannot be reproduced with an associated label. Not needed here (`floatingLabel` is the better answer), but it is a hole in the vocabulary |
|
||||
| **Newsletter inside the footer** | **F2.10** — 3 of 7 in Tailwind Plus put it in the footer; that is a `site-footer` slot, not another arrangement of this block |
|
||||
| **Error ink on a solid panel** | **canon, low** — `Field.ErrorText` is `risk` ink, calibrated against the page surface. Legible on the panel, but that is not the background it was designed for |
|
||||
|
||||
## Found while composing
|
||||
|
||||
Four things, all measured in the browser rather than reasoned about. The three
|
||||
canon ones are frozen (the user's standing constraint: nothing outside `blocks/`)
|
||||
and registered in `docs/process/PLAN-blocks-quality.md` §6.
|
||||
|
||||
- **No box-model reset in the foundation.** `[data-field-control]` declares
|
||||
`inline-size: 100%` + `padding-inline`, so under `content-box` the control
|
||||
renders **30px wider than its container** — in this block's `1fr auto` row it
|
||||
slid straight under the submit button. Measured: field 480 / control 510 in the
|
||||
blocks gallery versus 502 / 502 on the component docs, whose `uix.css` resets
|
||||
`box-sizing` under `[data-uix-docs]`. `web/routes/active/styles.css` does the
|
||||
same. So the framework _assumes_ the app provides `border-box` and only
|
||||
documents that assumption by doing it twice in app-land. **Fixed on my side**:
|
||||
`web/routes/blocks/_lib/reset.css`, A/B'd across all ten block previews and five
|
||||
shell pages — it changes this block and **nothing else**.
|
||||
- **`onValidSubmit` is a silent no-op on a prebuilt form.** `Form.Provider` only
|
||||
forwards `onValidSubmit`/`onInvalidSubmit` into the form it creates itself (the
|
||||
`defaults` path); pass a `form` from `createForm` and the props are ignored
|
||||
without a warning. Cost me a submit that validated, cleared and announced
|
||||
nothing. That is why this block does not expose them: the handler goes in
|
||||
`createForm`.
|
||||
- **SIUM messages are idlangrefs and the only example hand-parses them.** An
|
||||
invalid email comes back as `#?sium.errors.email|Must be a valid email address`.
|
||||
The supported path is `uix.langs.t(issue.message, issue.params)`, which resolves
|
||||
the catalog (verified: it renders _"Debe ser una dirección de correo válida"_,
|
||||
not the English fallback). The `Form`'s own docs demo instead slices the string
|
||||
after the `|` with a local `fallbackMessage` helper — so the repo's one example
|
||||
teaches the wrong pattern and always shows English.
|
||||
- **The blocks gallery has two composition roots.** `web/routes/blocks/+layout@.svelte`
|
||||
boots UIX inline instead of using `_lib/BootUix.svelte`, which the previews use —
|
||||
the same chain written twice, and the reason the reset needed importing in both
|
||||
places. Demo-harness debt, noted in the handoff.
|
||||
@ -0,0 +1,22 @@
|
||||
// Newsletter — the sign-up section: copy, one email field, one action.
|
||||
//
|
||||
// import { Newsletter } from '$blocks/newsletter';
|
||||
//
|
||||
// <Newsletter {form} {schema} onValidSubmit={subscribe}>
|
||||
// {#snippet title()}Una carta cada martes{/snippet}
|
||||
// {#snippet description()}Lo que cambió en el producto, sin relleno.{/snippet}
|
||||
// {#snippet field()}<Field name="email" …>…</Field>{/snippet}
|
||||
// {#snippet submit()}<Form.Submit color="fulfill">Suscribirme</Form.Submit>{/snippet}
|
||||
// {#snippet note()}Nada de spam. Te das de baja en un clic.{/snippet}
|
||||
// </Newsletter>
|
||||
//
|
||||
// <Newsletter layout="justified" panel={false}>…</Newsletter>
|
||||
//
|
||||
// The block arranges; it does NOT validate. `Form` owns the runtime and the
|
||||
// semantic arc (`commit-submit` / `signal-invalid`), the app owns the schema and
|
||||
// the handler, and every visible word arrives through a snippet (B-7).
|
||||
import Newsletter from './newsletter.svelte';
|
||||
|
||||
export { Newsletter };
|
||||
export default Newsletter;
|
||||
export type { NewsletterProps, NewsletterLayout } from './types';
|
||||
@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Newsletter — the sign-up section: a title, a supporting line, one email field
|
||||
* and its action. `center` stacks the copy over the form for the closing beat of
|
||||
* a page; `justified` puts the copy at the inline-start and the form at the end.
|
||||
*
|
||||
* FORM: snippet slots, not sub-components — the tier's rule (compound is earned
|
||||
* when parts REPEAT or COORDINATE, and a sign-up's parts do neither).
|
||||
*
|
||||
* WHAT IT DOES NOT DO: validation. `Form` owns the runtime, the schema, the
|
||||
* dirty/touched state, the error aggregation and the first-error focus; the app
|
||||
* owns the schema, the values and the submit handler — all three live in its
|
||||
* `createForm`. The block forwards the handle and arranges the row. Same for
|
||||
* the semantic arc: `Form`'s morfo already declares `commit-submit` (fulfill)
|
||||
* and `signal-invalid` (risk) — a block that emitted its own would be inventing
|
||||
* a second voice for one event.
|
||||
*
|
||||
* B contract: canon components only — no `.css`, no morfo, no strings of its
|
||||
* own. It does NOT compose the `cta` block (B-10: no block imports another), so
|
||||
* the panel composition is deliberately duplicated from it.
|
||||
*/
|
||||
import { Section } from '$uix/eidos/components/section';
|
||||
import { Container } from '$uix/eidos/components/container';
|
||||
import { Surface } from '$uix/eidos/components/surface';
|
||||
import { Stack } from '$uix/eidos/components/stack';
|
||||
import { Grid } from '$uix/eidos/components/grid';
|
||||
import { Box } from '$uix/eidos/components/box';
|
||||
import { Heading } from '$uix/eidos/components/heading';
|
||||
import { Text } from '$uix/eidos/components/text';
|
||||
import { Form } from '$uix/eidos/components/form';
|
||||
import { Motion } from '$uix/eidos/components/motion';
|
||||
import type { NewsletterProps } from './types';
|
||||
|
||||
let {
|
||||
layout = 'center',
|
||||
panel = true,
|
||||
color = 'primary',
|
||||
gradient = true,
|
||||
level = 2,
|
||||
container = 'lg',
|
||||
size = 'lg',
|
||||
form,
|
||||
schema,
|
||||
eyebrow,
|
||||
title,
|
||||
description,
|
||||
field,
|
||||
submit,
|
||||
note,
|
||||
children,
|
||||
...rest
|
||||
}: NewsletterProps = $props();
|
||||
|
||||
const uid = $props.id();
|
||||
const titleId = `${uid}-title`;
|
||||
const centered = $derived(layout === 'center');
|
||||
// On the panel the ink is the contrast one the palette guarantees for that
|
||||
// canvas; off the panel the normal content inks read.
|
||||
const ink = $derived(panel ? 'on-solid' : undefined);
|
||||
</script>
|
||||
|
||||
<!-- The copy cluster: identical in both layouts, only its alignment changes. -->
|
||||
{#snippet copy()}
|
||||
<Stack gap={4} align={centered ? 'center' : 'start'}>
|
||||
{#if eyebrow}
|
||||
{@render eyebrow()}
|
||||
{/if}
|
||||
{#if title}
|
||||
<Heading
|
||||
id={titleId}
|
||||
{level}
|
||||
align={centered ? 'center' : 'start'}
|
||||
wrap="balance"
|
||||
color={ink}
|
||||
>
|
||||
{@render title()}
|
||||
</Heading>
|
||||
{/if}
|
||||
{#if description}
|
||||
<Box maxWidth="60ch">
|
||||
<!-- `as="p"`: Text renders a span by default, and `text-align` is inert on
|
||||
an inline box — the lines would keep their `start` alignment. -->
|
||||
<Text
|
||||
as="p"
|
||||
size="lg"
|
||||
color={ink ?? 'muted'}
|
||||
align={centered ? 'center' : 'start'}
|
||||
wrap="pretty"
|
||||
>
|
||||
{@render description()}
|
||||
</Text>
|
||||
</Box>
|
||||
{/if}
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
<!--
|
||||
The row: the field takes the free track and the action hugs its content, which
|
||||
is what makes a sign-up read as ONE gesture. It becomes a single column below
|
||||
`sm`, where a button beside an email input leaves neither of them usable.
|
||||
`templateColumns` rather than a flex `grow` on the field — `Box`/`Surface`
|
||||
`flex` does not grow a child today (gap registered in the hero's README).
|
||||
-->
|
||||
{#snippet formBlock()}
|
||||
<Form {form} {schema} variant="plain">
|
||||
<Stack gap={3} align={centered ? 'center' : 'stretch'} width="100%">
|
||||
<Grid templateColumns={{ base: '1fr', sm: '1fr auto' }} gap={3} align="start" width="100%">
|
||||
{#if field}
|
||||
{@render field()}
|
||||
{/if}
|
||||
{#if submit}
|
||||
{@render submit()}
|
||||
{/if}
|
||||
</Grid>
|
||||
{#if note}
|
||||
<!-- Quieter by SIZE, not by ink: `muted` would vanish on a solid panel. -->
|
||||
<Text as="p" size="sm" color={ink} align={centered ? 'center' : 'start'}>
|
||||
{@render note()}
|
||||
</Text>
|
||||
{/if}
|
||||
</Stack>
|
||||
</Form>
|
||||
{/snippet}
|
||||
|
||||
{#snippet body()}
|
||||
{#if centered}
|
||||
<Stack gap={6} align="center" width="100%">
|
||||
{@render copy()}
|
||||
<!-- A form line as wide as the prose is unusable: the field gets its own,
|
||||
narrower measure. -->
|
||||
<Container size="sm" paddingX={0}>
|
||||
{@render formBlock()}
|
||||
</Container>
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</Stack>
|
||||
{:else}
|
||||
<Grid columns={{ base: 1, md: 2 }} gap={{ base: 6, md: 10 }} align="center">
|
||||
{@render copy()}
|
||||
<Box>
|
||||
{@render formBlock()}
|
||||
{#if children}
|
||||
{@render children()}
|
||||
{/if}
|
||||
</Box>
|
||||
</Grid>
|
||||
{/if}
|
||||
{/snippet}
|
||||
|
||||
<section aria-labelledby={title ? titleId : undefined} {...rest}>
|
||||
<Section {size}>
|
||||
<Container size={container}>
|
||||
<!-- One entrance for the whole section: a sign-up is a single ask. -->
|
||||
<Motion trigger="viewport" motion="scale-fade">
|
||||
{#if panel}
|
||||
<!-- Always the SOLID canvas — the soft track measures 0.002 L from
|
||||
`--color-surface-default` in light mode, so it does not read as a
|
||||
panel (measured in `cta`). -->
|
||||
<Surface
|
||||
variant="solid"
|
||||
{color}
|
||||
{gradient}
|
||||
rounded
|
||||
padding={{ base: 6, md: 10 }}
|
||||
width="100%"
|
||||
>
|
||||
{@render body()}
|
||||
</Surface>
|
||||
{:else}
|
||||
{@render body()}
|
||||
{/if}
|
||||
</Motion>
|
||||
</Container>
|
||||
</Section>
|
||||
</section>
|
||||
@ -0,0 +1,93 @@
|
||||
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 { HeadingLevel } from '$uix/eidos/components/heading';
|
||||
import type { FormProps } from '$uix/eidos/components/form';
|
||||
import type { SurfaceProps } from '$uix/eidos/components/surface';
|
||||
import type { ComponentColorProp } from '$uix/eidos/lib/types';
|
||||
|
||||
/** How the panel arranges its copy and its form. */
|
||||
export type NewsletterLayout = 'center' | 'justified';
|
||||
|
||||
// `title` is omitted from the element attributes because the block reuses the
|
||||
// name for the title snippet (the HTML `title` tooltip attribute would otherwise
|
||||
// intersect the Snippet into `string & Snippet` — the hero lesson).
|
||||
export type NewsletterProps = Omit<HTMLAttributes<HTMLElement>, 'children' | 'title'> & {
|
||||
/**
|
||||
* `center` stacks the copy over the form, centred — the closing beat of a
|
||||
* page. `justified` puts the copy at the inline-start and the form at the end:
|
||||
* the mid-page or in-footer arrangement.
|
||||
* @default 'center'
|
||||
*/
|
||||
layout?: NewsletterLayout;
|
||||
/**
|
||||
* Paint the section as a panel (`Surface`, the system's own finish). Turn it
|
||||
* off for a newsletter that sits on the page surface — the form's own controls,
|
||||
* helper text and error text are calibrated for that background.
|
||||
* @default true
|
||||
*/
|
||||
panel?: boolean;
|
||||
/**
|
||||
* Panel accent — the full colour system. Pick a DARK canvas: the palette's
|
||||
* contrast slot is white on every solid step, so a mid-lightness canvas puts
|
||||
* the copy below AA (measured in `cta`: `primary` 5.18 · `indigo` 5.21 ·
|
||||
* `plum` 4.75 pass, `neutral` 3.32 · `teal` 3.07 fail in light).
|
||||
* Ignored when `panel` is false.
|
||||
* @default 'primary'
|
||||
*/
|
||||
color?: ComponentColorProp;
|
||||
/**
|
||||
* Gradient finish on the panel — the system's treatment, derived from this
|
||||
* instance's palette. Ignored when `panel` is false.
|
||||
* @default true
|
||||
*/
|
||||
gradient?: SurfaceProps['gradient'];
|
||||
/** Semantic level of the title heading. @default 2 */
|
||||
level?: HeadingLevel;
|
||||
/** Content measure. Passed straight to `Container`. @default 'lg' */
|
||||
container?: ContainerSize;
|
||||
/** Block-axis padding of the SECTION (around the panel). @default 'lg' */
|
||||
size?: SectionSize;
|
||||
/**
|
||||
* The form handle from `createForm` — REQUIRED. The app owns the schema, the
|
||||
* values, the defaults, the validation timing AND the submit handler, because
|
||||
* `createForm` is where they all live:
|
||||
*
|
||||
* createForm({ schema, defaults: { email: '' }, onValidSubmit, … })
|
||||
*
|
||||
* The block deliberately does NOT expose `onValidSubmit`: `Form.Provider`
|
||||
* ignores that prop whenever a prebuilt `form` is passed (it only forwards it
|
||||
* into the form it creates itself), so a block accepting it would advertise a
|
||||
* callback that silently never fires. Registered in the README's findings.
|
||||
*/
|
||||
form: NonNullable<FormProps['form']>;
|
||||
/**
|
||||
* Standard Schema / SIUM schema — optional override; `Form` falls back to the
|
||||
* one the form was built with (or the `SiumProvider`).
|
||||
*/
|
||||
schema?: FormProps['schema'];
|
||||
/** A chip or label above the title. */
|
||||
eyebrow?: Snippet;
|
||||
/** The title's words — the block wraps them in a `Heading` and owns the
|
||||
* landmark `id`. */
|
||||
title?: Snippet;
|
||||
/** The supporting line — the block wraps it in a `Text`. */
|
||||
description?: Snippet;
|
||||
/**
|
||||
* The email field — the app composes its own `Field` (name, label,
|
||||
* placeholder and validation message are all its words, B-7). It lands in the
|
||||
* growing column of the row.
|
||||
*/
|
||||
field?: Snippet;
|
||||
/** The submit action — the app's `Form.Submit`. It hugs its content. */
|
||||
submit?: Snippet;
|
||||
/**
|
||||
* The privacy note under the row — the slot the reference catalogs converge
|
||||
* on. Small, same ink as the copy: a note is quieter by SIZE, because a muted
|
||||
* ink would disappear on a solid panel.
|
||||
*/
|
||||
note?: Snippet;
|
||||
/** Anything below the note (a logo strip, a subscriber count). */
|
||||
children?: Snippet;
|
||||
};
|
||||
@ -0,0 +1,26 @@
|
||||
/*
|
||||
* The one reset the blocks section needs, for BOTH of its surfaces (the gallery
|
||||
* shell and every block's standalone `preview` page) — which is why it is
|
||||
* imported from `BootUix`, the bootstrap they share.
|
||||
*
|
||||
* WHY: the eidos foundation ships no box-model reset; recipes assume the APP
|
||||
* provides one. Every other surface in this repo does — `web/routes/uix/uix.css`
|
||||
* under `[data-uix-docs]`, `web/routes/active/styles.css` for the app — and the
|
||||
* blocks gallery was the one harness built without it (F0).
|
||||
*
|
||||
* The cost of that omission is not cosmetic. A recipe like `[data-field-control]`
|
||||
* declares `inline-size: 100%` + `padding-inline`, so under `content-box` the
|
||||
* control renders 30px WIDER than its container: in the newsletter's
|
||||
* `1fr auto` row the email control grew past its track and slid under the submit
|
||||
* button. Measured, not guessed — field 480 / control 510 here versus 502 / 502
|
||||
* on the component docs, which have the reset.
|
||||
*
|
||||
* The canon-side question (should the foundation own this rather than assume it?)
|
||||
* is registered in `docs/process/PLAN-blocks-quality.md` §6.
|
||||
*/
|
||||
|
||||
*,
|
||||
*::before,
|
||||
*::after {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
@ -0,0 +1,261 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Newsletter 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 NewsletterSite from './NewsletterSite.svelte';
|
||||
import DocRow from '../_lib/DocRow.svelte';
|
||||
|
||||
let layout = $state<'center' | 'justified'>('center');
|
||||
let panel = $state(true);
|
||||
let color = $state('primary');
|
||||
let gradient = $state(true);
|
||||
let dir = $state<'ltr' | 'rtl'>('ltr');
|
||||
|
||||
const previewSrc = $derived(
|
||||
`/blocks/newsletter/preview?layout=${layout}&panel=${panel}&color=${color}&gradient=${gradient}&dir=${dir}`
|
||||
);
|
||||
</script>
|
||||
|
||||
<BlockDemo
|
||||
name="Newsletter"
|
||||
slug="$blocks/newsletter"
|
||||
{previewSrc}
|
||||
previewHeight="700px"
|
||||
meta={[
|
||||
{
|
||||
key: 'compone',
|
||||
value: 'Section · Container · Surface · Heading · Text · Form · Grid · Motion'
|
||||
},
|
||||
{ key: 'landmark', value: 'section aria-labelledby' },
|
||||
{ key: 'forma', value: 'slots de snippet' },
|
||||
{ key: 'validación', value: 'del Form — el block no inventa ninguna' }
|
||||
]}
|
||||
>
|
||||
{#snippet preview()}
|
||||
<NewsletterSite {layout} {panel} {color} {gradient} />
|
||||
{/snippet}
|
||||
|
||||
{#snippet lede()}
|
||||
El alta al boletín: una línea de copia, un campo y una acción. El block <strong>coloca</strong>
|
||||
— la validación, el foco al primer error y el arco semántico son del <Code>Form</Code>, y el
|
||||
esquema y el handler son del app. Escríbelo al revés y tendrías dos voces para un mismo envío.
|
||||
{/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="layout"
|
||||
>
|
||||
<ToggleGroup.Item value="center">center</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="justified">justified</ToggleGroup.Item>
|
||||
</ToggleGroup>
|
||||
</Group>
|
||||
|
||||
<Group gap={2} align="center" justify="start">
|
||||
<Text size="sm" color="muted">panel</Text>
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
attached
|
||||
value={[panel ? 'sí' : 'no']}
|
||||
onValueChange={(v) => (panel = (v[0] ?? 'sí') === 'sí')}
|
||||
aria-label="panel"
|
||||
>
|
||||
<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">color</Text>
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
attached
|
||||
value={[color]}
|
||||
onValueChange={(v) => (color = v[0] ?? color)}
|
||||
aria-label="color del panel"
|
||||
>
|
||||
<ToggleGroup.Item value="primary">primary</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="indigo">indigo</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="plum">plum</ToggleGroup.Item>
|
||||
</ToggleGroup>
|
||||
</Group>
|
||||
|
||||
<Group gap={2} align="center" justify="start">
|
||||
<Text size="sm" color="muted">acabado</Text>
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
attached
|
||||
value={[gradient ? 'sí' : 'no']}
|
||||
onValueChange={(v) => (gradient = (v[0] ?? 'sí') === 'sí')}
|
||||
aria-label="degradado del panel"
|
||||
>
|
||||
<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">dir</Text>
|
||||
<ToggleGroup
|
||||
selectionMode="single"
|
||||
size="sm"
|
||||
attached
|
||||
value={[dir]}
|
||||
onValueChange={(v) => (dir = (v[0] ?? dir) as typeof dir)}
|
||||
aria-label="dirección (solo la vista previa)"
|
||||
>
|
||||
<ToggleGroup.Item value="ltr">ltr</ToggleGroup.Item>
|
||||
<ToggleGroup.Item value="rtl">rtl</ToggleGroup.Item>
|
||||
</ToggleGroup>
|
||||
</Group>
|
||||
</Wrap>
|
||||
{/snippet}
|
||||
|
||||
{#snippet composition()}
|
||||
<Stack gap={4}>
|
||||
<Text color="muted">
|
||||
El block pone el panel, la medida y la fila; el formulario entero es del canon y las
|
||||
palabras del app.
|
||||
</Text>
|
||||
<Stack gap={3}>
|
||||
<DocRow term="la fila">
|
||||
<Code>Grid templateColumns={"{{ base: '1fr', sm: '1fr auto' }}"}</Code>: el campo se queda
|
||||
la pista libre y la acción abraza su contenido — eso es lo que hace que un alta se lea
|
||||
como UN gesto. Bajo <Code>sm</Code> pasa a una columna, porque un botón al lado de un campo
|
||||
de correo deja inservibles a los dos. Se usa <Code>templateColumns</Code> y no un
|
||||
<Code>grow</Code> porque el <Code>flex</Code> de <Code>Box</Code> no hace crecer a un hijo (hueco
|
||||
registrado en el <Code>hero</Code>).
|
||||
</DocRow>
|
||||
<DocRow term="la etiqueta">
|
||||
La demo compone <Code>Field floatingLabel</Code>: la etiqueta arranca dentro del control y
|
||||
sube al borde al enfocar o rellenar. Así la fila queda alineada <em>sin</em> sacrificar la
|
||||
etiqueta — que es lo que hacen las referencias, que la esconden con
|
||||
<Code>sr-only</Code>. Exige <Code>placeholder=" "</Code> en el input.
|
||||
</DocRow>
|
||||
<DocRow term="la nota">
|
||||
Bajo la fila, más pequeña y con <strong>la misma tinta</strong>: una nota se apaga por
|
||||
TAMAÑO, porque una tinta <Code>muted</Code> desaparecería sobre el panel sólido. Es el slot
|
||||
en el que converge el catálogo de referencia.
|
||||
</DocRow>
|
||||
<DocRow term="la medida">
|
||||
En <Code>center</Code> el formulario recibe su propio <Code>Container size="sm"</Code>:
|
||||
una línea de campo tan ancha como la prosa es inusable.
|
||||
</DocRow>
|
||||
<DocRow term="entrada">
|
||||
Un solo <Code>Motion trigger="viewport"</Code>: un alta es una sola petición.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet api()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Newsletter — disposición">
|
||||
<Code>layout</Code> (<Code>'center' | 'justified'</Code>, def. <Code>center</Code>) ·
|
||||
<Code>panel</Code> (def. <Code>true</Code>) · <Code>color</Code> (def.
|
||||
<Code>primary</Code>) · <Code>gradient</Code> (def. <Code>true</Code>) ·
|
||||
<Code>level</Code> (def. <Code>2</Code>) · <Code>container</Code> · <Code>size</Code>.
|
||||
</DocRow>
|
||||
<DocRow term="Newsletter — formulario">
|
||||
<Code>form</Code> (obligatorio) y <Code>schema</Code> (opcional): se reenvían tal cual al
|
||||
<Code>Form</Code>. Todo lo demás —<Code>defaults</Code>,
|
||||
<Code>validationBehaviour</Code> y el <strong>handler de envío</strong>— vive en el
|
||||
<Code>createForm</Code> del app, que es donde el framework lo lee.
|
||||
</DocRow>
|
||||
<DocRow term="eyebrow · title · description · field · submit · note · children">
|
||||
Snippets: todo el contenido entra por composición (B-7). <Code>field</Code> y
|
||||
<Code>submit</Code> van separados para que el block pueda arreglar la fila.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet a11y()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Landmark">
|
||||
Un <Code>section</Code> nombrado por su título vía <Code>aria-labelledby</Code>. Sin título
|
||||
no se emite el atributo.
|
||||
</DocRow>
|
||||
<DocRow term="El campo lo cablea el Field">
|
||||
<Code>for</Code> ↔ <Code>id</Code>, <Code>aria-describedby</Code> al error,
|
||||
<Code>aria-invalid</Code>, <Code>role="alert"</Code> en el <Code>ErrorText</Code> y el foco al
|
||||
primer error tras un envío inválido: todo del <Code>Field</Code>/<Code>Form</Code>. El block
|
||||
no toca ni un atributo.
|
||||
</DocRow>
|
||||
<DocRow term="Sema">
|
||||
<Code>commit-submit</Code> (fulfill) y <Code>signal-invalid</Code> (risk) los declara el morfo
|
||||
del <Code>Form</Code>. El block no emite nada: dos voces para un mismo envío serían un error
|
||||
de doctrina, no una redundancia.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet gaps()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="Confirmación tras el alta">
|
||||
<strong>App-land</strong> — el <Code>Form</Code> ya emite <Code>commit-submit</Code>; qué
|
||||
significa suscribirse lo decide quien recibe los valores. La demo lo enseña con un
|
||||
<Code>Callout</Code> en el slot <Code>children</Code>.
|
||||
</DocRow>
|
||||
<DocRow term="Más de un campo (nombre + correo)">
|
||||
<strong>Diferido</strong> — la fila es de un campo por diseño. Dos ya piden
|
||||
<Code>Form.ErrorSummary</Code> y otra disposición; entra cuando una demo lo pida.
|
||||
</DocRow>
|
||||
<DocRow term="Etiqueta solo para lectores">
|
||||
<strong>Canon</strong> — no hay primitivo <Code>VisuallyHidden</Code> en el ecosistema, así que
|
||||
la fila «solo input» de las referencias no se puede reproducir con etiqueta asociada. Aquí no
|
||||
hizo falta: <Code>floatingLabel</Code> resuelve el caso mejor.
|
||||
</DocRow>
|
||||
<DocRow term="Newsletter dentro del footer">
|
||||
<strong>F2.10</strong> — 3 de 7 en Tailwind Plus lo llevan en el pie. Es un slot del
|
||||
<Code>site-footer</Code>, no otra disposición de este block.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
|
||||
{#snippet notes()}
|
||||
<Stack gap={3}>
|
||||
<DocRow term="No compone el `cta`">
|
||||
B-10: ningún block importa otro. El panel es una duplicación consciente de la composición
|
||||
del <Code>cta</Code> — misma forma, mismos defaults, dos ficheros.
|
||||
</DocRow>
|
||||
<DocRow term="`panel` es un interruptor real">
|
||||
Apágalo y el formulario vuelve a la superficie de página, que es para la que están
|
||||
calibrados el control, el texto de ayuda y el de error. Sobre panel sólido el error sigue
|
||||
siendo tinta <Code>risk</Code>: legible, pero no es su fondo de diseño.
|
||||
</DocRow>
|
||||
<DocRow term="El block no valida">
|
||||
Ni un <Code>if</Code> sobre el correo. Si hiciera falta lógica nueva, iría a
|
||||
<Code>$libs/forms</Code> → soma <Code>Form</Code> → morfo, nunca aquí — es la decisión que ya
|
||||
tiene escrita el README del <Code>Form</Code>.
|
||||
</DocRow>
|
||||
<DocRow term="Por qué el handler va en `createForm`">
|
||||
Porque en el otro sitio no se ejecuta: <Code>Form.Provider</Code> solo reenvía
|
||||
<Code>onValidSubmit</Code> al form que crea él mismo, así que con un handle ya construido ese
|
||||
prop es un no-op silencioso. Medido, no supuesto. Por eso el block no lo expone.
|
||||
</DocRow>
|
||||
<DocRow term="El mensaje pasa por el traductor">
|
||||
SIUM devuelve un idlangref (<Code>#?sium.errors.email|…</Code>). La demo lo resuelve con
|
||||
<Code>uix.langs.t(issue.message, issue.params)</Code> y sale en castellano. Partir la cadena tras
|
||||
el <Code>|</Code> —lo que hace la demo de docs del <Code>Form</Code>— deja siempre el
|
||||
inglés.
|
||||
</DocRow>
|
||||
</Stack>
|
||||
{/snippet}
|
||||
</BlockDemo>
|
||||
@ -0,0 +1,145 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* The newsletter mini-page: the block with real product copy plus enough page
|
||||
* around it to read as a page — so the demo can render it FULL-BLEED and the
|
||||
* `preview` route can serve it as a standalone document.
|
||||
*
|
||||
* The APP owns the schema, the values and the handler. That is the whole point
|
||||
* of the block's form pass-through: `Form` validates, this file decides what
|
||||
* subscribing means.
|
||||
*/
|
||||
import { Newsletter } from '$blocks/newsletter';
|
||||
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 { Badge } from '$uix/eidos/components/badge';
|
||||
import { Link } from '$uix/eidos/components/link';
|
||||
import { Callout } from '$uix/eidos/components/callout';
|
||||
import { Form } from '$uix/eidos/components/form';
|
||||
import { Field } from '$uix/eidos/components/field';
|
||||
import { getActiveUix } from '$active-uix';
|
||||
import { createForm, type FormIssue } from '$libs/forms';
|
||||
import { object, string, pipe, email, meta } from '$sium/core';
|
||||
|
||||
const uix = getActiveUix();
|
||||
|
||||
type Layout = 'center' | 'justified';
|
||||
|
||||
let {
|
||||
layout = 'center' as Layout,
|
||||
panel = true,
|
||||
color = 'primary',
|
||||
gradient = true
|
||||
}: {
|
||||
layout?: Layout;
|
||||
panel?: boolean;
|
||||
color?: string;
|
||||
gradient?: boolean;
|
||||
} = $props();
|
||||
|
||||
// The app's data contract — SIUM, so the message travels through the catalog
|
||||
// and the field registration knows its label.
|
||||
const schema = object({
|
||||
email: pipe(string(), email(), meta({ label: 'Correo electrónico' }))
|
||||
});
|
||||
let subscribed = $state<string | null>(null);
|
||||
|
||||
// `progressive` (the Form's own default) is the right timing for a field that
|
||||
// STARTS empty: `onChange`/`onBlur` would flag an untouched, necessarily invalid
|
||||
// input on first paint — the noise the Form README warns about.
|
||||
//
|
||||
// The handler lives HERE, in `createForm`, not on the block: `Form.Provider`
|
||||
// only forwards `onValidSubmit` into a form it creates itself, so on a prebuilt
|
||||
// handle that prop is a silent no-op (measured — see the block's README).
|
||||
const form = createForm({
|
||||
schema,
|
||||
defaults: { email: '' },
|
||||
validationBehaviour: 'progressive',
|
||||
onValidSubmit: (values) => {
|
||||
subscribed = (values as { email: string }).email;
|
||||
}
|
||||
});
|
||||
|
||||
// SIUM hands back an idlangref (`#?sium.errors.email|…`) plus its params, so the
|
||||
// message goes through the TRANSLATOR. `uix.langs.t` accepts the full ref and
|
||||
// interpolates — no string surgery on the `#?…|` prefix, which is what the
|
||||
// Form's own docs demo resorts to.
|
||||
const emailError = $derived.by(() => {
|
||||
const issue = (form.issues.email as FormIssue[] | undefined)?.[0];
|
||||
if (issue) return uix.langs.t(issue.message, issue.params);
|
||||
const raw = form.errors.email?.[0];
|
||||
return raw ? uix.langs.t(raw) : undefined;
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- Some page before the sign-up, so it lands where a sign-up lands. -->
|
||||
<Section size="lg">
|
||||
<Container size="lg">
|
||||
<Stack gap={4}>
|
||||
<Heading level={2}>Lo que publicamos cada semana</Heading>
|
||||
<Text color="muted" measure="wide">
|
||||
Un desglose de lo que cambió en el producto, con el porqué detrás de cada decisión. Escrito
|
||||
por quien lo construyó, no por marketing.
|
||||
</Text>
|
||||
</Stack>
|
||||
</Container>
|
||||
</Section>
|
||||
|
||||
<Newsletter {layout} {panel} {color} {gradient} {form} {schema}>
|
||||
{#snippet eyebrow()}
|
||||
<!-- `soft` on the panel: the primary track is near-white in light mode, so the
|
||||
chip reads as a pale pill against the saturated canvas. OFF the panel that
|
||||
same track IS the page, so the pill disappears and the chip needs its
|
||||
border — there is no on-solid/on-surface chip treatment in the canon. -->
|
||||
<Badge size="sm" variant={panel ? 'soft' : 'outline'} color="primary">Cada martes</Badge>
|
||||
{/snippet}
|
||||
|
||||
{#snippet title()}Una carta cada martes{/snippet}
|
||||
|
||||
{#snippet description()}
|
||||
Cambios del producto, decisiones de diseño y lo que aprendimos rompiéndolo. Sin relleno y sin
|
||||
promociones.
|
||||
{/snippet}
|
||||
|
||||
{#snippet field()}
|
||||
<!-- `floatingLabel`: la etiqueta arranca DENTRO del control y sube al borde
|
||||
al enfocar o rellenar, así que la fila queda alineada sin sacrificar la
|
||||
etiqueta. Exige `placeholder=" "` en el input. -->
|
||||
<Field name="email" label="Correo electrónico" required floatingLabel size="lg">
|
||||
<Field.Label>Correo electrónico</Field.Label>
|
||||
<Field.Control>
|
||||
<Field.Input type="email" placeholder=" " bind:value={form.values.email} />
|
||||
</Field.Control>
|
||||
{#if emailError}
|
||||
<Field.ErrorText>{emailError}</Field.ErrorText>
|
||||
{/if}
|
||||
</Field>
|
||||
{/snippet}
|
||||
|
||||
{#snippet submit()}
|
||||
<Form.Submit size="lg" color="fulfill">Suscribirme</Form.Submit>
|
||||
{/snippet}
|
||||
|
||||
{#snippet note()}
|
||||
Nada de spam. Te das de baja en un clic y no compartimos tu correo con nadie. Lee la
|
||||
<Link href="#privacidad" variant="subtle" color={panel ? 'currentColor' : undefined}>
|
||||
política de privacidad
|
||||
</Link>.
|
||||
{/snippet}
|
||||
|
||||
{#snippet children()}
|
||||
{#if subscribed}
|
||||
<!-- La confirmación es del APP: `Form` ya emitió `commit-submit`; qué
|
||||
significa suscribirse lo decide quien recibe los valores. -->
|
||||
<Callout intent="affirm">
|
||||
<Callout.Icon />
|
||||
<Callout.Title>Confirmado</Callout.Title>
|
||||
<Callout.Content>
|
||||
Te hemos enviado un correo a <strong>{subscribed}</strong> para confirmar la suscripción.
|
||||
</Callout.Content>
|
||||
</Callout>
|
||||
{/if}
|
||||
{/snippet}
|
||||
</Newsletter>
|
||||
@ -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,16 @@
|
||||
<script lang="ts">
|
||||
/**
|
||||
* Standalone page for the newsletter mini-site — same component the demo
|
||||
* renders inline, served as its OWN document for the device-width frame.
|
||||
*/
|
||||
import { page } from '$app/state';
|
||||
import NewsletterSite from '../NewsletterSite.svelte';
|
||||
|
||||
const params = $derived(page.url.searchParams);
|
||||
const layout = $derived((params.get('layout') ?? 'center') as 'center' | 'justified');
|
||||
const panel = $derived(params.get('panel') !== 'false');
|
||||
const color = $derived(params.get('color') ?? 'primary');
|
||||
const gradient = $derived(params.get('gradient') !== 'false');
|
||||
</script>
|
||||
|
||||
<NewsletterSite {layout} {panel} {color} {gradient} />
|
||||
Loading…
Reference in new issue