Layout Components
We use essential cookies to keep you signed in. No tracking, no third parties.
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 looks — index.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:
- Connections loads no analytics and sets no cookies at all. Its server
comments say so outright (
server/src/app.js— "there are no cookies here"). A banner there would ask consent for nothing, which is worse than no banner: it trains people to dismiss a dialog that never mattered. Do not add one until Connections actually loads a tag. If it ever does, this document is what to implement. - Accounts' session cookie is strictly necessary — it is what keeps you logged in, which is the service the user asked for. PECR reg. 6(4) exempts it and no consent is required. Accounts needs a banner only if it ever adds analytics of its own.
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:
- The GTM container
<script>is created at consent time, never in the served HTML. - The standard GTM
<noscript>iframe is deliberately absent in every app. It loads unconditionally with no consent check, which defeats the entire design. Both sites requiring JS anyway, nothing is lost. - The banner is not a cookie wall. It is
position: fixedat the bottom,aria-modal="false", and the page behind it stays usable. ICO guidance is explicit that access must not be conditional on making a choice.
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 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 notice</button>
: <span>privacy 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:
- Necessary renders as a checked, disabled checkbox. It is not a choice, and showing it as one that happens to be locked is clearer than omitting the category and leaving people to wonder what else is set.
Save preferencesonly appears once expanded, replacingManage preferencesin the same slot. Three buttons before the user has asked for detail is the clutter the expansion exists to avoid.
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
- Do not add the
<noscript>GTM iframe back. Every tutorial includes it, it is the first thing a linter or a Tag Assistant check will suggest, and it loads with no consent check. Its absence is the point. Both apps carry a comment saying so — leave the comment. insetshorthand is tempting and wrong here.left/right/bottomwith notopis what keeps the bar the height of its content;inset: 16pxpins it to the top too and it becomes a full-height panel.- Each app has its own GTM container. Website's is hardcoded
(
GTM-MXHG6VMPinmain.js); Pets' arrives from/api/configasgtmContainerId. Do not share a container between properties — they report separately on purpose. - In Pets the banner renders in seven places in
App.jsx(shared-pet view, login, public profile, directory, logged-out, logged-in, …). Changing the props means changing all of them.
8. What to check afterwards
- Fresh profile: banner appears, and the Network tab shows no request to
googletagmanager.combefore a choice is made. - Decline: banner closes, still no GTM request,
localStorage.cookieConsenthasanalytics: false,Cookie settingsappears in the footer. - Accept: GTM request fires,
analytics: truestored. - Accept, then reopen and Decline: a
consentupdatepush appears on the dataLayer — not a seconddefault. This is §3, and it is the check that catches the bug. - Version bump: set
CONSENT_VERSIONto2in a scratch build, reload with a stored v1 decision, confirm the banner returns. - Storage disabled: banner shows, both buttons work, nothing throws.
- 375px: Decline and Accept each take half the width, both at least 40px tall.
- Keyboard: Tab reaches Decline before Accept, both activate on Enter and Space, focus is visible on each.
- Manage preferences: expands to Necessary (checked, disabled) and Analytics
(togglable);
Save preferencesstores exactly the toggle state, not Accept-all. Decline and Accept are still visible and still equally weighted while it is open. - In Pets, every theme: the bar reads against all nine, not just the default.
Generated from docs/cookie-consent.md. Edit there and re-run npm run build in docs-site-src/ — never hand-edit a page.