Library / Tutorial

Fortune-cookie consent,
step by step.

A cookie banner as a plate of four 3D fortune cookies. Crack one, read what it does on the paper inside, and flip the switch printed on it: the page changes at once, and saying no deletes what was stored. A consent store, gated scripts, a real-time three.js plate and three features, in seven stages you can run.

7Stages
4Cookies
1Library: three.js

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.

index.html — light only
<meta name="color-scheme" content="only light">
<meta name="theme-color" content="#F7F2EA">
site.css — the tokens
: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.

index.html — one product
<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>
site.css — the card
.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.

site.js — read the basket
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'}`); };
site.js — Add
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);
});
main.js — boot, stage 1
import { createSite } from './site.js';

createSite();
Stage 1 · the page and its basketOpen ↗
The ORVELLE counter: Eight things worth getting up for, four pastry cards with a croissant, canelés, macarons and a lemon tart, the first two marked Added and the basket in the nav showing 2.

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.js — the API
//   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
consent.js — the record
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);
consent.js — a stale or foreign record counts as no record
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;
};
consent.js — save a choice
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;
}
consent.js — tell everyone
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.

index.html — the panel
<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>
index.html — one cookie
<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>
fortune.css — a floating panel, hidden without JavaScript
.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; }
fortune.css — the cookie row
.fc-cookies { position: absolute; inset: 0; display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); list-style: none; margin: 0; padding: 0 6px; }
fortune.css — the static plate
/* 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.

fortune.js — first in the tab order
// 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.

fortune.js — a cookie's status
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.

fortune.js — open
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)]);
}
fortune.js — ask only when there is no choice yet
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.

fortune.js — Accept all
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');
}
fortune.js — × records a refusal
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.

fortune.css — the phone sheet
@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; }
}
Stage 2 · the first panelOpen ↗
The ORVELLE hero with the cookie panel at the bottom right: Crack one open, four fortune cookies on a pale stoneware plate, all four cracked in the Accept all cascade and marked Allowed, and the Reject non-essential and Accept all buttons.

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.

index.html — the paper slip
<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>
fortune.js — the fortunes
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' },
};
fortune.css — the paper: faint fibres, a torn edge
.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%); }
fortune.css — the switch
/* 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.

fortune.js — crack a cookie
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.

fortune.js — the paper slides out
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)' });
}
fortune.js — paint the switch
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.

fortune.js — flip
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.

fortune.js — the showcase paper
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.

fortune.js — Save my choices
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');
}
fortune.js — fold the paper, close the cookies
// 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.

fortune.js — between the cookies
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(); }
});
fortune.js — Escape
root.addEventListener('keydown', (e) => { if (e.key === 'Escape') { e.preventDefault(); dismiss(); } });
Stage 3 · crack, read, decideOpen ↗
The panel with the Analytics cookie cracked: its paper reads A little bird counts which pastries get looked at, never who looked, with the switch at Allow and the status Allowed.

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.

index.html — the import map
<script type="importmap">{"imports":{"three":"./vendor/three/build/three.module.js","three/addons/":"./vendor/three/jsm/"}}</script>
fortune.js — 3D or the static plate
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.

fortune.js — measure the columns
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);
plate3d.js — lay the plate out from the DOM
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.

bake3d.js — the shape
// 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.
bake3d.js — whole, or two halves
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.

plate3d.js — two halves on pivots
// 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 };
});
plate3d.js — crack
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();
},
plate3d.js — the spring and the hop
// 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.

plate3d.js — a crumb lands
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.

plate3d.js — start drawing
function kick() { if (!running) { running = true; settleFrames = 0; last = 0; requestAnimationFrame(frame); } }
plate3d.js — one 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);
}
plate3d.js — shadows every other 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); }
plate3d.js — the canvas is resized only when it changes
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.

plate3d.js — compile up front
// 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.

fortune.js — hover and focus
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));
fortune.css — the canvas, or the sprites
.fc.static .fc-sprite, .fc.static .fc-plate { display: block; }
.fc.static .fc-canvas { display: none; }
Stage 4 · the 3D plateOpen ↗
The panel with the real-time 3D plate: the Marketing cookie just cracked into two halves with crumbs in the air over the tray, its fortune about a Sunday box below.

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.

index.html — a gated script
<!-- 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>
consent.js — run what is allowed
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.

consent.js — declare
store: (cat, key) => { if (stores[cat]) stores[cat].add(key); if (!allowed(cat)) wipe(cat); },
consent.js — delete what a category stored
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;
}
site.js — Analytics keeps dv50b.analytics
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.

site.js — one job per 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(); } };
site.js — mount and unmount with consent
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);
}
site.js — what a feature can use
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.

analytics.js — mount
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);
}
analytics.js — the heat score
function heatOf(p) { return Math.min(99, Math.round(p.heat * 0.85 + Math.min(15, (data.views[p.id] || 0) * 3))); }
site.css — a heat badge
.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; }
analytics.js — unmount
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.

site.js — On this device
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);
}
site.js — Forget everything
document.querySelector('[data-forget]')?.addEventListener('click', () => {
  basket = []; safe(() => localStorage.removeItem(BASKET)); paintBasket();
  consent.reset(); consent.open();
});
Stage 5 · Analytics onOpen ↗
The ORVELLE page with Analytics allowed: the hero shows 1,487 pastries out of the oven since 5:00, and the counter cards carry heat badges: Hot 81, Popular 66, Steady 57, Steady 52.

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.

site.js — FLIP
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'));
  }
}
site.js — put the cards in an order
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();
}
site.js — steps in separate frames
// 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.

personalisation.js — where taste starts
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 };
}
personalisation.js — the score
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); }
personalisation.js — reorder and tag
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.

personalisation.js — a flavour chip
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();
});
personalisation.js — the row, part by part
// 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.

marketing.js — the ribbon, then the card, a frame apart
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)));
marketing.js — the demo form
card.querySelector('form').addEventListener('submit', (e) => { e.preventDefault(); card.querySelector('.demo').textContent = 'Demo only: nothing was sent, and nothing was stored.'; });
marketing.js — unmount
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;
}
Stage 6 · Personalisation and Marketing onOpen ↗
The taste row: Because you like buttery things, flavour chips with Buttery and Chocolate selected, three picks (cardamom swirl, butter croissant, chocolate éclair), and the counter below reordered with For you on the first three cards.

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.

index.html — the corner cookie
<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>
fortune.js — tuck into the corner
// 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(() => {});
fortune.js — grow back out of it
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)' });
}
fortune.css — the corner cookie
/* 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.

site.js — on every change
window.addEventListener('consent:change', (e) => {
  const d = e.detail;
  reconcile();
  later(() => toastFor(d));
});
site.js — what to say
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);
}
site.css — the toast
.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); }
Stage 7 · the finished componentOpen ↗
The page after Accept all: the Sunday Fortune Box ribbon on top, the oven counter in the hero, a What changed toast listing Analytics, Marketing and Personalisation on, and the panel tucked into a round fortune-cookie button at the bottom right.
Stage 7 · a first visit on a phoneOpen ↗
The ORVELLE page on a phone with the bottom sheet: four 3D fortune cookies on the tray, the Essential one cracked with crumbs, and its fortune below with the switch locked at Always on.
Stage 7 · after Accept all on a phoneOpen ↗
The ORVELLE page on a phone after Accept all: the Sunday Fortune Box ribbon with the code FORTUNE15, the hero, the oven counter, and the fortune-cookie button in the corner.

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 CATEGORIES in consent.js, a fortune each in FORTUNES, and a button in the panel. Raise POLICY when 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 through data-src included. Declare what each one stores with consent.store().
  • From your own code: consent.allowed('analytics'), consent.on(fn), the consent:change event (it lists the changes and the deleted keys), and consent.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).

The one idea worth keeping

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.