Cookie-consent component
components specs/components/cookie-consent.kmd
The tracking-consent surface: a first-tier bar offering accept/reject with equal prominence, and a second tier for per-category granularity. Its semantics — the record shape, the categories, opt-in default-deny, re-show conditions, the gating API — are NOT defined here; they live in `privacy/consent-record.kmd`, and this spec is the component layer over them (anatomy, tiers, geometry per active DS, focus, i18n, and the test that proves the choice is enforced). Shipped for web as `<koder-cookie-banner>` in `koder_web_kit`; gov.br ships `.br-cookiebar` (LGPD).
When this spec applies
Primary triggers
- Render a cookie / tracking consent surface
All triggers
- Add a cookie / tracking consent banner to a Koder surface
- Ask a visitor to accept or reject analytics / marketing (LGPD / GDPR)
- Render the 'manage preferences' tier of a consent UI
Specification body
Component — Cookie consent
Status: v0.1.0 — Draft. Opened from the KDS × gov.br gap analysis (
tools/design-gen/docs/govbr-ds-gap-analysis.md, stack#394). Anatomy below is MEASURED from@govbr-ds/corev3.7.0dist/core.min.css(.br-cookiebar, 60 occurrences), and cross-read against the shipped Koder reference implementation (koder_web_kit<koder-cookie-banner>+ its Playwright test), so it describes what exists rather than what a cookie bar is imagined to be.
The gap this spec closes is narrower than it looks
The gap analysis recorded "gov.br tem cookiebar; KDS não tem". That is false for
web and was already false when it was written: privacy/consent-record.kmd
(2026-06-02) defines the contract, and koder_web_kit ships KoderConsent +
<koder-cookie-banner> — two-tier, localized, tested, and deployed on the Koder
sites (meta/sites/areas/*/_kit/). What was actually missing is this file: a
component contract, so a non-web surface has something to implement against and
a reviewer has something to judge against.
Everything about consent as data therefore stays in consent-record.kmd and is
not restated here (reuse-first.kmd): the record shape, the category set,
opt-in/default-deny (its R1), reject-as-easy-as-accept (its R2), the re-show
conditions (R3), the optional audit POST (R4), the allows() gating API (R5).
Duplicating those would create two sources of truth for a legal obligation,
which is the one place drift is least affordable.
R1 — The gate is the component; the banner is its surface
A consent UI whose answer nothing consults MUST NOT ship. This is the rule the rest of the component hangs on.
A banner that renders, records a choice, and reports success while collection proceeds regardless is not a partial implementation — it is worse than shipping no banner at all. Absent a banner, a surface is visibly non-compliant and someone fixes it. With a decorative one, the surface manufactures evidence that the user was asked and answered, and both the user and the operator believe a choice is being honoured that is not. It fails LGPD Art. 8 (consent must be informed and specific) precisely by looking like it satisfies it.
So a surface claims this component only when a real collector reads
consent-record.kmd R5's gate before collecting. A surface with no gated
collector does not need this component and MUST NOT render it — asking for
consent that governs nothing is its own dark pattern.
Consequence, stated so nobody has to infer it: this component is not implementable on a surface that has no consent store and no gated collector, and the honest response there is to build the gate first (see Native surfaces below).
R2 — Two tiers, one component
Measured: gov.br models both tiers in a single component, switched by one class.
| Tier | gov.br | Anatomy |
|---|---|---|
First (.br-cookiebar.default) | top: unset → pinned to bottom: 0, full width | .info-text shown; .entry-content, .main-content, .complementary-content hidden. Actions: accept + reject + manage. |
Second (.br-cookiebar, no .default) | position: fixed, top/right/bottom/left: 0 — the whole viewport | Nests .br-modal: header + title + close, body listing the categories, footer actions. |
Both sit at z-index: --z-index-layer-4 under --surface-shadow-lg-up. The second
tier is a modal (dialogs.kmd): it traps focus and is dismissible; the first
tier is not a modal — it must never trap focus, because a bar the user has not
answered must not hold the page hostage.
The first tier's three actions carry equal weight by consent-record.kmd R2; that
rule is normative there, and this spec's only addition is where they sit: all
three in the same action row, no scroll, no disclosure.
R3 — Categories render as switches, necessary as inert
Per-category controls are switch.kmd (immediate effect, no submit),
one per category from consent-record.kmd's set. The reserved necessary
category renders visibly present and non-interactive (gov.br: .always-active)
— never hidden, never a switch the user can move and watch snap back. It is stated,
not offered; a control that silently refuses input teaches the user their input is
being ignored, which is exactly what the rest of the dialog is trying to disprove.
R4 — Geometry + colour per active design system
Chrome geometry comes from the active DS (theme::Shape, kroma#278) and tones
from its Palette (kroma#194), per design-RFC-014 — not from constants copied out
of gov.br's CSS. A gov.br surface then gets gov.br's own radii (square) and a Verge
surface gets Verge's, from the same component.
R5 — Accessibility
- The first tier is a
banners.kmdregion, announced politely — it is not an alert and must not interrupt a screen reader mid-sentence. - The second tier follows
dialogs.kmd: focus moves in on open, is trapped while open, and returns to the invoking control on close. - Every action is a real, translated sentence,
en-US+pt-BRfloor (i18n/contract.kmd) — the shipped web banner is already en/pt/es. - Reject must be reachable by keyboard in the same number of stops as accept. A
reject that costs more keystrokes than accept fails
consent-record.kmdR2 for keyboard users specifically, which is the group least likely to be measured.
R6 — Test
A surface claiming this component MUST prove, headlessly:
- Collection is gated — the collector does not collect before a choice exists, and does collect only the categories opted into. This is the negative control for R1: a decorative banner passes every other assertion here and fails only this one. Assert it against the collector, never against the banner's own state — a banner agreeing with itself proves nothing.
necessaryis allowed with no record; every other category is denied (this isconsent-record.kmdR1 observed through the component).- Reject and accept are both reachable, in the same number of keyboard stops.
- A recorded choice survives a reload and the first tier does not re-appear — and it does re-appear on each R3 re-show condition.
- The second tier returns focus to its invoker on close.
The shipped web reference (koder_web_kit/test/cookie-banner.test.mjs) covers
2 and 4 today against a real browser. (1) is the one to add: it is the only
assertion that can tell a working consent surface from a decorative one, and no
current test makes it.
Native surfaces (Kroma) — what must exist first
koder-app/behaviors.kmd §2 mandates telemetry from every Koder app and says
nothing about consent, and no native SDK has a KoderConsent counterpart — so a
native app today collects with no gate to consult. A Kroma banner built now would
therefore be exactly the R1 failure this spec forbids: a surface that records a
choice nothing enforces.
The order is fixed by R1, not by convenience: a native consent store + a telemetry client that gates on it, and only then the component. Tracked as kroma#280.
Cross-link
specs/privacy/consent-record.kmd— the semantics: record, categories, R1–R5.specs/components/banners.kmd— the first tier's region + re-show discipline.specs/components/dialogs.kmd— the second tier's focus contract.specs/components/switch.kmd— the per-category control.policies/identity-data-retention.kmd— retention of any server-side audit copy.
Requirements (testable)
Requirement: Consent gates real collection {#req-cookie-consent-gated-collection}
A surface claiming this component SHALL gate a real collector on the consent record before collecting, SHALL collect only the categories opted into, and SHALL NOT render the banner on any surface that has no consent store and no gated collector.
Scenario: No collection before a choice exists
- GIVEN a surface with the cookie-consent banner rendered and no consent record yet stored
- WHEN the page loads and the banner is displayed but no accept/reject choice has been made
- THEN the gated collector performs no collection for any non-
necessarycategory - AND the collector records zero collection events even though the banner is present on the page
Scenario: Only opted-in categories are collected
- GIVEN a visitor who has accepted the
analyticscategory and rejectedmarketing - WHEN collection runs after the choice is recorded
- THEN the collector collects data for
analytics - AND the collector collects no data for
marketing
Scenario: Banner is absent where nothing is gated
- GIVEN a surface with no consent store and no gated collector
- WHEN the surface renders
- THEN the cookie-consent component is not rendered
Requirement: Two tiers with correct modality {#req-cookie-consent-two-tiers}
The component SHALL present a first tier that is a non-modal banner region carrying accept, reject, and manage actions in one action row, and a second tier that is a modal dialog listing per-category controls; the first tier SHALL NOT trap focus and the second tier SHALL trap focus while open.
Scenario: First tier does not trap focus
- GIVEN the first-tier consent bar is shown and the user has not answered
- WHEN the user presses Tab past the bar's actions
- THEN focus moves to page content behind the bar
- AND focus is not held within the bar
Scenario: First tier exposes all three actions in one row
- GIVEN the first-tier consent bar is shown
- WHEN its action row is inspected
- THEN accept, reject, and manage actions are all present in the same action row
- AND no disclosure or scroll is required to reach any of them
Scenario: Second tier traps focus while open
- GIVEN the second-tier preferences dialog is open
- WHEN the user tabs through the last focusable control in the dialog
- THEN focus wraps to a control inside the dialog
- AND focus does not leave the dialog while it is open
Requirement: Categories render as switches with necessary inert {#req-cookie-consent-category-controls}
The second tier SHALL render one switch per consent category with immediate effect and no submit, and SHALL render the reserved necessary category visibly present and non-interactive rather than as a movable switch or a hidden control.
Scenario: A category switch takes effect immediately
- GIVEN the preferences dialog is open with the
analyticscategory off - WHEN the user toggles the
analyticsswitch on - THEN the
analyticsconsent takes effect without any submit action - AND the recorded consent for
analyticsreads allowed
Scenario: Necessary category is present but not interactive
- GIVEN the preferences dialog is open
- WHEN the
necessarycategory control is inspected - THEN it is visibly present in the category list
- AND it cannot be toggled off by the user
Requirement: Accessible and keyboard-equal choice {#req-cookie-consent-accessibility}
The component SHALL announce the first tier politely (not as an interrupting alert), SHALL make reject reachable by keyboard in the same number of stops as accept, and SHALL return focus to the invoking control when the second tier closes.
Scenario: Reject costs no more keyboard stops than accept
- GIVEN the first-tier consent bar is shown and focus enters the bar
- WHEN the user tabs to the accept action and, separately, to the reject action
- THEN the number of keyboard stops to reach reject equals the number to reach accept
Scenario: First tier is announced politely
- GIVEN a screen reader is active when the first-tier bar renders
- WHEN the bar appears
- THEN it is announced via a polite live region
- AND it does not interrupt the screen reader mid-sentence as an alert would
Scenario: Second tier returns focus to its invoker
- GIVEN the second-tier preferences dialog was opened from the manage action
- WHEN the user closes the dialog
- THEN focus returns to the manage control that invoked it
Requirement: Recorded choice persists and re-shows correctly {#req-cookie-consent-persistence}
A recorded choice SHALL survive a page reload without re-showing the first tier, and the first tier SHALL re-appear on each re-show condition defined by the consent record.
Scenario: Choice survives reload without re-prompting
- GIVEN a visitor who has recorded a consent choice
- WHEN the page is reloaded
- THEN the stored choice is still in effect
- AND the first-tier bar does not re-appear
Scenario: First tier re-appears on a re-show condition
- GIVEN a stored consent choice that has become stale per a consent-record re-show condition
- WHEN the surface loads
- THEN the first-tier bar re-appears to request consent again
References
specs/privacy/consent-record.kmdspecs/components/banners.kmdspecs/components/dialogs.kmdspecs/components/switch.kmdspecs/i18n/contract.kmdpolicies/identity-data-retention.kmd