blocks(cookie-consent): la ley europea como tipo, y las dos salidas al mismo peso

Cierra el tramo C de F2b. El único block cuya fase 0 fue jurídica antes que
visual: las referencias shippean el aviso como markup, y el markup no puede
impedir las tres cosas que lo vuelven ilegal — un rechazo debilitado,
categorías premarcadas, y cerrar tratado como consentir.

Aquí son el tipo y el constructor. `essential: true` hace inescribible una UI
que ofrezca apagarlo; `initialConsent()` no admite semilla, así que una primera
pregunta no puede llegar marcada; `onDecision` es obligatoria, así que un aviso
que no informa de vuelta no se puede montar. Las cinco reglas están numeradas en
consent.ts y afirmadas por 8 tests, porque son justo lo que un refactor rompe en
silencio mientras todo sigue pintando bien.

Desviación de la ficha, y a favor: UNA llamada requerida `onDecision(consent,
via)` en vez de `onAcceptAll` + `onRejectAll`. Con dos callbacks el consumidor
podía pasar un no-op a «rechazar»; aquí el camino de rechazo lo renderiza el
block y el API no expone knob para degradarlo. Medido: las dos acciones tienen
idéntico relleno, tinta, borde, tamaño, peso y alto — 5.96:1 en claro, 8.79:1 en
oscuro.

Tres cosas se arreglaron por mirar, no por deducir. La barra era un `Card`
outline y midió 1.00:1 contra la página: se disolvía en ella, así que va al
plano `overlay` del sistema, el mismo que estampa cada superficie flotante del
canon. A 375 las tres acciones envolvían con «rechazar» en una fila y «aceptar»
en la siguiente, leyéndose como dos botones ajenos justo donde la pareja más
importa: ahora son un `Group`, y el markup lo dice. Y el panel devolvía el foco
a un nodo muerto — se devuelve aquí, un frame después de que la trampa se
desarme, con un pestillo para que el asentamiento del montaje no le robe el foco
a nadie al cargar.

Tres gaps de canon salieron al componerlo, todos en el README: `Button` acepta
`ref` y no lo reenvía (forma de A-94), `Switch` no tiene parte de etiqueta, y el
`Dialog` restaura el foco a un nodo muerto cuando su disparador se desmontó.

El instrumento mintió antes que el código, otra vez: el pane del navegador no
compone frames, así que la animación de salida no termina y un diálogo cerrado
sigue pintado — el foco medido allí era ficción. Y `fillStyle` no normaliza
oklch, así que todo color leído del estilo computado se volvió negro y dio
1.11:1 sobre texto perfectamente legible. Las cifras de esta ficha se miden por
píxel, en Chrome real.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
alpha-0.1-background
dev 2 months ago
parent 313e4d52fa
commit dc7c4920d6

@ -260,7 +260,7 @@ single-price~~ ✅ · ~~V5 cta split~~ ✅ · ~~V2 testimonials spotlight~~ ✅
2026-08-17; entra por el tramo B**) · **B** ✅ **CERRADO 2026-08-17** (15 matrices + guard encendido y probado en rojo) — 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** (~~`logo-cloud`~~ ✅ con el eje `data-ink` en la fundación · ~~`blog`~~ ✅ como **`article-grid`** — el nombre se decidió en su fase 0 — · queda `cookie-consent`, tanda propia con fase 0 legal) **C** tres blocks nuevos — `logo-cloud`
cardinalidad sin competir en dumps) · **C** ✅ **CERRADO 2026-08-18** (~~`logo-cloud`~~ ✅ con el eje `data-ink` en la fundación · ~~`blog`~~ ✅ como **`article-grid`** — el nombre se decidió en su fase 0 — · ~~`cookie-consent`~~ ✅ con su fase 0 legal: las 5 reglas son el TIPO y 8 tests, y **una sola llamada requerida `onDecision(consent, via)` en vez de las dos de la ficha** — es MÁS fuerte, porque el camino de rechazo lo renderiza el block y no el consumidor, que con dos callbacks podía pasar un no-op) **C** tres blocks nuevos — `logo-cloud`
(reabierto: las refs lo shippean estático), `blog`, `cookie-consent` (fase 0
legal profunda; la ley vive en el TIPO) — y `onboarding` NO entra (receta de
`wizard`) · **D** la página compuesta como puerta de cierre.

@ -934,7 +934,29 @@ la fase 0 de cada una lee los README reales de lo que compone.
- **Demo**: 3–6 posts con contenido real, con/sin media, autor on/off,
claro/oscuro, 375/1280.
#### Ficha N3 `cookie-consent`
#### Ficha N3 `cookie-consent` — ✅ EJECUTADA 2026-08-18
> **Cerrada.** Lo entregado y las dos desviaciones, antes de la ficha original:
>
> - **Una sola llamada requerida, `onDecision(consent, via)`**, en vez de los
> `onAcceptAll` + `onRejectAll` que la ficha pedía (la propia ficha ya
> contenía las dos formas). Es **más fuerte**, no menos: con dos callbacks el
> consumidor podía pasar un no-op a «rechazar»; aquí el camino de rechazo lo
> renderiza el block y el API no expone knob para tocarlo. `via` distingue los
> tres caminos para el log del app.
> - **`Dialog`, no `Drawer`** — la fase 0 lo decidía con la guía delante: el
> panel es una decisión corta con una salida clara, no una superficie que se
> arrastra.
> - **La barra NO es un `Card`**: `data-depth="overlay"`, el plano del sistema.
> El `Card variant="outline"` fue el primer intento y midió 1.00:1 contra la
> página — la barra se disolvía en ella.
> - **Tres gaps de canon** salieron al componerla, todos en el README: `Button`
> acepta `ref` y no lo reenvía (forma de A-94) · `Switch` no tiene parte de
> etiqueta · el `Dialog` restaura el foco a un nodo muerto cuando su
> disparador se ha desmontado.
> - Medido en Chrome real (el pane del navegador no compone frames y daba foco
> ficticio): tres caminos, teclado, anuncio capturado en la región polite,
> claro/oscuro (5.96:1 / 8.79:1 en las dos acciones), 375, RTL.
- **Función**: el aviso de consentimiento — overlay legal con estado, no una
sección. Barra anclada + panel de preferencias por categoría.

@ -0,0 +1,117 @@
# CookieConsent
## Function
The consent notice: a bar pinned to the bottom edge and a preferences panel. The
only block of the tier whose fase 0 was **legal** before it was visual — the
reference catalogs ship this as markup, and markup cannot prevent the three
things that make a cookie notice unlawful: a weakened reject, pre-ticked
categories, and closing treated as consent.
Here those are the type and the constructor. The five numbered rules live in
[`consent.ts`](./consent.ts) and are asserted by [`consent.test.ts`](./consent.test.ts).
## Composition map
| Part | Composes | Notes |
| ------------- | --------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| the bar | `Affix` + a bare `<section role="region">` | `placement="bottom-center" stretch`; the `Affix` README uses a cookie notice as its own example |
| its surface | `data-depth="overlay"` + `Box` + `Container` | the SYSTEM's overlay plane (surface + border + shadow + halo), the same channel every menu and popover stamps on itself |
| its actions | `Flex` + `Button` ×3, the two decisions in a `Group` | ghost «manage», then the pair — same `variant`, same `size`, no knob to weaken either |
| the panel | `Dialog` + `Dialog.Content size="md"` | modal, unlike the bar; `onCloseAutoFocus` prevented so the block returns the focus itself |
| its rows | `Stack` + `Group` + `Switch` + `Switch.Thumb` | label bound by `aria-labelledby`, purpose by `aria-describedby` — the reason travels WITH the control |
| its footer | `Dialog.Footer` + `Wrap` + `Button` ×3 | reject · accept (the same pair again) · save |
**Landmark**: the bar is `role="region"` + `aria-labelledby` — findable by
someone who tabbed past it, and NOT `role="dialog"`, which would imply a
modality it deliberately does not have. The panel is the canon `Dialog`'s.
## Coordination
_Position under the 2026-07-31 doctrine ([`architecture/blocks.md`](../../../../docs/architecture/blocks.md) §«Coordination»)._
**Owns its states and its words.** 12 idlangrefs, because they explain ITS
states: the category names, their purposes, the four actions and the
announcement. An app takes them over by registering `blocks.cookie-consent.*`.
It also owns the **announcement**: the bar vanishing is the only feedback a
sighted visitor gets, and none at all for anyone else, so the decision is spoken
through `uix.announce` — never a live region of the block's own.
## Decisions
**2026-08-18 — its fase 0 was jurisdictional:**
- **The rules are the TYPE, not the documentation.** `essential: true` is a
constraint, so a UI offering to switch it off cannot be written.
`initialConsent()` has no seed overload, so a first ask cannot arrive
pre-ticked. `onDecision` is required, so a notice that reports nothing is not
constructible. `rejectAll()` returns literally `initialConsent()`.
- **Both actions at one weight, with no way out.** Same component, same
`variant`, same `size`, same cluster — and no prop to change either. Measured:
identical fill, ink, border, font-size, weight and height; the labels read at
5.96:1 light / 8.79:1 dark. The asymmetry the references ship (solid «accept»,
grey text-link «reject») cannot be expressed through this API.
- **The bar does not trap; the panel does.** A notice that corners the reader
coerces the decision. Inside the panel the visitor came to decide, so it traps
— and its Escape emits nothing.
- **The block writes no cookie** (D-BLK.6): no storage, no script blocking. It
hands the decision to the app and stops. A block with a side effect on the
document would stop being a block.
- **The pair is a `Group`, and it had to be.** As three loose children the
actions wrapped at 375 with «reject» on one row and «accept» on the next,
reading as two unrelated buttons at exactly the width where the pairing matters
most. The markup now says they are a pair.
- **The overlay plane, not a `Card`.** The first attempt was
`Card variant="outline"`, whose surface IS the page's: the bar dissolved into
the page (1.00:1) with only a hairline holding the boundary. `data-depth="overlay"`
is the system's own answer and what every floating canon surface stamps.
## Demo
`web/routes/blocks/cookie-consent/` — a plausible page with the notice over it
and, the point of the demo, **the decision emitted, visible**: the three paths
print `onDecision(consent, via)` verbatim. Mini-page in `CookieConsentSite.svelte`.
## Equivalencias
_La matriz del tramo B: cada variante que las referencias shippean, frente a la
composición nuestra que la logra. **Una receta que no se ha visto en el navegador
NO entra en esta tabla**._
| Variante de la referencia | Refs | Receta | Demostrada en | Estado |
| ---------------------------------------- | ------------------------------- | --------------------------------------------------------- | ---------------------- | ------------------------------------------------------------------------------------------------------------------ |
| Barra inferior a todo el ancho | Flowbite · Prime · Untitled | por defecto (`Affix bottom-center stretch`) | por defecto | cubierta |
| Aviso + panel de preferencias | Flowbite · Untitled | `Gestionar preferencias` abre el `Dialog` | por defecto | cubierta |
| Categorías con interruptor | Flowbite · Untitled | `.Switch` por categoría opcional | por defecto | cubierta |
| Enlace a la política | todas | el slot `policy` | por defecto | cubierta |
| Reabrir desde el pie («cookie settings») | Untitled | `bind:open` desde el botón de la app | botón de la mini-página | cubierta |
| Sólo aceptar (sin rechazo visible) | TW · Flowbite | — | — | **no se ofrece por doctrina**: rechazar tiene que ser tan fácil como aceptar, y el tipo no deja escribir lo contrario |
| Categorías premarcadas | Flowbite | — | — | **no se ofrece por doctrina**: `initialConsent()` no admite semilla |
| Modal que bloquea la página | Prime | — | — | **no se ofrece por doctrina**: un aviso que atrapa al lector coacciona la decisión |
| Esquina flotante en vez de banda | Untitled | — | — | gap: `cookie-consent-corner` (es otro `placement` del `Affix`; entra como prop cuando una demo lo pida) |
| Lista de proveedores (IAB TCF) | — | — | — | app-land: es un producto en sí, con su propio registro y su cadena de firmas |
## Gaps
| Gap | Disposition |
| ------------------------------------------------ | ---------------------------------------------------------------------------------------------------------------- |
| **Persistencia** (la cookie, su caducidad) | **app-land** — por diseño (D-BLK.6) |
| **Bloqueo de scripts** hasta el sí | **app-land** — es del cargador de la app |
| **Esquina flotante** | **deferred** — otro `placement`; entra como prop cuando una demo lo pida |
| **`Button` acepta `ref` y no lo reenvía** | **canon** — `bind:ref` tipa bien y no ata nada (forma de A-94); aquí el disparador se enfoca por `id` |
| **`Switch` no tiene parte de etiqueta** | **canon** — la etiqueta visible es hermana y no es diana de clic; se ata por `aria-labelledby` |
| **El `Dialog` restaura el foco a un nodo muerto** | **canon** — cuando el disparador se desmonta, el foco acaba en el `body`; el block lo devuelve él mismo |
## Found while composing
- **Un panel abierto sigue atrapando el foco mientras se cierra.** Devolver el
foco en `onOpenChangeComplete` no basta: el nodo aún está en el DOM y la trampa
lo recupera al instante (trazado dos veces, 1897ms → 1902ms). Va un frame
después, con `dom.raf`.
- **El instrumento mintió tres veces antes que el código.** El pane del navegador
no compone frames, así que la animación de salida no termina nunca y un diálogo
cerrado sigue pintado: el foco medido allí era ficción. Y `fillStyle` no
normaliza oklch, así que todo color leído del estilo computado se volvió negro
y dio 1.11:1 sobre texto perfectamente legible. Las cifras de esta ficha se
miden por PÍXEL.

@ -0,0 +1,81 @@
import { describe, expect, it } from 'vitest';
import {
acceptAll,
COOKIE_CATEGORY_LABEL,
COOKIE_CATEGORY_PURPOSE,
initialConsent,
isSwitchable,
OPTIONAL_CATEGORIES,
rejectAll,
type CookieCategory
} from './consent';
const ALL: CookieCategory[] = ['essential', 'functional', 'analytics', 'marketing'];
/**
* These are not unit tests of a helper — they are the compliance rules, asserted.
* Each one corresponds to a numbered rule in `consent.ts`, and each is a thing a
* refactor could quietly break while everything still renders.
*/
describe('cookie consent model', () => {
it('a first ask has EVERY optional category off (no pre-ticking)', () => {
const consent = initialConsent();
for (const category of OPTIONAL_CATEGORIES) {
expect(consent[category], `${category} must start off`).toBe(false);
}
});
it('essential is on and not switchable', () => {
expect(initialConsent().essential).toBe(true);
expect(isSwitchable('essential')).toBe(false);
expect(OPTIONAL_CATEGORIES).not.toContain('essential');
});
it('reject-all is exactly the first-ask state — same shape, no hidden opt-in', () => {
expect(rejectAll()).toEqual(initialConsent());
});
it('accept-all turns on every optional category and nothing else exists', () => {
const consent = acceptAll();
for (const category of OPTIONAL_CATEGORIES) {
expect(consent[category], `${category} must be on`).toBe(true);
}
expect(Object.keys(consent).sort()).toEqual([...ALL].sort());
});
it('accept-all and reject-all are SYMMETRIC: same keys, opposite optional values', () => {
// The asymmetry the references ship is visual, but it starts here: if the
// two outcomes did not cover the same categories, one of them would be a
// partial decision dressed as a whole one.
const yes = acceptAll();
const no = rejectAll();
expect(Object.keys(yes).sort()).toEqual(Object.keys(no).sort());
for (const category of OPTIONAL_CATEGORIES) {
expect(yes[category]).toBe(!no[category]);
}
});
it('every category has a label AND a purpose — informed consent is stated, not linked', () => {
for (const category of ALL) {
expect(COOKIE_CATEGORY_LABEL[category], `${category} label`).toBeTruthy();
expect(COOKIE_CATEGORY_PURPOSE[category], `${category} purpose`).toBeTruthy();
}
});
it('the two Records cover the union and nothing else', () => {
expect(Object.keys(COOKIE_CATEGORY_LABEL).sort()).toEqual([...ALL].sort());
expect(Object.keys(COOKIE_CATEGORY_PURPOSE).sort()).toEqual([...ALL].sort());
});
it('every word is an idlangref under the block namespace with an English fallback', () => {
for (const ref of [
...Object.values(COOKIE_CATEGORY_LABEL),
...Object.values(COOKIE_CATEGORY_PURPOSE)
]) {
expect(ref.startsWith('#?'), ref).toBe(true);
const [key, fallback] = ref.slice(2).split('|');
expect(key.startsWith('blocks.cookie-consent.'), key).toBe(true);
expect(fallback?.length, `${key} needs a fallback`).toBeGreaterThan(0);
}
});
});

@ -0,0 +1,116 @@
/**
* The consent model — and the place where European law lives in this block.
*
* The reference catalogs ship cookie notices as markup dumps, and every one of
* them is a compliance hazard: «Accept all» as a solid primary and «Reject» as a
* grey text link, categories pre-ticked, closing the banner treated as consent.
* None of that is a styling accident — it is what a dump cannot prevent.
*
* So the rules are not documentation here, they are the TYPE and the machine:
*
* 1. **Rejecting is as easy as accepting.** The block always renders both
* actions, with the same component, the same variant and the same size, and
* exposes NO knob to weaken one of them. (EDPB Guidelines 03/2022 on
* deceptive design; CNIL has fined exactly this asymmetry.)
* 2. **Nothing non-essential is pre-ticked.** `initialConsent()` returns every
* optional category OFF and there is no prop to seed them otherwise on a
* first ask — consent must be an unambiguous affirmative act (GDPR art. 4(11),
* CJEU *Planet49*).
* 3. **Essential is not a choice.** It is typed `true`, so a UI that offers to
* switch it off cannot even be written. Strictly necessary storage needs no
* consent (ePrivacy art. 5(3)), and pretending otherwise is theatre.
* 4. **Closing is not consenting.** Dismissing the bar or pressing Escape in the
* preferences panel produces NO decision — `decide()` is the only door, and
* it is always an explicit press.
* 5. **Withdrawing is as easy as giving.** The app re-opens the panel by passing
* the stored `consent`, which is also the only way a category arrives ticked:
* a previous decision of the visitor's own.
*
* What the block does NOT do, by design (D-BLK.6): it writes no cookie, reads no
* storage and blocks no script. It hands the decision to the app and stops there
* — persistence and enforcement are the app's, and a block that wrote cookies
* would be a block with a side effect on the document.
*/
/** The categories. `essential` is not optional and not switchable. */
export type CookieCategory = 'essential' | 'functional' | 'analytics' | 'marketing';
/** The optional ones — the only categories a visitor actually decides. */
export const OPTIONAL_CATEGORIES = ['functional', 'analytics', 'marketing'] as const;
export type OptionalCategory = (typeof OPTIONAL_CATEGORIES)[number];
/**
* A decision. `essential` is `true` by type: not a default, a constraint — no
* consumer can build a consent object that turns strictly necessary storage off,
* and no UI can offer it.
*/
export interface CookieConsent {
readonly essential: true;
functional: boolean;
analytics: boolean;
marketing: boolean;
}
/** How the visitor decided. Travels with the decision so the app can log it. */
export type CookieDecisionVia = 'accept-all' | 'reject-all' | 'preferences';
/** Where the block is in its conversation with the visitor. */
export type CookieConsentState =
/** Nothing decided yet — the bar is showing. */
| 'unset'
/** The preferences panel is open. */
| 'preferences'
/** A decision was made; nothing is showing. */
| 'decided';
/**
* The starting point of a FIRST ask: every optional category off.
*
* There is deliberately no `initialConsent(seed)` overload. A seeded «on» would
* be a pre-ticked box, which is precisely what the law forbids and what the
* reference dumps do.
*/
export function initialConsent(): CookieConsent {
return { essential: true, functional: false, analytics: false, marketing: false };
}
/** Everything on — what «Accept all» means. */
export function acceptAll(): CookieConsent {
return { essential: true, functional: true, analytics: true, marketing: true };
}
/** Everything optional off — what «Reject all» means, and the same shape. */
export function rejectAll(): CookieConsent {
return initialConsent();
}
/**
* The words. An exhaustive `Record` per category, as idlangrefs with an English
* fallback: the block owns them because they explain ITS states, and an app
* takes them over by registering `blocks.cookie-consent.*` (B-7 as amended).
*/
export const COOKIE_CATEGORY_LABEL: Record<CookieCategory, string> = {
essential: '#?blocks.cookie-consent.category.essential|Strictly necessary',
functional: '#?blocks.cookie-consent.category.functional|Functional',
analytics: '#?blocks.cookie-consent.category.analytics|Analytics',
marketing: '#?blocks.cookie-consent.category.marketing|Marketing'
};
/**
* Why each category exists, in the visitor's terms. Informed consent means the
* purpose is stated BEFORE the choice, not linked away in a policy — so every
* category has a sentence and the `Record` makes a silent one impossible.
*/
export const COOKIE_CATEGORY_PURPOSE: Record<CookieCategory, string> = {
essential: '#?blocks.cookie-consent.purpose.essential|Needed for the site to work. Always on.',
functional:
'#?blocks.cookie-consent.purpose.functional|Remember your preferences, like language or theme.',
analytics: '#?blocks.cookie-consent.purpose.analytics|Help us understand which pages are used.',
marketing: '#?blocks.cookie-consent.purpose.marketing|Used to show you ads on other sites.'
};
/** Whether a category can be switched. Only `essential` cannot. */
export function isSwitchable(category: CookieCategory): category is OptionalCategory {
return category !== 'essential';
}

@ -0,0 +1,305 @@
<script lang="ts">
/**
* CookieConsent — the consent notice: a bar pinned to the bottom edge and a
* preferences panel.
*
* NOT a section. It is an overlay with state, which is why it is the one block
* of the tier whose fase 0 was legal rather than visual: the reference catalogs
* ship this as markup, and markup cannot prevent the three things that make a
* cookie notice unlawful — a weakened reject, pre-ticked categories, and
* closing treated as consent. Here those are the TYPE and this file, and the
* rules they answer to are numbered in `consent.ts`.
*
* What this file guarantees by construction:
*
* - **Both actions, same weight.** «Accept all» and «Reject all» are the SAME
* component with the same variant and size, side by side in the same cluster.
* There is no prop to change either — the asymmetry the references ship
* cannot be expressed through this API.
* - **The bar does not steal focus.** It is not modal: a notice that traps the
* reader is a notice that coerces. The panel does trap, because there the
* visitor came to decide.
* - **Escape closes the panel and decides NOTHING.** Only the three buttons
* call `onDecision`.
* - **It announces.** The decision reaches assistive tech through
* `uix.announce` — the second sanctioned service — because the bar vanishing
* is the only feedback a sighted visitor gets, and that is no feedback at all
* for anyone else.
*
* B contract: canon components only — no `.css`, no morfo. The placement is
* the canon `Affix` (whose own README uses a cookie notice as its example), so
* the block re-implements nothing.
*/
import { Affix } from '$uix/eidos/components/affix';
import { Box } from '$uix/eidos/components/box';
import { Dialog } from '$uix/eidos/components/dialog';
import { Stack } from '$uix/eidos/components/stack';
import { Flex } from '$uix/eidos/components/flex';
import { Group } from '$uix/eidos/components/group';
import { Wrap } from '$uix/eidos/components/wrap';
import { Button } from '$uix/eidos/components/button';
import { Switch } from '$uix/eidos/components/switch';
import { Text } from '$uix/eidos/components/text';
import { Container } from '$uix/eidos/components/container';
import { ActiveEidos } from '$uix/eidos';
import {
acceptAll,
COOKIE_CATEGORY_LABEL,
COOKIE_CATEGORY_PURPOSE,
initialConsent,
OPTIONAL_CATEGORIES,
rejectAll,
type CookieConsent,
type CookieDecisionVia
} from './consent';
import type { CookieConsentProps } from './types';
let {
onDecision,
consent,
open = $bindable(false),
children,
policy,
...rest
}: CookieConsentProps = $props();
const eidos = ActiveEidos.require();
const t = (ref: string) => eidos.langs.t(ref);
const uid = $props.id();
const titleId = `${uid}-title`;
// The panel's trigger, addressed BY ID so the focus can come home. The bar
// unmounts while the panel is open — it lives on the affix plane, ABOVE the
// modal, so leaving it mounted would float it over the very dialog that is
// meant to be modal — and taking the trigger out from under the focus is what
// breaks the return. Measured: closing with Escape used to land the focus on
// the page body.
//
// By id and not `bind:ref` because the eidos `Button` accepts `ref` in its
// type and never forwards it: the binding type-checks and silently binds
// nothing (filed as a gap — a type must not promise a surface it discards).
// `uix.dom.getDocument()` rather than the bare global, so the lookup is right
// inside an iframe or a popup.
const triggerId = `${uid}-preferences`;
// Whether the panel has actually been open. Not reactive UI state — a latch,
// and a necessary one: the panel's presence settles on MOUNT too, so without
// it the restore below fired on page load and the bar took the focus the
// moment it appeared. Which is the one thing this bar must never do.
let panelWasOpen = false;
// A stored decision means the visitor already chose: the bar does not come
// back. Reopening happens through `open`, which is how withdrawing stays as
// easy as giving.
const decided = $derived(consent !== undefined);
// The panel's working copy. On a FIRST ask it starts from `initialConsent()`
// — everything optional off — and never from a seed. Reopening starts from
// what the visitor themselves chose.
let draft = $state<CookieConsent>(consent ? { ...consent } : initialConsent());
$effect(() => {
if (consent) draft = { ...consent };
});
function decide(next: CookieConsent, via: CookieDecisionVia) {
open = false;
onDecision(next, via);
// The bar disappearing IS the feedback for someone who can see it. For
// anyone else there would be none, so the decision is spoken.
eidos.uix?.announce(
t('#?blocks.cookie-consent.announce.saved|Your cookie choices were saved'),
'polite'
);
}
</script>
{#if !decided && !open}
<!-- `Affix` positions and nothing else; `stretch` spans the viewport edge and
the `Container` inside gives the content the page's own measure. -->
<Affix placement="bottom-center" stretch>
<!--
`role="region"` + a name: the notice must be findable by someone who
tabbed past it, and a nameless region is not exposed at all. NOT
`role="dialog"` — that would imply modality the bar deliberately does not
have.
`data-depth="overlay"` is the SYSTEM's overlay plane — surface, border,
shadow and halo in one channel, the same one every menu, popover and
combobox content stamps on its own element. It is the honest plane here:
this bar floats over content that scrolls behind it. A page-level `Card`
was the first attempt and measured wrong — an outline card is the page's
own surface, so the bar dissolved into the page (`barVsPage` 1.00:1) and
had nothing but a hairline to hold the boundary.
-->
<section role="region" aria-labelledby={titleId} data-depth="overlay" {...rest}>
<Box paddingY={4}>
<Container size="lg">
<Flex direction={{ base: 'column', md: 'row' }} gap={5} align={{ md: 'center' }}>
<Stack gap={2} align="start" flex="1">
<Text id={titleId} size="sm" weight="medium">
{t('#?blocks.cookie-consent.title|Cookies on this site')}
</Text>
{#if children}
<Text size="sm" color="secondary">{@render children()}</Text>
{/if}
</Stack>
<!--
The actions at one weight. «Accept all» and «Reject all» are the
same `Button` with the same `variant` and the same `size` — the API
exposes no way to make one louder, which is the whole point: a dump
can be tweaked, a constructor cannot.
They live in their OWN `Group` because they are a pair, and the
markup has to say so: as three loose children they wrapped at 375
with «Reject» on one row and «Accept» on the next, which reads as
two unrelated buttons at exactly the width where the pairing
matters most. Measured, not guessed.
-->
<Flex
direction={{ base: 'column', sm: 'row' }}
gap={3}
align={{ base: 'stretch', sm: 'center' }}
justify="end"
>
<Button id={triggerId} variant="ghost" size="sm" onclick={() => (open = true)}>
{t('#?blocks.cookie-consent.action.preferences|Manage preferences')}
</Button>
<Group gap={3} align="center" justify="end">
<Button
variant="surface"
size="sm"
onclick={() => decide(rejectAll(), 'reject-all')}
>
{t('#?blocks.cookie-consent.action.rejectAll|Reject all')}
</Button>
<Button
variant="surface"
size="sm"
onclick={() => decide(acceptAll(), 'accept-all')}
>
{t('#?blocks.cookie-consent.action.acceptAll|Accept all')}
</Button>
</Group>
</Flex>
</Flex>
</Container>
</Box>
</section>
</Affix>
{/if}
<!--
The panel IS modal, unlike the bar: here the visitor came to decide, so
trapping the focus is serving them rather than cornering them. Escape closes it
and decides nothing — `decide()` is only reachable from a button.
-->
<Dialog
bind:open
onOpenChangeComplete={(settled) => {
// The sanctioned hook: it fires once the exit animation has SETTLED, which
// is when the bar has remounted and its trigger exists again. After a
// decision the bar is gone for good and this is a no-op on null.
if (settled) {
panelWasOpen = true;
return;
}
if (!panelWasOpen) return;
panelWasOpen = false;
const dom = eidos.uix?.dom;
// One frame LATER, not now: at completion the panel is still in the DOM and
// its focus trap is still armed, so a focus moved outside gets yanked
// straight back in — traced, twice. `dom.raf` is the sanctioned frame, and
// it targets the right window inside an iframe or a popup.
dom?.raf(() => dom.getDocument().getElementById(triggerId)?.focus());
}}
>
<Dialog.Content
size="md"
onCloseAutoFocus={(e) => {
// The panel's own restore aims at the element that had the focus when the
// panel opened — the bar's trigger, which by then no longer exists — so
// left alone it lands on the body. The block restores it itself in
// `onOpenChangeComplete`, one frame after the trap disarms.
e.preventDefault();
}}
>
<Dialog.Header>
<Dialog.Title>
{t('#?blocks.cookie-consent.panel.title|Cookie preferences')}
</Dialog.Title>
<Dialog.Description>
{t(
'#?blocks.cookie-consent.panel.description|Choose what you allow. You can change this at any time.'
)}
</Dialog.Description>
</Dialog.Header>
<Stack gap={5} align="stretch">
<!-- Essential first, and NOT as a switch: it is typed `true`, so there is
nothing to offer. Showing it as a disabled toggle would suggest a
choice that does not exist. -->
<Stack gap={1} align="start">
<Group gap={2} align="center">
<Text size="sm" weight="medium">{t(COOKIE_CATEGORY_LABEL.essential)}</Text>
<Text size="xs" color="muted">
{t('#?blocks.cookie-consent.alwaysOn|Always on')}
</Text>
</Group>
<Text size="sm" color="muted">{t(COOKIE_CATEGORY_PURPOSE.essential)}</Text>
</Stack>
{#each OPTIONAL_CATEGORIES as category (category)}
<Stack gap={1} align="start">
<!--
`Switch` takes the THUMB as its child, never a label — the morfo
declares two parts and neither is one. So the visible label is a
sibling bound by `aria-labelledby`, which is also the better name:
the accessible name IS the text on screen instead of a second copy
of it in an `aria-label`.
And `aria-describedby` carries the PURPOSE into the control, so the
reason to allow a category is announced with the choice rather than
sitting next to it unread. Informed consent for someone who cannot
see the sentence beside the toggle.
-->
<Group gap={3} align="center">
<Switch
bind:checked={draft[category]}
size="sm"
aria-labelledby={`${uid}-${category}`}
aria-describedby={`${uid}-${category}-why`}
>
<Switch.Thumb />
</Switch>
<Text id={`${uid}-${category}`} size="sm" weight="medium">
{t(COOKIE_CATEGORY_LABEL[category])}
</Text>
</Group>
<Text id={`${uid}-${category}-why`} size="sm" color="muted">
{t(COOKIE_CATEGORY_PURPOSE[category])}
</Text>
</Stack>
{/each}
{#if policy}
<Text size="sm" color="muted">{@render policy()}</Text>
{/if}
</Stack>
<Dialog.Footer>
<Wrap gap={3} align="center" justify="end">
<Button variant="surface" size="sm" onclick={() => decide(rejectAll(), 'reject-all')}>
{t('#?blocks.cookie-consent.action.rejectAll|Reject all')}
</Button>
<Button variant="surface" size="sm" onclick={() => decide(acceptAll(), 'accept-all')}>
{t('#?blocks.cookie-consent.action.acceptAll|Accept all')}
</Button>
<Button variant="solid" size="sm" onclick={() => decide({ ...draft }, 'preferences')}>
{t('#?blocks.cookie-consent.action.save|Save choices')}
</Button>
</Wrap>
</Dialog.Footer>
</Dialog.Content>
</Dialog>

@ -0,0 +1,42 @@
// CookieConsent — the consent notice: a bar pinned to the bottom edge and a
// preferences panel.
//
// import { CookieConsent } from '$blocks/cookie-consent';
//
// <CookieConsent
// consent={stored}
// bind:open={settingsOpen}
// onDecision={(consent, via) => persist(consent, via)}
// >
// Usamos cookies para que el sitio funcione y, con tu permiso, para medir su uso.
// {#snippet policy()}<Link href="/cookies">Política de cookies</Link>{/snippet}
// </CookieConsent>
//
// The block writes NO cookie and blocks NO script (D-BLK.6): it hands the
// decision to the app and stops there. Where European law lives — both actions at
// one weight, nothing pre-ticked, essential not switchable, closing ≠ consenting
// — is `consent.ts`, asserted by `consent.test.ts`.
import CookieConsentComponent from './cookie-consent.svelte';
export { CookieConsentComponent as CookieConsent };
export default CookieConsentComponent;
export {
acceptAll,
initialConsent,
isSwitchable,
rejectAll,
COOKIE_CATEGORY_LABEL,
COOKIE_CATEGORY_PURPOSE,
OPTIONAL_CATEGORIES
} from './consent';
export type {
CookieCategory,
CookieConsent as CookieConsentValue,
CookieConsentState,
CookieDecisionVia,
OptionalCategory
} from './consent';
export type { CookieConsentProps } from './types';

@ -0,0 +1,47 @@
import type { Snippet } from 'svelte';
import type { HTMLAttributes } from 'svelte/elements';
import type { CookieConsent, CookieDecisionVia } from './consent';
export type { CookieConsent, CookieDecisionVia };
export type CookieConsentProps = Omit<HTMLAttributes<HTMLElement>, 'children'> & {
/**
* The decision. **Required** — a consent notice that does not report back is a
* decoration, and the app is the only one who can act on it.
*
* It fires ONLY on an explicit press: accepting all, rejecting all, or saving
* from the preferences panel. Dismissing the bar or pressing Escape produces
* nothing, because closing is not consenting.
*
* The block writes no cookie and blocks no script (D-BLK.6): persistence and
* enforcement are the app's.
*/
onDecision: (consent: CookieConsent, via: CookieDecisionVia) => void;
/**
* A decision already stored by the app. Passing it means the visitor has
* decided, so nothing shows — and it is **the only way a category arrives
* ticked**: a previous choice of their own, never a seeded default.
*
* Pair it with `open` to reopen the panel, which is how withdrawing consent
* stays as easy as giving it.
*/
consent?: CookieConsent;
/**
* Force the preferences panel open — the «cookie settings» link every footer
* needs. Bindable, so the app can close it back.
* @default false
*/
open?: boolean;
/**
* The notice's own words: what is stored and why, plus the app's link to its
* policy. Required in practice — informed consent means the visitor reads
* something before choosing — but a snippet, because the words are the app's
* (B-7) and the law they answer to is the app's jurisdiction too.
*/
children?: Snippet;
/**
* Extra content under the panel's categories — usually the link to the full
* cookie policy.
*/
policy?: Snippet;
};

@ -45,6 +45,53 @@ export const blocksLangs = {
sent: { es: 'Suscrito', en: 'Subscribed' }
}
},
'cookie-consent': {
title: { es: 'Cookies en este sitio', en: 'Cookies on this site' },
alwaysOn: { es: 'Siempre activas', en: 'Always on' },
category: {
essential: { es: 'Estrictamente necesarias', en: 'Strictly necessary' },
functional: { es: 'Funcionales', en: 'Functional' },
analytics: { es: 'Analíticas', en: 'Analytics' },
marketing: { es: 'Publicitarias', en: 'Marketing' }
},
purpose: {
essential: {
es: 'Necesarias para que el sitio funcione. Siempre activas.',
en: 'Needed for the site to work. Always on.'
},
functional: {
es: 'Recuerdan tus preferencias, como el idioma o el tema.',
en: 'Remember your preferences, like language or theme.'
},
analytics: {
es: 'Nos ayudan a saber qué páginas se usan.',
en: 'Help us understand which pages are used.'
},
marketing: {
es: 'Se usan para mostrarte anuncios en otros sitios.',
en: 'Used to show you ads on other sites.'
}
},
panel: {
title: { es: 'Preferencias de cookies', en: 'Cookie preferences' },
description: {
es: 'Elige qué permites. Puedes cambiarlo cuando quieras.',
en: 'Choose what you allow. You can change this at any time.'
}
},
action: {
preferences: { es: 'Gestionar preferencias', en: 'Manage preferences' },
rejectAll: { es: 'Rechazar todo', en: 'Reject all' },
acceptAll: { es: 'Aceptar todo', en: 'Accept all' },
save: { es: 'Guardar elección', en: 'Save choices' }
},
announce: {
saved: {
es: 'Se ha guardado tu elección sobre las cookies',
en: 'Your cookie choices were saved'
}
}
},
contact: {
field: {
name: { es: 'Nombre', en: 'Name' },

@ -35,7 +35,8 @@ export const BLOCK_CATALOG: BlockGroup[] = [
{ slug: 'banner', label: 'Banner', shipped: true },
{ slug: 'team', label: 'Team', shipped: true },
{ slug: 'contact', label: 'Contact', shipped: true },
{ slug: 'content-section', label: 'Content section', shipped: true }
{ slug: 'content-section', label: 'Content section', shipped: true },
{ slug: 'cookie-consent', label: 'Cookie consent', shipped: true }
]
},
{

@ -0,0 +1,174 @@
<script lang="ts">
/**
* CookieConsent demo. Full-bleed on the page; device widths from `./preview`.
*
* The bar is `position: fixed`, so in the inline preview it anchors to THIS
* document's viewport — which is honest but not comparable. The framed preview
* is where its real placement is judged.
*/
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 CookieConsentSite from './CookieConsentSite.svelte';
import DocRow from '../_lib/DocRow.svelte';
let showLog = $state(true);
const previewSrc = $derived(`/blocks/cookie-consent/preview?log=${showLog}`);
</script>
<BlockDemo
name="Cookie consent"
slug="$blocks/cookie-consent"
{previewSrc}
previewHeight="720px"
meta={[
{ key: 'compone', value: 'Affix · Card · Dialog · Switch · Button · Container' },
{ key: 'landmark', value: 'region (la barra) + dialog (el panel)' },
{ key: 'tipo', value: 'simple (overlay con estado)' },
{ key: 'strings propias', value: '12 idlangrefs (explican SUS estados)' }
]}
>
{#snippet preview()}
<CookieConsentSite {showLog} />
{/snippet}
{#snippet controls()}
<Wrap gap={5}>
<Group gap={2} align="center" justify="start">
<Text size="sm" color="muted">lo que recibe la app</Text>
<ToggleGroup
selectionMode="single"
size="sm"
attached
value={[showLog ? 'sí' : 'no']}
onValueChange={(v) => (showLog = (v[0] ?? 'sí') === 'sí')}
aria-label="panel de la decisión emitida"
>
<ToggleGroup.Item value="sí">sí</ToggleGroup.Item>
<ToggleGroup.Item value="no">no</ToggleGroup.Item>
</ToggleGroup>
</Group>
</Wrap>
{/snippet}
{#snippet lede()}
El aviso de consentimiento: una barra posada en el borde inferior y un panel de preferencias. El
único block del tier cuya fase 0 fue <strong>jurídica</strong> antes que visual — las referencias
lo shippean como markup, y el markup no puede impedir las tres cosas que vuelven ilegal un aviso de
cookies: un rechazo debilitado, categorías premarcadas, y cerrar tratado como consentir. Aquí eso
es el TIPO y el constructor.
{/snippet}
{#snippet composition()}
<Stack gap={4}>
<Text color="muted">
El block coloca y pregunta; la persistencia y el bloqueo de scripts son de la app.
</Text>
<Stack gap={3}>
<DocRow term="Las dos acciones, al mismo peso">
«Aceptar todo» y «Rechazar todo» son el MISMO <Code>Button</Code> con el mismo
<Code>variant</Code> y el mismo <Code>size</Code>, en el mismo grupo. No hay prop para
debilitar ninguna: la asimetría que shippean las referencias no se puede ni expresar por
esta API. (Directrices EDPB 03/2022 sobre patrones engañosos; la CNIL ha multado
exactamente esa asimetría.)
</DocRow>
<DocRow term="Nada premarcado">
<Code>initialConsent()</Code> devuelve toda categoría opcional APAGADA y no existe prop para
sembrarlas. La única forma de que una llegue marcada es <Code>consent</Code>: una decisión
previa del propio visitante.
</DocRow>
<DocRow term="Lo esencial no es una elección">
Está tipado <Code>essential: true</Code>, así que una UI que ofrezca apagarlo no se puede
ni escribir. Se muestra como texto, no como interruptor deshabilitado: fingir la elección
es teatro.
</DocRow>
<DocRow term="Cerrar no es consentir">
La barra <strong>no</strong> es modal y no roba el foco — un aviso que atrapa al lector es
un aviso que coacciona. El panel sí atrapa, porque ahí el visitante vino a decidir, y su
Escape cierra sin emitir nada: <Code>onDecision</Code> sólo sale de una pulsación.
</DocRow>
<DocRow term="El block no escribe cookies">
Ni lee almacenamiento ni bloquea scripts (D-BLK.6). Entrega la decisión y para: persistir
y hacerla cumplir son de la app, y un block con efecto sobre el documento dejaría de ser
un block.
</DocRow>
</Stack>
</Stack>
{/snippet}
{#snippet api()}
<Stack gap={3}>
<DocRow term="CookieConsent">
<Code>onDecision</Code> <strong>obligatorio</strong> — un aviso que no informa de vuelta es
una decoración · <Code>consent</Code> (la decisión guardada; pasarla oculta la barra) ·
<Code>bind:open</Code> (reabrir el panel: el enlace «preferencias de cookies» del pie).
</DocRow>
<DocRow term="Slots">
<Code>children</Code> son las palabras del aviso (de la app: la ley que las rige es de su jurisdicción)
· <Code>policy</Code> va bajo las categorías del panel.
</DocRow>
<DocRow term="El modelo, exportado">
<Code>initialConsent()</Code> · <Code>acceptAll()</Code> · <Code>rejectAll()</Code> ·
<Code>isSwitchable()</Code> · <Code>OPTIONAL_CATEGORIES</Code> ·
<Code>COOKIE_CATEGORY_LABEL</Code> / <Code>COOKIE_CATEGORY_PURPOSE</Code>. La app los usa
para su propio pie de preferencias sin re-derivar las reglas.
</DocRow>
</Stack>
{/snippet}
{#snippet a11y()}
<Stack gap={3}>
<DocRow term="La barra es una región nombrada">
<Code>role="region"</Code> + <Code>aria-labelledby</Code> a su propio título: sin nombre, una
región no se expone siquiera. NO es <Code>role="dialog"</Code> — implicaría una modalidad que
deliberadamente no tiene.
</DocRow>
<DocRow term="Se anuncia">
La barra desapareciendo ES el feedback para quien la ve; para quien no, no habría ninguno.
La decisión se dice por <Code>uix.announce</Code>, el anunciador del sistema, sin región
viva propia.
</DocRow>
<DocRow term="El propósito, antes de la elección">
Cada categoría lleva su frase junto a su interruptor, no enlazada aparte: consentimiento
informado es lo que se lee, no lo que es alcanzable. Los dos <Code>Record</Code> exhaustivos hacen
imposible una categoría muda.
</DocRow>
</Stack>
{/snippet}
{#snippet gaps()}
<Stack gap={3}>
<DocRow term="Persistencia">
<strong>App-land</strong> — por diseño. La cookie, su caducidad y su dominio son decisiones del
producto.
</DocRow>
<DocRow term="Bloqueo de scripts">
<strong>App-land</strong> — que analítica no arranque hasta el sí es del cargador de la app; el
block sólo dice qué se decidió.
</DocRow>
<DocRow term="Vendedores uno a uno (TCF)">
<strong>No se ofrece</strong> — la lista de proveedores del IAB es un producto en sí, con su propio
registro y su propia cadena de firmas. Cuatro categorías es lo que un sitio normal necesita.
</DocRow>
</Stack>
{/snippet}
{#snippet notes()}
<Stack gap={3}>
<DocRow term="Por qué las reglas son tests">
<Code>consent.test.ts</Code> afirma las cinco reglas numeradas: sin premarcado, esencial no conmutable,
rechazar-todo idéntico al primer estado, aceptar y rechazar simétricos, y toda categoría con etiqueta
Y propósito. Son cosas que un refactor puede romper en silencio mientras todo sigue pintando bien.
</DocRow>
<DocRow term="La colocación es del canon">
El README del propio <Code>Affix</Code> usa un aviso de cookies como su ejemplo. El block no reimplementa
nada: <Code>placement="bottom-center"</Code> + <Code>stretch</Code>.
</DocRow>
</Stack>
{/snippet}
</BlockDemo>

@ -0,0 +1,125 @@
<script lang="ts">
/**
* The cookie-consent mini-page: a plausible page underneath, the notice over
* it, and — the point of this demo — the DECISION EMITTED, visible.
*
* Every reference ships this block as a bar you can look at. What you cannot
* look at is what it hands back, which is the only part an app consumes: hence
* the panel on the right, printing `onDecision(consent, via)` verbatim after
* each of the three paths.
*
* The persistence is the APP's here too, and deliberately fake: the page keeps
* the decision in a `$state` so the demo can be reset. A real app writes its
* cookie — the block writes none (D-BLK.6).
*/
import {
CookieConsent,
type CookieConsentValue,
type CookieDecisionVia
} from '$blocks/cookie-consent';
import { Section } from '$uix/eidos/components/section';
import { Container } from '$uix/eidos/components/container';
import { Stack } from '$uix/eidos/components/stack';
import { Group } from '$uix/eidos/components/group';
import { Heading } from '$uix/eidos/components/heading';
import { Text } from '$uix/eidos/components/text';
import { Link } from '$uix/eidos/components/link';
import { Button } from '$uix/eidos/components/button';
import { Card } from '$uix/eidos/components/card';
import { Badge } from '$uix/eidos/components/badge';
import { Code } from '$uix/eidos/components/code';
let { showLog = true }: { showLog?: boolean } = $props();
// The app's storage, faked: a real one persists this. The block reads none.
let stored = $state<CookieConsentValue | undefined>(undefined);
let via = $state<CookieDecisionVia | undefined>(undefined);
let settingsOpen = $state(false);
function reset() {
stored = undefined;
via = undefined;
settingsOpen = false;
}
const categories = ['functional', 'analytics', 'marketing'] as const;
</script>
<Section size="xl">
<Container size="lg">
<Stack gap={8} align="start">
<Stack gap={3} align="start">
<Heading level={1} size="xxxl">Panel de analítica</Heading>
<Text color="muted" size="lg">
Una página cualquiera. El aviso de cookies se posa sobre ella sin robarle el foco: se
puede leer esto, tabular por sus enlaces y decidir después. Lo que no se puede es que
cerrarlo cuente como un sí.
</Text>
</Stack>
<Group gap={4} align="center">
<Button variant="solid" onclick={() => (settingsOpen = true)}>
Preferencias de cookies
</Button>
<Button variant="ghost" onclick={reset}>Empezar de cero</Button>
</Group>
{#if showLog}
<Card variant="outline" size="md" width="100%" maxWidth="34rem">
<Stack gap={4} align="start">
<Group gap={3} align="center">
<Heading level={2} size="sm">Lo que recibe la app</Heading>
{#if via}
<Badge color="teal" variant="soft" size="sm">{via}</Badge>
{:else}
<Badge color="neutral" variant="soft" size="sm">sin decidir</Badge>
{/if}
</Group>
{#if stored}
<Stack gap={2} align="stretch" width="100%">
<Group gap={3} justify="space-between">
<Text size="sm" color="muted"><Code>essential</Code></Text>
<Text size="sm">siempre</Text>
</Group>
{#each categories as category (category)}
<Group gap={3} justify="space-between">
<Text size="sm" color="muted"><Code>{category}</Code></Text>
<Text size="sm" color={stored[category] ? 'success' : 'muted'}>
{stored[category] ? 'sí' : 'no'}
</Text>
</Group>
{/each}
</Stack>
{:else}
<Text size="sm" color="muted">
Nada todavía. Cerrar el aviso o pulsar Escape en el panel no emite decisión: es la
regla 4 y aquí se puede comprobar.
</Text>
{/if}
</Stack>
</Card>
{/if}
<Text size="sm" color="muted">
Con una decisión guardada, el aviso no vuelve — se reabre por <Code>open</Code>, que es cómo
retirar el consentimiento sigue siendo tan fácil como darlo.
</Text>
</Stack>
</Container>
</Section>
<CookieConsent
consent={stored}
bind:open={settingsOpen}
onDecision={(consent, decidedVia) => {
stored = consent;
via = decidedVia;
}}
>
Usamos cookies necesarias para que el sitio funcione y, con tu permiso, otras para recordar tus
preferencias y medir el uso.
{#snippet policy()}
<Link href="#politica">Leer la política de cookies</Link>
{/snippet}
</CookieConsent>

@ -0,0 +1,31 @@
<script lang="ts">
/**
* The preview is its OWN page — `@` resets the layout, so the section's topbar
* and rail are not here. The axes come from 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,17 @@
<script lang="ts">
/**
* Standalone page for the cookie-consent mini-site — same component the demo
* renders inline, served as its OWN document for the device-width frame.
*
* It matters more here than for a section: the bar is `position: fixed`, so it
* anchors to the viewport of ITS document. In the frame that is the device
* width, which is exactly what needs looking at.
*/
import { page } from '$app/state';
import CookieConsentSite from '../CookieConsentSite.svelte';
const params = $derived(page.url.searchParams);
const showLog = $derived(params.get('log') !== 'false');
</script>
<CookieConsentSite {showLog} />
Loading…
Cancel
Save

Powered by TurnKey Linux.