AmoleoFamily

Layout Components

We use essential cookies to keep you signed in. No tracking, no third parties.

Decline Accept

Cookie consent — the shared banner

The consent gate carried by every Amoleo site that sets a non-essential cookie. It lives here, in the umbrella workspace, for the same reason the footer lockup does: it belongs to no single app, and a new site should be able to copy it without first working out which existing app got it right.

Amoleo-Website is the reference for how it looksindex.html, styles.css and main.js. Where this document and that code disagree on styling, the code is right and this should be corrected.

Amoleo-Pets is the reference for what it does: it carries a manage-preferences expansion the Website lacks, which has to be ported the other way before either site is finished. See §5.1.

The look: a bottom bar, one sentence, two buttons.

┌────────────────────────────────────────────────────────────────┐
│ Cookies                                                        │
│ Amoleo would like to use analytics cookies …    [Decline] [Accept] │
└────────────────────────────────────────────────────────────────┘

0. Status — who needs this

Repo Site Sets non-essential cookies? Banner
Amoleo-Website www.amoleo.com Yes — GTM/GA4 Reference for the styling. Needs the manage-preferences expansion porting in — see §5.1
Amoleo-Pets pets.amoleo.com Yes — GTM/GA4 Yes — reference for the interface; to be restyled to match the Website
Amoleo-Connections connections.amoleo.com No None, correctly
Amoleo-Rissbrook www.rissbrook.co.uk Not yet None yet
Amoleo-Accounts accounts.amoleo.com Session cookie only Will not need one

Two of these are deliberate absences, not gaps:

1. The rule the whole design follows

Nothing that needs consent is loaded until consent is given. Not loaded-then-signalled, not loaded-then-denied. Under UK PECR the storage has already happened the moment the container runs, and a Consent Mode signal sent afterwards does not undo it.

Three consequences that are easy to get wrong:

Decline is as prominent as Accept — same size, same row, adjacent. Accept carries the primary button style and Decline the quiet one, which is the most the guidance allows; making Decline a text link or hiding it behind "Manage preferences" is the dark pattern this arrangement exists to avoid.

Withdrawal must be as easy as consent. Every site keeps a Cookie settings control in the footer, hidden until a decision exists, that reopens the banner.

2. Storage

One key, in localStorage, identical in every app:

localStorage.cookieConsent = JSON.stringify({
  necessary: true,
  analytics: false,
  timestamp: '2026-07-31T12:00:00.000Z',
  version: 1
})

version is the re-ask mechanism. A stored decision whose version does not match the app's CONSENT_VERSION is treated as no decision at all, so bumping the constant asks everyone again. Bump it whenever what the analytics do changes materially — a new tag, a new purpose, a new recipient. Do not bump it for a restyle.

The key itself is strictly necessary — it exists only to honour the choice — and so needs no consent of its own. Say that in the privacy notice; the Website's already does.

Storage being unavailable (private mode, storage disabled) is treated as undecided: the banner shows every visit and the decision holds for that page view only. Never fall back to assuming consent.

3. The consent signal — default once, update after

This is the one piece of logic that is subtle enough to get wrong twice, and did: Pets shipped without it.

GTM only honours gtag('consent', 'default', …) before the container initialises. Anything after that must be 'update' or it is silently ignored. The first signal of a page view is therefore the default; every later change of mind is an update — and that later path is the one that matters, because withdrawing consent by definition happens after the container loaded.

var signalled = false;

function applyConsent(analytics) {
  window.dataLayer = window.dataLayer || [];
  window.gtag = window.gtag || function () { window.dataLayer.push(arguments); };

  var mode = signalled ? 'update' : 'default';
  signalled = true;

  window.gtag('consent', mode, { analytics_storage: analytics ? 'granted' : 'denied' });
  if (analytics) loadGtm();
}

Get this wrong and the symptom is invisible in testing unless you specifically accept, then reopen the banner and decline, then check whether the container is still recording. It will be.

4. The markup (static — Website, Rissbrook)

Lives in the served HTML rather than being built in JS, so it is styleable and readable without running the script. The script only unhides it.

This is the pre-port markup — it has no manage-preferences expansion, because the Website does not have one yet. Add it here when §5.1 is done.

<div class="consent" role="dialog" aria-modal="false"
     aria-labelledby="consent-heading" data-consent hidden>
  <div class="consent-inner">
    <div>
      <h2 id="consent-heading">Cookies</h2>
      <p>
        Amoleo would like to use analytics cookies to understand how this site is
        used. Nothing is loaded until you choose. See the
        <a href="/privacy.html">privacy&nbsp;notice</a>.
      </p>
    </div>
    <div class="consent-actions">
      <button type="button" class="btn btn-quiet" data-consent-decline>Decline</button>
      <button type="button" class="btn btn-hero" data-consent-accept>Accept</button>
    </div>
  </div>
</div>

And in the footer, on every page:

<button type="button" class="linkish" data-consent-reopen hidden>Cookie settings</button>

aria-modal="false" is load-bearing — it is the machine-readable half of "this is not a cookie wall". role="dialog" without it announces a modal that traps nothing.

The copy is deliberately short. It names the purpose (understand how the site is used), states the guarantee (nothing loaded until you choose), and links the detail rather than reciting it.

5. The component (React — Pets)

client/src/components/CookieConsentBanner.jsx. Same classes, same structure, same copy as §4 — only the privacy link differs, because Pets opens a modal rather than navigating to a page.

/**
 * The family cookie banner: one sentence, Decline and Accept.
 *
 * Bottom bar rather than a full-screen modal, and deliberately non-blocking —
 * the page behind stays usable, so this never acts as a cookie wall (ICO
 * guidance). Decline sits beside Accept at the same size for the same reason.
 *
 * Markup and copy are shared with Amoleo · Website; see
 * Amoleo-Family/docs/cookie-consent.md before changing either.
 */
export default function CookieConsentBanner({ onSave, onViewPrivacy }) {
  return (
    <div className="consent" role="dialog" aria-modal="false"
         aria-labelledby="consent-heading">
      <div className="consent-inner">
        <div>
          <h2 id="consent-heading">Cookies</h2>
          <p>
            Amoleo would like to use analytics cookies to understand how this site
            is used. Nothing is loaded until you choose. See the{' '}
            {onViewPrivacy
              ? <button type="button" className="linkish" onClick={onViewPrivacy}>privacy&nbsp;notice</button>
              : <span>privacy&nbsp;notice</span>}.
          </p>
        </div>
        <div className="consent-actions">
          <button type="button" className="btn btn-quiet" onClick={() => onSave(false)}>Decline</button>
          <button type="button" className="btn btn-hero" onClick={() => onSave(true)}>Accept</button>
        </div>
      </div>
    </div>
  )
}

The initialAnalytics prop stays, because the expansion below needs a state to open in.

5.1 Manage preferences — Pets' expansion is the one to keep

Pets' banner has a third control, Manage preferences, which expands a per-category list — Necessary (checked, disabled) and Analytics (a real checkbox) — with Save preferences replacing it once open. The Website has no equivalent.

This is the half of the design that travels the other way. The Website's styling is the family look and Pets adopts it; but the Website's interface is the poorer of the two, and it is the expansion that has to be ported across rather than dropped from Pets.

The reasoning against it — one optional category, so a toggle offers no choice the two buttons don't — is true today and still not enough. It only holds while there is exactly one optional purpose, so it is an argument that expires the first time a second tag is added, and rebuilding the control then is more work than keeping it. The expansion is also where the category names and their descriptions are stated, which is the part that does the actual informing; Decline and Accept alone ask people to consent to a sentence.

So the target state is the same three controls on both sites:

[Decline] [Accept]
[         Manage preferences         ]   → expands →   [ Save preferences ]

Decline and Accept stay side by side and equally prominent — the expansion is a third option beside them, never a place Decline is hidden behind. That distinction is the whole of §1's dark-pattern rule and the port must not blur it.

Outstanding work, Amoleo-Website: build the expansion into index.html / privacy.html, styles.css and main.js, restyled from Pets' inline styles into the class-based .consent-* system in §6. Pets' version is the behavioural reference — read client/src/components/CookieConsentBanner.jsx before writing it. Once it ships, fold the real markup into §4 and §5 here and delete this note; until then the two sites legitimately differ and §4 is the pre-port markup.

Two details worth carrying over rather than reinventing:

6. The CSS

Now the literal source lives in tools/family-css/components/cookie-consent.css, compiled into family-css/base.css — see docs/family-css-system.md. This section stays as the record of why and the token-mapping table just below; that file is the one copy of the literal rules now, not this fence.

.consent {
  position: fixed;
  left: 16px;
  right: 16px;
  bottom: 16px;
  z-index: 50;
  background: var(--panel);
  border: 1px solid var(--border);
  border-radius: var(--radius-md);
  box-shadow: var(--shadow-lg);
}
.consent-inner {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 16px 28px;
  max-width: var(--wrap);
  margin: 0 auto;
  padding: 20px 24px;
}
.consent h2 { font-size: 17px; margin-bottom: 4px; }
.consent p { margin: 0; font-size: 14.5px; max-width: 62ch; }
.consent-actions { display: flex; gap: 10px; flex-wrap: wrap; }

@media (max-width: 560px) {
  .consent-actions .btn { flex: 1; }
}

Token mapping, because the apps do not agree on names:

Website Pets
--panel --card-bg
--border --border-color
--wrap no equivalent — use 760px
--radius-md, --shadow-lg same names, both exist

z-index: 50 sits above the page and below nothing else on the Website. In Pets check it against the modal stack — Pets' own modals are higher, and the banner should not float over an open privacy modal it just opened.

The flex: 1 at 560px is what turns two buttons into two full-width halves on a phone. Without it they sit at content width in the corner, which on a 375px screen puts Decline somewhere around 60px wide.

7. Things that will otherwise cost an hour

8. What to check afterwards

Generated from docs/cookie-consent.md. Edit there and re-run npm run build in docs-site-src/ — never hand-edit a page.