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
parent
313e4d52fa
commit
dc7c4920d6
@ -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;
|
||||
};
|
||||
@ -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…
Reference in new issue