This is the whole build of the fortune-cookie consent, in the order it was written. The page belongs to a made-up pâtisserie, ORVELLE, and its cookie banner is a plate of four fortune cookies, one per kind of cookie: Essential, Analytics, Marketing and Personalisation. You crack one, read its fortune, and decide on the paper slip that comes out of it. Every choice changes the page behind the panel at once, and switching a category off deletes what it stored. Each stage below is the page as it stood at that step. It shows a still, and Run it here starts the live page in its place. The code under each heading is only what that stage adds. The finished source is at the end, and on GitHub under the MIT licence.
There is no framework and no build step. consent.js is the store, fortune.js the panel,
site.js the page, plate3d.js and bake3d.js the 3D plate (three.js r186, shipped
in vendor/), and three small feature modules sit in features/. They are ES modules loaded by
one <script type="module">. Two fonts come from Google Fonts: Cormorant for the headings and the
fortunes, Montserrat for everything else.
1 · The page and its basket
Start with the shop, before any consent: a hero, a counter of eight pastries, the story of the fortune cookie, the address and a footer. The page is light by design, and it says so, so a phone in dark mode does not repaint it.
<meta name="color-scheme" content="only light">
<meta name="theme-color" content="#F7F2EA">
:root {
--bg: #F7F2EA; --paper: #FCF9F3; --card: #FFFDF8; --ink: #1C1917; --ink-2: #44403C; --muted: #6B625A;
--line: #E4DACD; --line-2: #D6C9B8; --gold: #A16207; --gold-2: #B8862E; --cocoa: #3B2A20; --red: #A8322A;
--blush: #EFDCD2; --sage: #E1E5D5; --butter: #F3E6C6;
--serif: 'Cormorant', 'Cormorant Garamond', Georgia, serif; --sans: 'Montserrat', system-ui, -apple-system, 'Segoe UI', sans-serif;
--r: 18px; --wrap: 1240px; --gut: clamp(16px, 4vw, 40px);
--ease: cubic-bezier(.2, .7, .2, 1);
color-scheme: only light;
}
A product is plain HTML: it works without JavaScript. The tags and the base popularity on each card are for stages 5 and 6, which read them from the markup.
<article class="card" data-id="croissant" data-tags="buttery" data-heat="92" data-i="0">
<div class="card-media"><img src="img/croissant.jpg" alt="A golden butter croissant" width="960" height="1200" loading="lazy" decoding="async"><div class="card-badges" data-badges></div></div>
<div class="card-body"><h3>Butter croissant</h3><p>Seventy-two layers of French butter and a shatter you can hear.</p>
<div class="card-foot"><span class="price">€3.40</span><button class="add" type="button" data-add>Add<span class="vh"> Butter croissant to basket</span></button></div></div>
</article>
.card { position: relative; display: flex; flex-direction: column; background: var(--card); border: 1px solid var(--line); border-radius: var(--r); overflow: hidden; }
The basket is the one thing on this page a visitor cannot refuse: it is what the shop is for. It lives in
localStorage under dv50b.basket, every access wrapped so a blocked storage never throws.
let basket = safe(() => JSON.parse(localStorage.getItem(BASKET)) || [], []);
if (!Array.isArray(basket)) basket = [];
const paintBasket = () => { basketN.textContent = basket.length; basketBtn.setAttribute('aria-label', `Basket, ${basket.length} item${basket.length === 1 ? '' : 's'}`); };
grid.addEventListener('click', (e) => {
const b = e.target.closest('[data-add]'); if (!b) return;
const id = b.closest('.card').dataset.id;
basket.push(id); safe(() => localStorage.setItem(BASKET, JSON.stringify(basket)));
paintBasket(); basketBtn.classList.remove('bump'); void basketBtn.offsetWidth; basketBtn.classList.add('bump');
b.classList.add('added'); b.firstChild.textContent = 'Added'; setTimeout(() => { b.classList.remove('added'); b.firstChild.textContent = 'Add'; }, 1400);
});
import { createSite } from './site.js';
createSite();

2 · A consent store and a first panel
Consent is a small store with no UI of its own. It answers what is allowed, saves a choice with how it was made
(accept-all, switch, save, dismissed…), tells everyone about it, and
asks again when the policy changes or the choice is older than about six months.
// consent.get() → { essential: true, analytics, marketing, personalisation, decided, method, ts, version }
// consent.allowed('analytics') → boolean
// consent.set({ analytics: true }, method?) saves a choice ('api' by default)
// consent.acceptAll() · consent.rejectAll() · consent.reset()
// consent.open() · consent.close() (wired by the panel)
// consent.store('analytics', 'dv50b.analytics') declares what a category stores; revoking it deletes those keys
// consent.on(fn) → unsubscribe; fn(detail) on every change
// window event 'consent:change' (detail = state + { changes: [{cat, on}], deleted: [keys] }), 'consent:ready' once
export const KEY = 'dv50b.consent';
export const POLICY = 2; // v2: the fortune-cookie build. A different version asks again.
const MAX_AGE = 182 * 24 * 3600 * 1000; // ask again after about six months
export const CATEGORIES = ['essential', 'analytics', 'marketing', 'personalisation'];
export const OPTIONAL = CATEGORIES.slice(1);
const read = () => {
const r = safe(() => JSON.parse(localStorage.getItem(KEY)), null);
if (!r || r.v !== POLICY || typeof r.categories !== 'object' || !r.ts || Date.now() - r.ts > MAX_AGE) return null;
return r;
};
function save(choice, method) {
const before = get();
const categories = { essential: true };
for (const k of OPTIONAL) categories[k] = !!choice[k];
rec = { v: POLICY, ts: Date.now(), method, categories };
const stored = safe(() => { localStorage.setItem(KEY, JSON.stringify(rec)); return true; }, false);
const changes = OPTIONAL.filter((k) => before[k] !== categories[k]).map((cat) => ({ cat, on: categories[cat] }));
const deleted = changes.filter((c) => !c.on).flatMap((c) => wipe(c.cat));
emit({ changes, deleted, stored });
return stored;
}
function emit(extra = {}) {
const detail = { ...get(), changes: [], deleted: [], ...extra };
for (const fn of listeners) safe(() => fn(detail));
window.dispatchEvent(new CustomEvent('consent:change', { detail }));
// gated scripts run a frame later, so the page's own reaction and the scripts it unlocks don't share one frame
requestAnimationFrame(() => setTimeout(runGates, 0));
}
The panel is a non-modal dialog: the page stays usable behind it. Each cookie is a real <button>
with its name and a status under it, and the four sit in a four-column grid over the plate. Until stage 4 the plate is
two baked sprites per cookie, whole and cracked: studio renders made in code, so the art matches the 3D plate that
replaces them.
<section id="fc" class="fc" role="dialog" aria-labelledby="fc-title" aria-describedby="fc-desc" data-state="closed" aria-hidden="true" inert>
<header class="fc-head">
<p class="fc-eyebrow"><svg aria-hidden="true" viewBox="0 0 24 24"><path d="M3 14c2-6 7-9 9-9s7 3 9 9c-3-1-6 0-9 3-3-3-6-4-9-3z"/></svg>Cookie preferences</p>
<h2 id="fc-title">Crack one open.</h2>
<p id="fc-desc">Four fortune cookies, one for each kind of cookie we use. Tap one to open it.<span class="more"> Nothing optional runs until you say yes.</span></p>
<button class="fc-x" type="button" data-act="close" aria-label="Close cookie preferences"><svg viewBox="0 0 24 24" aria-hidden="true"><path d="M6 6l12 12M18 6L6 18"/></svg></button>
</header>
<div class="fc-stage" data-stage>
<div class="fc-plate" aria-hidden="true"></div>
<ul class="fc-cookies">
<li><button class="fc-cookie" type="button" id="fc-c-essential" data-cat="essential" aria-describedby="fc-s-essential">
<span class="fc-sprite" aria-hidden="true"><img src="img/cookie-1-whole.png" alt="" width="260" height="180" data-whole><img src="img/cookie-1-open.png" alt="" width="260" height="180" data-open></span>
<span class="fc-name">Essential</span><span class="fc-status" id="fc-s-essential">Always on</span></button></li>
<li><button class="fc-cookie" type="button" id="fc-c-analytics" data-cat="analytics" aria-describedby="fc-s-analytics">
<span class="fc-sprite" aria-hidden="true"><img src="img/cookie-2-whole.png" alt="" width="260" height="180" data-whole><img src="img/cookie-2-open.png" alt="" width="260" height="180" data-open></span>
<span class="fc-name">Analytics</span><span class="fc-status" id="fc-s-analytics">Unopened</span></button></li>
<li><button class="fc-cookie" type="button" id="fc-c-marketing" data-cat="marketing" aria-describedby="fc-s-marketing">
<span class="fc-sprite" aria-hidden="true"><img src="img/cookie-3-whole.png" alt="" width="260" height="180" data-whole><img src="img/cookie-3-open.png" alt="" width="260" height="180" data-open></span>
<span class="fc-name">Marketing</span><span class="fc-status" id="fc-s-marketing">Unopened</span></button></li>
<li><button class="fc-cookie" type="button" id="fc-c-personalisation" data-cat="personalisation" aria-describedby="fc-s-personalisation">
<span class="fc-sprite" aria-hidden="true"><img src="img/cookie-4-whole.png" alt="" width="260" height="180" data-whole><img src="img/cookie-4-open.png" alt="" width="260" height="180" data-open></span>
<span class="fc-name"><span class="full">Personalisation</span><span class="short" aria-hidden="true">Personal</span></span><span class="fc-status" id="fc-s-personalisation">Unopened</span></button></li>
</ul>
</div>
<div class="fc-actions">
<button class="fc-btn" type="button" data-act="reject">Reject non-essential</button>
<button class="fc-btn" type="button" data-act="accept">Accept all</button>
</div>
<p class="fc-policy">Read the full <a href="#policy">cookie policy</a>.</p>
</section>
<li><button class="fc-cookie" type="button" id="fc-c-analytics" data-cat="analytics" aria-describedby="fc-s-analytics">
<span class="fc-sprite" aria-hidden="true"><img src="img/cookie-2-whole.png" alt="" width="260" height="180" data-whole><img src="img/cookie-2-open.png" alt="" width="260" height="180" data-open></span>
<span class="fc-name">Analytics</span><span class="fc-status" id="fc-s-analytics">Unopened</span></button></li>
.fc { --w: 452px; --stage: 184px; --pad: 22px;
position: fixed; right: 24px; bottom: 24px; z-index: 50; width: var(--w); max-height: calc(100dvh - 48px); overflow: auto; overscroll-behavior: contain;
display: none; flex-direction: column; gap: 0; padding: var(--pad) var(--pad) 16px;
background: var(--paper); color: var(--ink); border: 1px solid var(--line); border-radius: 24px;
box-shadow: 0 30px 80px -20px rgba(59, 42, 32, .35), 0 8px 24px rgba(59, 42, 32, .1);
scrollbar-width: thin; }
.js .fc { display: flex; }
.fc[data-state="closed"] { opacity: 0; pointer-events: none; }
.fc-cookies { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); list-style: none; margin: 0; padding: 0 6px; }
/* static sprites: the plate itself until stage 4 draws it in 3D (the cookie art is the same studio render) */
.fc-sprite { display: none; position: absolute; left: 50%; top: 14%; width: 118%; max-width: 150px; aspect-ratio: 26 / 18; transform: translateX(-50%); pointer-events: none; }
.fc-sprite img { position: absolute; inset: 0; width: 100%; height: 100%; transition: opacity .45s var(--ease); }
.fc-sprite [data-open] { opacity: 0; }
.fc-cookie.cracked .fc-sprite [data-open] { opacity: 1; }
.fc-cookie.cracked .fc-sprite [data-whole] { opacity: 0; }
.fc.static .fc-sprite, .fc.static .fc-plate { display: block; }
JavaScript moves the panel right after the skip link, so a keyboard reaches it first.
// first in the tab order: right after the skip link
document.querySelector('.skip').after(root);
A status reads from the store: Essential is always on; the others read Unopened, then Not chosen yet once opened (reading is not deciding), and show the saved choice once you decide.
function status(cat) {
const s = consent.get();
if (cat === 'essential') return ['locked', 'Always on'];
if (!s.decided) return ['', state.cracked[cat] ? 'Not chosen yet' : 'Unopened']; // opened is not decided: a neutral label until a choice is saved
return s[cat] ? ['on', 'Allowed'] : ['off', 'No thanks'];
}
A first visit opens the panel with the Essential cookie already cracked. That is the showcase: it shows what the cookies do, and since Essential cannot be refused, nothing optional is pre-ticked.
async function open({ first = false, focus = true } = {}) {
if (state.open) { if (focus) btn.essential.focus(); return; }
state.open = true;
root.inert = false; root.removeAttribute('aria-hidden'); root.dataset.state = 'open';
for (const c of CATEGORIES) state.cracked[c] = false;
// a first visit opens on the showcase: the Essential cookie is already cracked (it is always on, so nothing optional
// is pre-ticked)
const showcase = first;
if (showcase) { state.showcase = true; state.cracked.essential = true; }
syncCracks(); syncStatus();
let grow = null;
if (!reduced.matches) grow = root.animate([{ transform: 'translateY(24px)', opacity: 0 }, { transform: 'none', opacity: 1 }], { duration: 480, easing: 'cubic-bezier(.2,.8,.2,1)' });
if (focus && !first) btn.essential.focus({ preventScroll: true });
// the first open settles before the page reports ready (capped, so a hidden tab never holds it up)
if (first && grow) await Promise.race([grow.finished.catch(() => {}), wait(700)]);
}
const s = consent.get();
if (!s.decided) await open({ first: true, focus: false });
else setClosed();
Accept all cracks the unopened cookies in a cascade, 120 ms apart, saves, and closes. Reject non-essential has exactly the same size and style as Accept all, and × on a first visit is recorded as "dismissed": essential only.
async function acceptAll() {
if (state.busy) return; state.busy++;
consent.acceptAll();
const order = CATEGORIES.filter((c) => !state.cracked[c]);
// the cascade: every unopened cookie cracks, a beat apart
for (const [i, c] of order.entries()) { setTimeout(() => { state.cracked[c] = true; syncCracks(); }, i * 120); }
syncStatus();
await wait(order.length * 120); syncCracks();
await wait(reduced.matches ? 500 : 1300);
state.busy--;
await tuck('accept');
}
async function dismiss() {
if (state.busy) return;
if (!consent.get().decided) consent.rejectAll('dismissed');
await tuck('close');
}
Under 641 px the panel becomes a bottom sheet, the full width of the screen and never more than 86 % of its height.
@media (max-width: 640px) {
.fc { --w: 100%; --stage: 148px; --pad: 16px; right: 0; left: 0; bottom: 0; border-radius: 22px 22px 0 0; border-bottom: 0;
max-height: min(86dvh, 640px); padding-bottom: calc(12px + env(safe-area-inset-bottom)); }
.fc h2 { font-size: 26px; }
#fc-desc { font-size: 12.5px; }
#fc-desc .more { display: none; }
.fc-stage { margin-top: 8px; }
.fc-name { font-size: 11px; }
.fc-status { font-size: 9px; letter-spacing: .06em; }
.fc-actions { margin-top: 10px; }
.fc-btn { font-size: 12.5px; }
.fc-policy { margin-top: 8px; }
}

3 · Crack, read, decide
Now a cookie holds a fortune. One paper slip serves all four: it carries the category, its number, one sentence
about what it does, what it keeps and shows, and a switch printed on the paper. The switch is a real
role="switch" button; Essential's is locked with aria-disabled.
<div class="fc-slip" id="fc-slip" data-cat="" role="group" aria-labelledby="fc-slip-cat">
<div class="fc-paper">
<p class="fc-slip-cat" id="fc-slip-cat"><span data-slip-name>Your fortune</span><span class="fc-slip-no" data-slip-no></span></p>
<p class="fc-slip-text" id="fc-slip-text" data-slip-text>Tap a cookie. Each one hides a sentence about what it does.</p>
<div class="fc-slip-foot">
<p class="fc-slip-meta" data-slip-meta></p>
<button class="fc-switch" type="button" role="switch" id="fc-switch" aria-checked="false" aria-labelledby="fc-switch-label" aria-describedby="fc-slip-text">
<span class="fc-switch-label" id="fc-switch-label" data-switch-label>Allow</span>
<span class="fc-switch-opt" data-off>No thanks</span><span class="fc-switch-track" aria-hidden="true"><i></i></span><span class="fc-switch-opt" data-on>Allow</span>
</button>
</div>
</div>
</div>
export const FORTUNES = {
essential: { no: 'No. 1', text: 'Without this one your basket forgets you between pages. It stays, and it never leaves this site.',
meta: '<b>Keeps</b> your basket and this choice · <b>shares</b> nothing' },
analytics: { no: 'No. 2', text: 'A little bird counts which pastries get looked at, never who looked.',
meta: '<b>Keeps</b> view counts on this device · <b>shows</b> the oven counter and heat badges' },
marketing: { no: 'No. 3', text: 'A Sunday box at fifteen percent off is in your future, if you would like to hear about it.',
meta: '<b>Keeps</b> that you saw the offer · <b>shows</b> the offer ribbon and the newsletter' },
personalisation: { no: 'No. 4', text: 'The pastries you love will drift to the front of the counter.',
meta: '<b>Keeps</b> your flavour picks · <b>reorders</b> the counter to your taste' },
};
.fc-paper { position: relative; padding: 14px 18px 12px; background:
repeating-linear-gradient(90deg, rgba(160, 130, 100, .025) 0 2px, transparent 2px 5px), #FFFDF7;
border-radius: 3px; box-shadow: 0 1px 0 rgba(255, 255, 255, .8) inset, 0 10px 22px -10px rgba(80, 55, 35, .35), 0 1px 3px rgba(80, 55, 35, .12);
clip-path: polygon(0 0, 100% 0, 99.2% 12%, 100% 25%, 99.4% 38%, 100% 50%, 99.1% 63%, 100% 76%, 99.3% 88%, 100% 100%, 0 100%, .7% 88%, 0 76%, .9% 63%, 0 50%, .6% 38%, 0 25%, .8% 12%); }
/* the switch printed on the slip: a real role=switch button */
.fc-switch { display: inline-flex; align-items: center; gap: 8px; min-height: 44px; padding: 0 2px; border: 0; background: none; flex: none; border-radius: 999px; }
.fc-switch-label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.fc-switch-opt { font: 600 11.5px/1 var(--sans); color: var(--muted); transition: color .2s; white-space: nowrap; }
.fc-switch[aria-checked="false"] [data-off], .fc-switch[aria-checked="true"] [data-on] { color: var(--ink); }
.fc-switch-track { position: relative; width: 46px; height: 26px; border-radius: 13px; background: #D9CFC2; transition: background .25s var(--ease); flex: none; }
.fc-switch-track i { position: absolute; left: 3px; top: 3px; width: 20px; height: 20px; border-radius: 50%; background: #fff; box-shadow: 0 1px 3px rgba(0, 0, 0, .25); transition: transform .25s var(--ease); }
.fc-switch[aria-checked="true"] .fc-switch-track { background: #4D7C0F; }
.fc-switch[aria-checked="true"] .fc-switch-track i { transform: translateX(20px); }
Cracking is reading, not consenting: it saves nothing. The crack starts in one frame and the paper and the labels
follow in the next, so the two never share a frame. A keyboard press (a click with detail 0) moves the
focus straight to the switch, and the live region reads the fortune out.
async function crack(cat, { focusSwitch = false, quiet = false } = {}) {
const first = !state.cracked[cat];
state.cracked[cat] = true; state.active = cat;
syncCracks();
await nextFrame(); // the crack starts this frame; the paper and the labels follow in the next
syncStatus();
await showSlip(cat, btn[cat]);
if (!quiet) {
const [, t] = status(cat);
say(`${NAME[cat]} cookie ${first ? 'cracked' : 'open'}. ${FORTUNES[cat].text} ${cat === 'essential' ? 'Always on.' : t === 'Not chosen yet' ? 'Not chosen yet: off until you allow it.' : `Currently ${t}.`}`);
}
if (focusSwitch) sw.focus();
}
The paper slides out of the cookie: it starts at the cookie, small and folded, and ends at rest with no transform at all, so its text stays sharp.
async function showSlip(cat, from) {
paintSlip(cat);
if (reduced.matches) { slip.animate([{ opacity: 0 }, { opacity: 1 }], { duration: 220, easing: 'linear' }); return; }
// the paper slides out of the cookie: start at the cookie, small and folded, end at rest (no transform at rest)
const a = from ? from.getBoundingClientRect() : null, b = slip.getBoundingClientRect();
const dx = a ? a.left + a.width / 2 - (b.left + b.width / 2) : 0, dy = a ? a.top + a.height * 0.35 - (b.top + b.height / 2) : -20;
slip.animate([
{ transform: `translate(${dx}px, ${dy}px) scale(.18, .08)`, opacity: 0 },
{ transform: `translate(${dx * 0.3}px, ${dy * 0.3}px) scale(.7, .35)`, opacity: 1, offset: 0.35 },
{ transform: 'translate(0, 3px) scale(1.01, 1.02)', offset: 0.78 },
{ transform: 'none', opacity: 1 },
], { duration: 520, easing: 'cubic-bezier(.2,.7,.2,1)' });
}
function paintSwitch(cat) {
const locked = cat === 'essential', on = locked || consent.allowed(cat);
sw.setAttribute('aria-checked', String(on));
sw.toggleAttribute('aria-disabled', false);
if (locked) sw.setAttribute('aria-disabled', 'true');
$('[data-switch-label]').textContent = locked ? `${NAME[cat]} cookies, always on` : `Allow ${NAME[cat].toLowerCase()} cookies`;
sw.querySelector('[data-on]').textContent = locked ? 'Always on' : 'Allow';
}
A flip is a choice, saved at once. The knob moves in this frame and the save happens in the next task, so the switch answers the finger before the page starts changing. Essential nudges and explains instead.
function flip() {
const cat = state.active; if (!cat || !FORTUNES[cat]) return;
if (cat === 'essential') {
sw.classList.remove('nudge'); void sw.offsetWidth; sw.classList.add('nudge');
say('Essential cookies cannot be switched off. They keep your basket and this choice, and nothing else.');
return;
}
const on = !consent.allowed(cat);
// the knob moves this frame; the choice is saved (and the page starts changing) in the next task
sw.setAttribute('aria-checked', String(on));
requestAnimationFrame(() => setTimeout(() => {
consent.set({ [cat]: on }, 'switch');
paintSwitch(state.active === cat ? cat : state.active); syncStatus();
say(`${NAME[cat]} ${on ? 'allowed' : 'switched off'}. ${on ? 'The page has changed.' : 'Its data was deleted.'}`);
}, 0));
}
The first visit's showcase now puts the Essential fortune out on the very first painted frame, as plain DOM with no animation or timer in front of it, so the slot between the cookies and the buttons is never empty.
const showcase = first;
if (showcase) { state.showcase = true; state.cracked.essential = true; state.active = 'essential'; paintSlip('essential'); }
else { state.active = ''; paintSlip(''); }
Save keeps what the switches say (or essential only, if nothing was touched). Closing folds the paper back first.
async function save() {
if (state.busy) return;
const s = consent.get();
consent.set(Object.fromEntries(OPTIONAL.map((k) => [k, s[k]])), s.decided ? 'save' : 'save-defaults');
say('Choices saved.');
await tuck('save');
}
// 1. the papers fold back
const rm = reduced.matches;
const fold = slip.animate([{ transform: 'none', opacity: 1 }, { transform: 'scale(1, .06)', opacity: 0 }], { duration: rm ? 180 : 260, easing: 'cubic-bezier(.5,0,.75,0)', fill: 'forwards' });
// 2. the cookies close
for (const c of CATEGORIES) state.cracked[c] = false;
state.active = ''; syncCracks();
await wait(rm ? 200 : 620);
Keys: ← and → move between the cookies, Home and End jump, Enter or Space cracks one, and Escape closes.
b.addEventListener('keydown', (e) => {
const i = CATEGORIES.indexOf(c), n = CATEGORIES.length;
const j = e.key === 'ArrowRight' ? (i + 1) % n : e.key === 'ArrowLeft' ? (i + n - 1) % n : e.key === 'Home' ? 0 : e.key === 'End' ? n - 1 : -1;
if (j >= 0) { e.preventDefault(); btn[CATEGORIES[j]].focus(); }
});
root.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); dismiss(); } });

4 · A real-time 3D plate
The sprites become a three.js scene: the same four cookies on a stoneware tray, lit like the product renders. three.js
is shipped with the component and found through an import map, and plate3d.js is loaded only when it can
run: with reduced motion, ?static or no WebGL, the panel keeps the static plate of stage 3.
<script type="importmap">{"imports":{"three":"./vendor/three/build/three.module.js","three/addons/":"./vendor/three/jsm/"}}</script>
let plate = null;
const wantStatic = () => reduced.matches || q.has('static');
async function boot3d() {
if (wantStatic()) return null;
const { createPlate, webglAvailable } = await import('./plate3d.js');
if (!webglAvailable()) return null;
try { const p = createPlate(canvas, { cats: CATEGORIES, phone: phone() }); await p.ready; return p; }
catch (e) { console.warn('3D plate unavailable, using the static plate', e); return null; }
}
plate = await boot3d();
root.classList.toggle('static', !plate);
The DOM decides where the cookies go. The panel measures the centre of each button's column and hands those to the
plate, which turns CSS pixels into world units, aims a 24° camera about 33° above the tray, and shifts the picture up
so the cookies sit over their labels. A ResizeObserver keeps it in step.
function measure() {
const r = stage.getBoundingClientRect();
const centers = CATEGORIES.map((c) => { const b = btn[c].getBoundingClientRect(); return b.left - r.left + b.width / 2; });
return { w: r.width, h: r.height, centers };
}
function relayout() { if (!plate) return; const m = measure(); if (m.w > 0) plate.layout(m.w, m.h, m.centers, phone() ? 34 : 38); }
new ResizeObserver(() => { if (state.open) relayout(); }).observe(stage);
function layout(stageW, stageH, centers, labelBand = 40) {
W = Math.max(1, stageW); H = Math.max(1, stageH);
const colW = (centers[centers.length - 1] - centers[0]) / Math.max(1, centers.length - 1) || W / 4;
k = 2.12 / colW; // world units per CSS px at the cookies' depth
const fov = cam.fov * Math.PI / 180, el = 0.58; // ~33° above the plate
const dist = (H * k) / (2 * Math.tan(fov / 2));
const target = new THREE.Vector3(0, 0.42, 0.1);
cam.position.set(0, target.y + Math.sin(el) * dist, Math.cos(el) * dist); cam.lookAt(target);
cam.aspect = W / H;
// shift the picture up so the cookies sit above the label band
const cy = (H - labelBand) * 0.44;
cam.setViewOffset(W, H, 0, (H / 2 - cy), W, H);
cam.updateProjectionMatrix();
xs = centers.map((c) => (c - W / 2) * k);
cats.forEach((cat, i) => { cookies[cat].root.position.set(xs[i], 0, 0); });
const hx = (xs[xs.length - 1] - xs[0]) / 2 + colW * k * 0.46;
hz = Math.min(1.5, hx * 0.3);
if (Math.abs(hx - plateHX) > 0.02) { plate.geometry.dispose(); plate.geometry = plateGeometry({ hx, hz, rim: 0.15, seg: 120 }); plateHX = hx; }
key.target.position.set(0, 0, 0);
Object.assign(key.shadow.camera, { left: -hx - 1, right: hx + 1, top: 4, bottom: -4, near: 0.5, far: 30 }); key.shadow.camera.updateProjectionMatrix();
resize();
renderer.shadowMap.needsUpdate = true;
draw();
}
The cookie is modelled in code. A disc is folded over its diameter, and the crease is bent over a rim so the corners come together and the open edge flares into two lobes. It is built as a thick shell (outer and inner surface plus the rim), and a jagged crack line splits it into two halves.
// A disc of radius R folded over its diameter (the crease, along y), then bent over a rim so the two corners of the
// crease come down and together while the open edge flares into two lobes. Units: R = 1.
export function fortuneCookieGeometry({ R = 1, seed = 1, cracked = false, ns = 40, nt = 44, thick = 0.032 } = {}) {
const P = fortuneSurface({ R, seed });
if (!cracked) return { whole: shell(P, { ns: ns * 2, nt, thick }) };
const cl = crackLine(seed);
const left = shell(P, { ns, nt, thick, sRange: (t) => [0, cl(t)] });
const right = shell(P, { ns, nt, thick, sRange: (t) => [cl(t), 1] });
return { left, right, crackCenter: P(cl(0.5), 0.5) };
}
On the plate a cookie is always two halves on pivots, touching when closed. So a crack is only a spring on their angle: an under-damped one, so the break wobbles before it settles, plus a hop and a paper slip that rises once the halves have parted.
// cookies: always two halves (closed = touching), so a crack is just a spring on their angle
const tex = cookieTextures({ size: phone ? 256 : 384, seed: 2 });
const mat = cookieMaterial(tex);
const paperMat = new THREE.MeshStandardMaterial({ color: '#fffdf6', roughness: 0.85, side: THREE.DoubleSide });
const paperGeo = (() => { const g = new THREE.PlaneGeometry(0.78, 0.3, 16, 2); const p = g.attributes.position; for (let i = 0; i < p.count; i++) p.setZ(i, 0.05 * Math.sin(p.getX(i) * 2.4)); g.computeVertexNormals(); return g; })();
const cookies = {};
cats.forEach((cat, i) => {
const g = fortuneCookieGeometry({ seed: i + 1, cracked: true, ns: phone ? 22 : 30, nt: phone ? 26 : 34, thick: 0.034 });
const root = new THREE.Group(), inner = new THREE.Group(); root.add(inner); inner.rotation.z = Math.PI / 2;
const halves = [[g.left, -1], [g.right, 1]].map(([geo, sgn]) => {
const piv = new THREE.Group(); piv.position.copy(g.crackCenter); piv.position.x -= 0.7;
const m = new THREE.Mesh(geo, mat); m.castShadow = m.receiveShadow = true;
m.position.copy(g.crackCenter).negate(); m.position.x += 0.7; piv.add(m); inner.add(piv);
return { piv, sgn, base: piv.position.clone() };
});
// rest the cookie on the plate: lowest point at y = 0
root.updateMatrixWorld(true); const box = new THREE.Box3().setFromObject(root, true); inner.position.y -= box.min.y;
const paper = new THREE.Mesh(paperGeo, paperMat); paper.castShadow = true; paper.visible = false; root.add(paper);
root.rotation.y = YAW[i % 4];
scene.add(root);
cookies[cat] = { root, inner, halves, paper, top: box.max.y - box.min.y, open: 0, vel: 0, target: 0, lift: 0, liftV: 0, hover: 0, hoverT: 0, pap: 0, papT: 0 };
});
crack(cat, { crumbs: withCrumbs = true } = {}) {
const c = cookies[cat]; if (!c || c.target === OPEN) return;
c.target = OPEN; c.vel += 4.5; c.liftV += 2.6; c.papT = 1; c.paper.visible = true;
if (withCrumbs) emit(cat);
kick();
},
// halves: an under-damped spring, so the break wobbles before it settles
const a = 190 * (c.target - c.open) - 9.5 * c.vel; c.vel += a * dt; c.open += c.vel * dt;
if (c.target === 0 && c.open < 0) { c.open = 0; c.vel = 0; }
Fourteen crumbs fly out of each crack. They are one instanced mesh of 140, with plain ballistic physics: gravity, a spin, a bounce that loses two thirds of its speed on the tray (or on the panel beyond it), and rest.
b.v.y -= 16 * dt; b.p.addScaledVector(b.v, dt);
b.r.x += b.w.x * dt; b.r.y += b.w.y * dt; b.r.z += b.w.z * dt;
const onPlate = Math.abs(b.p.x) < hx && Math.abs(b.p.z) < hz - 0.2;
const floor = onPlate ? 0.012 + b.s * 0.4 : -0.04 + b.s * 0.4;
if (b.p.y < floor && b.fade === 0) {
b.p.y = floor;
if (b.v.y < -0.8) { b.v.y *= -0.32; b.v.x *= 0.55; b.v.z *= 0.55; b.w.multiplyScalar(0.5); }
else { b.v.set(0, 0, 0); b.w.set(0, 0, 0); b.rest = true; }
}
The plate draws only while something moves. A crack kicks the loop; it stops three frames after everything is still. While it runs, the shadow map is redrawn every other frame. If many frames run long, the pixel ratio steps down by 0.25, then the shadows go.
function kick() { if (!running) { running = true; settleFrames = 0; last = 0; requestAnimationFrame(frame); } }
function frame(t) {
const dt = Math.min(1 / 30, last ? (t - last) / 1000 : 1 / 60);
// adaptive quality: watch the frame interval while animating; if many run long, drop the pixel ratio, then shadows
if (last) { const iv = t - last; quality.base = Math.min(quality.base, iv); quality.n++; if (iv > quality.base * 1.6 + 2) quality.slow++;
if (quality.n >= 60) { if (quality.slow > 18) degrade(); quality.n = 0; quality.slow = 0; } }
last = t;
const moving = step(dt);
draw(!moving);
onFrame && onFrame();
if (moving) settleFrames = 0; else settleFrames++;
if (settleFrames > 2) { running = false; last = 0; return; }
requestAnimationFrame(frame);
}
// while animating, the shadow map is redrawn every other frame (and always on the last one)
function draw(final = true) { if (quality.shadows && (final || (++tick & 1))) renderer.shadowMap.needsUpdate = true; renderer.render(scene, cam); }
function resize() {
const pr = Math.max(1, Math.min(quality.dprCap, Math.sqrt(0.9e6 / (W * H))));
const key = `${W}x${H}@${pr}`; if (key === sized) return; sized = key; // re-sizing a canvas reallocates it: only on change
renderer.setPixelRatio(pr); renderer.setSize(W, H, false);
}
Every shader is compiled before the panel opens, the hidden paper slips and the crumbs included, so the first crack never stalls.
// compile every program up front, the hidden paper slips and the crumbs included, so the first crack never stalls
api.compile = async () => {
await ready;
for (const cat of cats) cookies[cat].paper.visible = true;
cmesh.setMatrixAt(0, new THREE.Matrix4().makeScale(0.001, 0.001, 0.001)); cmesh.instanceMatrix.needsUpdate = true;
renderer.compile(scene, cam); renderer.shadowMap.needsUpdate = true; renderer.render(scene, cam);
for (const cat of cats) cookies[cat].paper.visible = false;
cmesh.setMatrixAt(0, hideM); cmesh.instanceMatrix.needsUpdate = true;
draw();
};
Hover and focus lift a cookie a little. The buttons still sit over the canvas and take every click.
b.addEventListener('pointerenter', () => plate && plate.hover(c, true));
b.addEventListener('pointerleave', () => plate && plate.hover(c, false));
b.addEventListener('focus', () => plate && plate.hover(c, true));
b.addEventListener('blur', () => plate && plate.hover(c, false));
.fc.static .fc-sprite, .fc.static .fc-plate { display: block; }
.fc.static .fc-canvas { display: none; }

5 · Analytics: the first gated script
Now a choice changes the page. The optional features are scripts that cannot run without consent: they sit in the
page as type="text/plain", which the browser ignores. When a category is allowed, the store swaps each
one for a real module.
<!-- gated scripts: inert (type text/plain) until their category is allowed; consent.js swaps them for real modules -->
<script type="text/plain" data-type="module" data-consent="analytics" src="js/features/analytics.js"></script>
function runGates() {
document.querySelectorAll('script[type="text/plain"][data-consent]').forEach((old) => {
if (!old.dataset.consent.split(/\s+/).every(allowed)) return;
const s = document.createElement('script');
for (const a of old.attributes) if (a.name !== 'type') s.setAttribute(a.name, a.value);
if (old.dataset.type) s.type = old.dataset.type;
s.textContent = old.textContent;
old.replaceWith(s);
});
document.querySelectorAll('[data-consent][data-src]:not(script)').forEach((el) => {
if (el.dataset.consent.split(/\s+/).every(allowed) && !el.getAttribute('src')) el.setAttribute('src', el.dataset.src);
});
}
Every category declares the keys it stores. Revoking it deletes them, and a missing or stale record wipes them too.
store: (cat, key) => { if (stores[cat]) stores[cat].add(key); if (!allowed(cat)) wipe(cat); },
function wipe(cat) {
const gone = [];
for (const k of stores[cat] || []) if (safe(() => localStorage.getItem(k) !== null, false)) { safe(() => localStorage.removeItem(k)); gone.push(k); }
return gone;
}
consent.store('analytics', 'dv50b.analytics');
A feature registers a mount and an unmount with the page, and the page keeps the features in
step with consent. Every deferred DOM change waits its turn in a queue of one job per frame, so turning three features on
at once never lands in a single frame.
const jobs = []; let pumping = false;
const pump = () => requestAnimationFrame(() => setTimeout(() => { const fn = jobs.shift(); if (fn) safe(fn); if (jobs.length) pump(); else pumping = false; }, 0));
const later = (fn) => { jobs.push(fn); if (!pumping) { pumping = true; pump(); } };
function reconcileStep() {
for (const cat of OPTIONAL) {
const f = features[cat], on = consent.allowed(cat);
if (f && on && !mounted[cat]) { mounted[cat] = true; safe(() => f.mount()); return true; }
if (f && !on && mounted[cat]) { mounted[cat] = false; safe(() => f.unmount()); return true; }
}
return false;
}
function reconcile() {
if (queued) return; queued = true;
const run = () => { if (reconcileStep()) later(run); else { queued = false; later(inspect); } };
later(run);
}
const O = window.orvelle = {
products, byId, slots,
register(cat, f) { features[cat] = f; reconcile(); },
changed: () => inspect(),
mounted: () => ({ ...mounted }),
};
Analytics adds a live "pastries out of the oven since 5:00" counter to the hero (labelled as demo figures) and a heat badge to every product. It counts views on this device only: a product 60 % visible for 0.8 s is one view, and Add counts two.
function mount() {
data = load(); store(data);
oven = document.createElement('p'); oven.className = 'oven'; oven.dataset.feature = 'analytics';
let n = baked();
oven.innerHTML = `<span class="oven-dot" aria-hidden="true"></span><span><b data-n>${fmt.format(n)}</b> pastries out of the oven since 5:00 <small>· shop counter, demo figures</small></span>`;
O.slots.oven.appendChild(oven);
const num = oven.querySelector('[data-n]');
tick = setInterval(() => { n += 1 + (Math.random() < 0.4); num.textContent = fmt.format(n); }, 2600);
requestAnimationFrame(() => setTimeout(() => { if (data) O.products.forEach(badge); }, 0)); // badges a frame after the counter
// count a view when a product is at least 60% visible for 0.8 s (once per page view each)
seen = new Set(); const timers = new Map();
io = new IntersectionObserver((es) => { for (const e of es) {
const id = e.target.dataset.id;
if (e.intersectionRatio >= 0.6 && !seen.has(id)) timers.set(id, setTimeout(() => { if (!data) return; seen.add(id); data.views[id] = (data.views[id] || 0) + 1; store(data); badge(O.byId[id], 0); }, 800));
else { clearTimeout(timers.get(id)); timers.delete(id); }
} }, { threshold: [0, 0.6] });
O.products.forEach((p) => io.observe(p.el));
onBasket = (e) => { const id = e.detail.id; data.views[id] = (data.views[id] || 0) + 2; store(data); badge(O.byId[id], 0); };
window.addEventListener('orvelle:basket', onBasket);
}
function heatOf(p) { return Math.min(99, Math.round(p.heat * 0.85 + Math.min(15, (data.views[p.id] || 0) * 3))); }
.heat { display: inline-flex; align-items: center; gap: 6px; height: 28px; padding: 0 10px 0 8px; border-radius: 999px; font: 600 11px/1 var(--sans); letter-spacing: .04em; color: #fff; background: var(--heat, #C2410C); box-shadow: 0 2px 8px rgba(60, 30, 10, .18); animation: rise .45s var(--ease) both; animation-delay: var(--d, 0s); }
.heat i { display: block; width: 26px; height: 4px; border-radius: 2px; background: rgba(255, 255, 255, .35); overflow: hidden; }
.heat i::before { content: ''; display: block; height: 100%; width: var(--w, 50%); background: #fff; border-radius: 2px; }
function unmount() {
clearInterval(tick); io && io.disconnect(); window.removeEventListener('orvelle:basket', onBasket);
const gone = [oven, ...document.querySelectorAll('.heat')];
for (const el of gone) { if (!el) continue; el.classList.add('out'); setTimeout(() => el.remove(), 300); }
oven = null; data = null;
}
The footer gets an "On this device" list of every dv50b.* key and its size. A deleted key stays for six
seconds, struck through, and Forget everything clears it all and asks again.
function inspect() {
if (!insp) return;
const keys = safe(() => Object.keys(localStorage).filter((k) => k.startsWith('dv50b.')).sort(), []);
const rows = keys.map((k) => { const li = document.createElement('li'); li.innerHTML = `<span>${k}</span><span>${(safe(() => localStorage.getItem(k).length, 0))} B</span>`; return li; });
for (const [k, t] of ghosts) { if (Date.now() - t > 6000 || keys.includes(k)) { ghosts.delete(k); continue; } const li = document.createElement('li'); li.className = 'gone'; li.innerHTML = `<span>${k}</span><span>deleted</span>`; rows.push(li); }
if (!rows.length) { const li = document.createElement('li'); li.innerHTML = '<span>Nothing stored yet</span><span></span>'; rows.push(li); }
insp.replaceChildren(...rows);
}
document.querySelector('[data-forget]')?.addEventListener('click', () => {
basket = []; safe(() => localStorage.removeItem(BASKET)); paintBasket();
consent.reset(); consent.open();
});

6 · Personalisation and Marketing
Personalisation reorders the counter to your taste, so the page learns to move its cards. FLIP: measure every card, change the DOM, then play each card from where it was to where it is.
function flip(container, mutate, { duration = 560 } = {}) {
const box = container.getBoundingClientRect();
if (reduced() || box.bottom < -200 || box.top > innerHeight + 200) { mutate(); return; } // nobody would see the move
const kids = [...container.children], before = new Map(kids.map((k) => [k, k.getBoundingClientRect()]));
mutate();
if (reduced()) return;
for (const k of container.children) {
const a = before.get(k); if (!a) continue;
const b = k.getBoundingClientRect(), dx = a.left - b.left, dy = a.top - b.top;
if (Math.abs(dx) < 1 && Math.abs(dy) < 1) continue;
k.classList.add('moving');
k.animate([{ transform: `translate(${dx}px, ${dy}px)` }, { transform: 'none' }], { duration, easing: 'cubic-bezier(.2,.75,.2,1)' })
.finished.then(() => k.classList.remove('moving'), () => k.classList.remove('moving'));
}
}
function arrange(ids = order, { animate = true } = {}) {
order = ids.slice();
const run = () => {
const extras = [...grid.children].filter((k) => !k.classList.contains('card'));
for (const id of order) grid.appendChild(byId[id].el);
for (const x of extras) x.remove();
for (const x of extras) grid.insertBefore(x, grid.children[+x.dataset.slotAt || 4] || null);
};
animate ? flip(grid, run) : run();
}
// run steps in separate frames (each after a paint), so one change never stacks several layouts in one frame
function seq(...fns) { for (const fn of fns) later(fn); }
Taste starts from your basket, or from what most regulars pick when the basket is empty. Each product scores the sum of your liking for its tags, and the counter, the "For you" tags and three picks follow the score.
function load() {
try { const d = JSON.parse(localStorage.getItem(KEY)); if (d && d.v === 1) return d; } catch {}
// first time: start from the basket; with an empty basket, start from what most regulars pick
const likes = { buttery: 0.6, chocolate: 0.45, spiced: 0.3 }; // what most regulars pick
const basket = O.basket();
for (const id of basket) for (const t of O.byId[id].tags) likes[t] = (likes[t] || 0) + 1;
const seeded = !basket.length;
return { v: 1, likes, seeded };
}
const score = (p) => p.tags.reduce((s, t) => s + (data.likes[t] || 0), 0);
function ranked() { return O.products.slice().sort((a, b) => score(b) - score(a) || a.i - b.i); }
function apply({ split = false } = {}) {
const r = ranked();
const move = () => { if (!data) return; O.arrange(r.map((p) => p.id)); };
const tag = () => {
if (!data) return;
document.querySelectorAll('.card .yours').forEach((e) => e.remove());
r.slice(0, 3).forEach((p) => { if (score(p) > 0) { const t = document.createElement('span'); t.className = 'yours'; t.textContent = 'For you'; p.el.querySelector('.card-media').appendChild(t); } });
paintRow(r); note.textContent = 'Sorted to your taste.';
};
if (split) O.seq(move, tag); else { move(); tag(); }
}
A "Because you like … things" row lands above the counter with nine flavour chips. Tapping one tunes the taste and the counter moves at once. The row arrives as an empty box of about its size and fills in over three frames.
row.addEventListener('click', (e) => {
const c = e.target.closest('.chip'); if (!c) return;
if (!data) return; const t = c.dataset.t; data.likes[t] = (data.likes[t] || 0) > 0.4 ? 0 : 1.2; data.seeded = false; store(data); apply();
});
// the row lands as an empty box of about its size (page shifts once), its contents lay out in the next frame,
// then the counter reorders and gets its tags: each step in its own frame
const parts = [...row.children]; parts.forEach((p) => p.classList.add('pending'));
O.slots.taste.appendChild(row);
O.seq(...parts.map((p) => () => p.classList.remove('pending')));
Marketing adds a Sunday Fortune Box offer ribbon over the nav (with a code, and a × that remembers) and a newsletter card in the grid. The newsletter is honest about being a demo: it sends and stores nothing, and says so.
O.seq(() => rb && rb.isConnected === false && data && O.slots.ribbon.appendChild(rb), () => c === card && O.flip(O.grid, () => O.grid.insertBefore(c, O.grid.children[4] || null)));
card.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); card.querySelector('.demo').textContent = 'Demo only: nothing was sent, and nothing was stored.'; });
function unmount() {
drop(ribbon); ribbon = null;
const c = card; card = null;
if (c) { c.classList.add('out'); setTimeout(() => O.seq(() => O.flip(O.grid, () => c.remove())), 280); }
data = null;
}

7 · The corner cookie and "What changed"
The last stage is the download exactly. Closing the panel no longer just hides it: it tucks itself into a fortune-cookie button in the corner, which takes the focus and opens the panel again from where it sits.
<button class="fc-reopen" type="button" data-reopen aria-label="Cookie preferences" hidden>
<img src="img/cookie-1-whole.png" alt="" width="130" height="90">
</button>
// 3. the panel tucks itself into the cookie button in the corner
reopen.hidden = false;
const a = root.getBoundingClientRect(), b = reopen.getBoundingClientRect();
const sx = b.width / a.width, sy = b.height / a.height;
const tx = b.left + b.width / 2 - (a.left + a.width / 2), ty = b.top + b.height / 2 - (a.top + a.height / 2);
reopen.style.visibility = 'hidden';
await root.animate(rm ? [{ opacity: 1 }, { opacity: 0 }] : [
{ transform: 'none', opacity: 1, borderRadius: '24px' },
{ transform: `translate(${tx}px, ${ty}px) scale(${sx}, ${sy})`, opacity: 0.2, borderRadius: '50%' },
], { duration: rm ? 200 : 420, easing: 'cubic-bezier(.6,0,.3,1)' }).finished.catch(() => {});
if (fromBtn) {
const a = root.getBoundingClientRect(), b = reopen.getBoundingClientRect();
grow = root.animate([{ transform: `translate(${b.left + b.width / 2 - (a.left + a.width / 2)}px, ${b.top + b.height / 2 - (a.top + a.height / 2)}px) scale(${b.width / a.width}, ${b.height / a.height})`, opacity: 0.2, borderRadius: '50%' },
{ transform: 'none', opacity: 1, borderRadius: '24px' }], { duration: 420, easing: 'cubic-bezier(.2,.8,.2,1)' });
}
/* the reopen button the panel tucks into */
.fc-reopen { position: fixed; right: 20px; bottom: 20px; z-index: 49; width: 64px; height: 64px; padding: 0; border-radius: 50%; display: grid; place-items: center;
border: 1px solid var(--line); background: var(--paper); box-shadow: 0 10px 30px -8px rgba(59, 42, 32, .4); transition: transform .25s var(--ease); }
.fc-reopen img { width: 54px; height: auto; transform: translateY(2px); }
.fc-reopen:hover { transform: translateY(-2px) rotate(-6deg); }
.fc-reopen[hidden] { display: none; }
.fc-reopen.pop { animation: pop .5s cubic-bezier(.3, 1.6, .5, 1) both; }
@keyframes pop { from { transform: scale(.3); opacity: 0; } }
And every change is said out loud in a "What changed" toast: what turned on or off, and which keys were deleted from this device. On a phone it sits at the top, clear of the sheet.
window.addEventListener('consent:change', (e) => {
const d = e.detail;
reconcile();
later(() => toastFor(d));
});
function toastFor(d) {
const lines = d.changes.map(({ cat, on }) => {
const t = FEATURE_TEXT[cat], gone = d.deleted.filter((k) => (consent.stores()[cat] || []).includes(k));
return [on ? 'on' : 'off', `<b>${t.name} ${on ? 'on' : 'off'}.</b> ${on ? t.on : t.off}${gone.length ? `<small>Deleted ${gone.join(', ')} from this device.</small>` : ''}`];
});
if (!lines.length && (d.method === 'dismissed' || d.method === 'save-defaults' || d.method === 'reject-non-essential')) lines.push(['off', '<b>Essential only.</b> Nothing optional runs on this page.']);
if (!lines.length && d.method === 'save') {
const on = OPTIONAL.filter((c) => d[c]).map((c) => FEATURE_TEXT[c].name);
lines.push(['on', on.length ? `<b>Saved.</b> ${on.join(', ')} on; everything else off.` : '<b>Saved.</b> Essential only.']);
}
if (d.method === 'reset') lines.push(['off', '<b>Forgotten.</b> Every key this page stored is gone.']);
showToast(lines);
}
.toast { contain: layout paint style; position: fixed; left: 20px; bottom: 20px; z-index: 60; width: min(360px, calc(100vw - 40px)); padding: 16px 18px 14px; border-radius: 16px; background: var(--ink); color: #F5EBDD; box-shadow: 0 18px 50px rgba(28, 25, 23, .28); }



Measured on a 120 Hz screen through cracks, flips, Accept all and Save: no long tasks and no missed frames at 1440 × 900 or on a 390 × 844 phone, with the CPU slowed 4 × too. The frames that did run long in a few runs were within what a blank page dropped on the same machine.
Make it yours
- Your categories are
CATEGORIESinconsent.js, a fortune each inFORTUNES, and a button in the panel. RaisePOLICYwhen they change and everyone is asked again. - Your features are scripts with
data-consent. Anything can be gated the same way, an iframe or an image throughdata-srcincluded. Declare what each one stores withconsent.store(). - From your own code:
consent.allowed('analytics'),consent.on(fn), theconsent:changeevent (it lists the changes and the deleted keys), andconsent.open()for your own "Cookie preferences" link. - The look is tokens at the top of each CSS file, and the panel's sizes are custom properties that each layout retunes.
- Without WebGL, with reduced motion or with
?static, the plate is the two baked sprites per cookie; everything else works the same. - Accessibility is already there: a labelled dialog first in the tab order, cookies as buttons with
aria-expanded, a real switch, a live region, arrow keys, Escape, 44 px targets and equal weight for Accept and Reject.
The code is on GitHub at UlviShabanbayli/domvybe-fortune-cookie-consent, under the MIT licence: use it in your own projects, commercial ones included. ORVELLE is made up. The pastry pictures and the cookie sprites are studio renders made in code, and the plate is drawn live with three.js (MIT).
Make reading separate from deciding. Cracking a cookie only shows what it does; the choice is a switch you flip, and it changes the page at once. Nothing optional runs until then, and switching it off takes its data with it.