feat: initial public release

ConsentOS — a privacy-first cookie consent management platform.

Self-hosted, source-available alternative to OneTrust, Cookiebot, and
CookieYes. Full standards coverage (IAB TCF v2.2, GPP v1, Google
Consent Mode v2, GPC, Shopify Customer Privacy API), multi-tenant
architecture with role-based access, configuration cascade
(system → org → group → site → region), dark-pattern detection in
the scanner, and a tamper-evident consent record audit trail.

This is the initial public release. Prior development history is
retained internally.

See README.md for the feature list, architecture overview, and
quick-start instructions. Licensed under the Elastic Licence 2.0 —
self-host freely; do not resell as a managed service.
This commit is contained in:
James Cottrill
2026-04-13 14:20:15 +00:00
commit fbf26453f2
341 changed files with 62807 additions and 0 deletions

836
apps/banner/src/banner.ts Normal file
View File

@@ -0,0 +1,836 @@
/**
* consent-bundle.js — Full consent banner UI with Shadow DOM isolation.
*
* Loaded async by consent-loader.js when no existing consent is found.
* Fetches site config, renders the banner, handles user interaction,
* records consent via the API.
*
* Enterprise features (A/B testing, GPP, GPC, profile sync, Shopify,
* re-consent) are loaded via the EE banner extension module when present.
*/
import { announce, createLiveRegion, focusFirst, onEscape, prefersReducedMotion, trapFocus } from './a11y';
import { updateAcceptedCategories } from './blocker';
import { buildConsentState, readConsent, writeConsent } from './consent';
import { buildGcmStateFromCategories, updateGcm } from './gcm';
import { type TranslationStrings, DEFAULT_TRANSLATIONS, detectLocale, interpolate, loadTranslations, renderLinks } from './i18n';
import type { BannerConfig, ButtonConfig, CategorySlug, SiteConfig } from './types';
// -- Preference-centre closure captured during init() ---------------------
/**
* Holds a closure that re-opens the banner for consent withdrawal.
* Populated during ``init()`` once config and translations are loaded,
* and invoked by ``window.ConsentOS.showPreferences()``. The floating
* "manage cookies" button also calls through this indirection so a
* single entry point keeps the behaviour consistent.
*/
let _openPreferences: (() => void) | null = null;
// -- EE extension hooks (no-ops in CE mode) ---------------------------------
/** Result from the A/B test assignment. */
interface ABAssignment {
abTestId: string;
variantId: string;
variant: { name: string };
}
/** Result from GPC evaluation. */
interface GpcResult {
detected: boolean;
honoured: boolean;
}
/** EE hooks that enterprise code can override at runtime. */
interface EEHooks {
applyABTest: (config: SiteConfig, visitorId: string) => { config: SiteConfig; assignment: ABAssignment | null };
needsReconsent: (consent: unknown, config: SiteConfig) => { required: boolean; reasons: string[] };
evaluateGpc: (config: SiteConfig, region: string | null) => GpcResult;
getVisitorRegion: () => string | null;
installGppApi: (cmpId: number, supportedApis: string[]) => void;
setGppDisplayStatus: (status: string) => void;
isGppApiInstalled: () => boolean;
updateGppConsent: (gpp: unknown) => string | undefined;
buildGppFromConsent: ((accepted: CategorySlug[], config: SiteConfig) => unknown) | null;
identifyUser: (jwt: string, config: SiteConfig) => Promise<string[]>;
clearIdentity: () => void;
isIdentified: () => boolean;
pushConsentToServer: (accepted: CategorySlug[], rejected: CategorySlug[], tc?: string, gpp?: string, gcm?: Record<string, string>) => void;
updateShopifyConsent: (accepted: CategorySlug[]) => void;
}
/** Default no-op hooks for CE mode. */
const _hooks: EEHooks = {
applyABTest: (config) => ({ config, assignment: null }),
needsReconsent: () => ({ required: false, reasons: [] }),
evaluateGpc: () => ({ detected: false, honoured: false }),
getVisitorRegion: () => null,
installGppApi: () => {},
setGppDisplayStatus: () => {},
isGppApiInstalled: () => false,
updateGppConsent: () => undefined,
buildGppFromConsent: null,
identifyUser: async () => [],
clearIdentity: () => {},
isIdentified: () => false,
pushConsentToServer: () => {},
updateShopifyConsent: () => {},
};
/**
* Register EE hooks. Called by the EE banner extension module.
* Exposed on `window.__consentos_hooks` for the EE bundle to call.
*/
export function registerEEHooks(hooks: Partial<EEHooks>): void {
Object.assign(_hooks, hooks);
}
// Expose for EE bundle
(window as any).__consentos_register_ee = registerEEHooks;
const ALL_CATEGORIES: CategorySlug[] = [
'necessary',
'functional',
'analytics',
'marketing',
'personalisation',
];
const NON_ESSENTIAL: CategorySlug[] = [
'functional',
'analytics',
'marketing',
'personalisation',
];
/** Initialise the banner. Called when the bundle loads. */
async function init(): Promise<void> {
const { siteId, apiBase, cdnBase } = window.__consentos;
if (!siteId) {
console.warn('[ConsentOS] No site ID configured');
return;
}
// Fetch site config — declared with let as A/B testing may replace it
let config: SiteConfig;
try {
const resp = await fetch(`${apiBase}/api/v1/config/sites/${siteId}`);
if (!resp.ok) throw new Error(`Config fetch failed: ${resp.status}`);
config = await resp.json();
} catch (err) {
console.error('[ConsentOS] Failed to load site config:', err);
config = buildDefaultConfig(siteId);
}
// Apply A/B test variant assignment (modifies banner_config if applicable)
const existingConsent = readConsent();
const visitorId = existingConsent?.visitorId ?? crypto.randomUUID?.() ?? String(Date.now());
const abResult = _hooks.applyABTest(config, visitorId);
config = abResult.config;
const abAssignment = abResult.assignment;
if (abAssignment) {
console.info(`[ConsentOS] A/B test assigned: variant "${abAssignment.variant.name}"`);
}
// Install the real CMP public API now that we have the config
installCmpApi(config);
// Check if existing consent needs re-consent. We still load
// translations and install the floating button even when no banner
// needs to show, so the visitor can re-open the preference centre
// at any time (GDPR Art. 7(3) — withdrawal must be as easy as
// giving consent).
let reconsentRequired = false;
if (existingConsent) {
const reconsent = _hooks.needsReconsent(existingConsent, config);
reconsentRequired = reconsent.required;
if (reconsent.required) {
console.info('[ConsentOS] Re-consent required:', reconsent.reasons.join(', '));
}
}
// Install GPP API if enabled
if (config.gpp_enabled) {
_hooks.installGppApi(0, config.gpp_supported_apis ?? []);
_hooks.setGppDisplayStatus('visible');
}
// Evaluate GPC signal
const visitorRegion = _hooks.getVisitorRegion();
const gpcResult = _hooks.evaluateGpc(config, visitorRegion);
if (gpcResult.detected) {
console.info(`[ConsentOS] GPC signal detected (honoured: ${gpcResult.honoured})`);
}
// Load translations
const locale = detectLocale();
const t = await loadTranslations(cdnBase, locale);
// Capture a closure that re-opens the banner with current consent
// pre-filled. Called from the floating button and from
// ``window.ConsentOS.showPreferences()``.
_openPreferences = () => {
removePreferencesButton();
const current = readConsent();
renderBanner(config, t, gpcResult, abAssignment, {
prefillCategories: current?.accepted ?? null,
showCategoriesInitially: true,
});
};
if (existingConsent && !reconsentRequired) {
// Banner isn't shown; expose the floating button straight away.
showPreferencesButton(config, t);
return;
}
// First-visit or re-consent: render the banner itself.
renderBanner(config, t, gpcResult, abAssignment);
}
/**
* Install the real window.ConsentOS API, replacing the loader stubs.
*
* `identifyUser(jwt)` syncs consent with the server. If the server profile
* fully covers all categories, the banner is suppressed. If categories are
* missing, only those categories need consent from the user.
*/
function installCmpApi(config: SiteConfig): void {
window.ConsentOS = {
identifyUser: async (jwt: string): Promise<string[]> => {
const missing = await _hooks.identifyUser(jwt, config);
return missing;
},
clearIdentity: (): void => {
_hooks.clearIdentity();
},
showPreferences: (): void => {
if (_openPreferences) {
_openPreferences();
} else {
console.warn('[ConsentOS] showPreferences called before init complete');
}
},
};
}
/** Build a default config when the API is unreachable. */
function buildDefaultConfig(siteId: string): SiteConfig {
return {
id: '',
site_id: siteId,
blocking_mode: 'opt_in',
regional_modes: null,
tcf_enabled: false,
gpp_enabled: false,
gpp_supported_apis: [],
gpc_enabled: true,
gpc_jurisdictions: [],
gpc_global_honour: false,
gcm_enabled: true,
gcm_default: null,
shopify_privacy_enabled: false,
banner_config: null,
privacy_policy_url: null,
terms_url: null,
consent_expiry_days: 365,
consent_group_id: null,
ab_test: null,
initiator_map: null,
};
}
/** Options for re-opening the banner from the preferences button. */
interface OpenOptions {
/** Pre-check these category slugs (skips strict-necessary which is always on). */
prefillCategories: CategorySlug[] | null;
/** Open the banner with the category toggles visible. */
showCategoriesInitially: boolean;
}
/** Create a Shadow DOM host and render the banner inside it. */
function renderBanner(
config: SiteConfig,
t: TranslationStrings,
gpcResult?: GpcResult,
abAssignment?: ABAssignment | null,
openOptions?: OpenOptions,
): void {
const host = document.createElement('div');
host.id = 'consentos-banner-host';
const shadow = host.attachShadow({ mode: 'open' });
const titleId = 'cmp-title';
const descId = 'cmp-desc';
shadow.innerHTML = `
<style>${getBannerStyles(config)}</style>
<div class="consentos-banner" role="dialog" aria-label="${t.title}" aria-labelledby="${titleId}" aria-describedby="${descId}" aria-modal="true">
<div class="consentos-banner__content">
<div class="consentos-banner__text">
<p class="consentos-banner__title" id="${titleId}">${t.title}</p>
<p class="consentos-banner__description" id="${descId}">
${renderDescription(t.description, config)}
</p>
</div>
<div class="consentos-banner__categories" id="consentos-categories" role="group" aria-label="${t.managePreferences}">
${renderCategories(t)}
</div>
<div class="consentos-banner__actions" role="group" aria-label="Consent actions">
<button class="cmp-btn cmp-btn--secondary" data-action="reject" type="button">
${t.rejectAll}
</button>
<button class="cmp-btn cmp-btn--secondary" data-action="settings" type="button" aria-expanded="false" aria-controls="consentos-categories">
${t.managePreferences}
</button>
<button class="cmp-btn cmp-btn--primary" data-action="accept" type="button">
${t.acceptAll}
</button>
</div>
</div>
</div>
`;
// Attach event listeners
const banner = shadow.querySelector('.consentos-banner') as HTMLElement;
const categoriesDiv = shadow.querySelector('#consentos-categories') as HTMLElement;
const settingsBtn = shadow.querySelector('[data-action="settings"]') as HTMLElement;
// Hide or show the category toggles depending on entry mode.
// Opening via ``showPreferences`` lands directly on the toggles.
const startWithCategories = openOptions?.showCategoriesInitially === true;
categoriesDiv.style.display = startWithCategories ? 'block' : 'none';
settingsBtn.setAttribute('aria-expanded', startWithCategories ? 'true' : 'false');
// Pre-fill category checkboxes from existing consent when re-opened.
if (openOptions?.prefillCategories) {
const prefill = new Set(openOptions.prefillCategories);
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
const slug = input.getAttribute('data-category') as CategorySlug;
if (slug === 'necessary') return; // always on + disabled
input.checked = prefill.has(slug);
});
}
// Create live region for screen reader announcements
const liveRegion = createLiveRegion(shadow);
// Set up keyboard navigation
const cleanupFocusTrap = trapFocus(banner);
const cleanupEscape = onEscape(banner, () => {
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
});
shadow.querySelectorAll('[data-action]').forEach((btn) => {
btn.addEventListener('click', (e) => {
const action = (e.currentTarget as HTMLElement).getAttribute('data-action');
if (action === 'accept') {
// Explicit Accept All overrides GPC — user choice takes precedence
handleConsent(ALL_CATEGORIES, [], config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
} else if (action === 'reject') {
handleConsent(['necessary'], NON_ESSENTIAL, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
} else if (action === 'settings') {
const isHidden = categoriesDiv.style.display === 'none';
categoriesDiv.style.display = isHidden ? 'block' : 'none';
settingsBtn.setAttribute('aria-expanded', isHidden ? 'true' : 'false');
announce(liveRegion, isHidden ? t.managePreferences : t.title);
} else if (action === 'save') {
const accepted = getSelectedCategories(shadow);
const rejected = NON_ESSENTIAL.filter((c) => !accepted.includes(c));
handleConsent(accepted, rejected, config, gpcResult, abAssignment, t);
removeBanner(host, cleanupFocusTrap, cleanupEscape);
}
});
});
document.body.appendChild(host);
// Move focus into the banner for keyboard users
focusFirst(banner);
}
/** Render category toggles HTML. */
function renderCategories(t: TranslationStrings): string {
const categories = [
{ slug: 'necessary', name: t.categoryNecessary, desc: t.categoryNecessaryDesc, locked: true },
{ slug: 'functional', name: t.categoryFunctional, desc: t.categoryFunctionalDesc, locked: false },
{ slug: 'analytics', name: t.categoryAnalytics, desc: t.categoryAnalyticsDesc, locked: false },
{ slug: 'marketing', name: t.categoryMarketing, desc: t.categoryMarketingDesc, locked: false },
{ slug: 'personalisation', name: t.categoryPersonalisation, desc: t.categoryPersonalisationDesc, locked: false },
];
return (
categories
.map(
(cat) => `
<label class="cmp-category">
<div class="cmp-category__info">
<span class="cmp-category__name" id="cmp-cat-${cat.slug}">${cat.name}</span>
<span class="cmp-category__desc" id="cmp-cat-${cat.slug}-desc">${cat.desc}</span>
</div>
<input type="checkbox" data-category="${cat.slug}"
aria-labelledby="cmp-cat-${cat.slug}"
aria-describedby="cmp-cat-${cat.slug}-desc"
${cat.locked ? 'checked disabled' : ''}
/>
</label>
`
)
.join('') +
`<button class="cmp-btn cmp-btn--primary cmp-btn--save" data-action="save" type="button">
${t.savePreferences}
</button>`
);
}
/** Read which categories are checked in the shadow DOM. */
function getSelectedCategories(shadow: ShadowRoot): CategorySlug[] {
const checked: CategorySlug[] = ['necessary'];
shadow.querySelectorAll<HTMLInputElement>('input[data-category]').forEach((input) => {
if (input.checked) {
checked.push(input.getAttribute('data-category') as CategorySlug);
}
});
return [...new Set(checked)];
}
/** Handle a consent decision: write cookie, update GCM, GPP, post to API, dispatch event. */
function handleConsent(
accepted: CategorySlug[],
rejected: CategorySlug[],
config: SiteConfig,
gpcResult?: GpcResult,
abAssignment?: ABAssignment | null,
t?: TranslationStrings,
): void {
const existing = readConsent();
const gcmState = buildGcmStateFromCategories(accepted);
// Generate GPP string if GPP is enabled
let gppString: string | undefined;
if (config.gpp_enabled && _hooks.isGppApiInstalled() && _hooks.buildGppFromConsent) {
const gpp = _hooks.buildGppFromConsent(accepted, config);
gppString = _hooks.updateGppConsent(gpp);
_hooks.setGppDisplayStatus('hidden');
}
const state = buildConsentState(
accepted,
rejected,
existing?.visitorId,
undefined,
gcmState,
config.id,
gppString,
gpcResult?.detected,
gpcResult?.honoured,
);
// Write first-party cookie
writeConsent(state, config.consent_expiry_days);
// Release blocked scripts for accepted categories
updateAcceptedCategories(accepted);
// Update Google Consent Mode
if (config.gcm_enabled) {
updateGcm(gcmState);
}
// Update Shopify Customer Privacy API
if (config.shopify_privacy_enabled) {
_hooks.updateShopifyConsent(accepted);
}
// Post consent to API (fire and forget)
const { siteId, apiBase } = window.__consentos;
fetch(`${apiBase}/api/v1/consent/`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
site_id: siteId,
visitor_id: state.visitorId,
action: determineAction(accepted, rejected),
categories_accepted: accepted,
categories_rejected: rejected,
gcm_state: gcmState,
gpc_detected: gpcResult?.detected ?? false,
gpc_honoured: gpcResult?.honoured ?? false,
page_url: window.location.href,
ab_test_id: abAssignment?.abTestId ?? null,
ab_variant_id: abAssignment?.variantId ?? null,
}),
}).catch((err) => console.warn('[ConsentOS] Failed to record consent:', err));
// Push to server if user is identified (non-blocking background sync)
if (_hooks.isIdentified()) {
_hooks.pushConsentToServer(accepted, rejected, undefined, gppString, gcmState);
}
// Dispatch event
document.dispatchEvent(
new CustomEvent('consentos:consent-change', { detail: { accepted } })
);
if (typeof window.dataLayer !== 'undefined') {
window.dataLayer.push({
event: 'consentos_consent_change',
cmp_accepted_categories: accepted,
});
}
// Bridge for the standalone ConsentOS GTM template — when the
// template is loaded on the page it registers a global callback so
// it can react to consent changes. Lives in its own repo.
if (typeof (window as any).__consentos_gtm_consent_update === 'function') {
(window as any).__consentos_gtm_consent_update({ accepted });
}
// Re-expose the floating preferences button so the visitor can
// withdraw or change their decision later.
if (t) {
showPreferencesButton(config, t);
}
}
/**
* Render the banner description with template variables and markdown links.
*
* Replaces `{{privacy_policy}}` and `{{terms}}` with their URLs from config,
* then converts `[text](url)` markdown links to `<a>` tags.
* Links with empty URLs (because the config field is unset) are removed.
*/
function renderDescription(description: string, config: SiteConfig): string {
const rendered = interpolate(description, {
privacy_policy: config.privacy_policy_url ?? '',
terms: config.terms_url ?? '',
});
return renderLinks(rendered);
}
/** Determine the consent action string. */
function determineAction(
accepted: CategorySlug[],
rejected: CategorySlug[]
): string {
if (rejected.length === 0) return 'accept_all';
if (accepted.length === 1 && accepted[0] === 'necessary') return 'reject_all';
return 'custom';
}
/** Remove the banner from the DOM. */
function removeBanner(
host: HTMLElement,
...cleanups: Array<() => void>
): void {
cleanups.forEach((fn) => fn());
const useMotion = !prefersReducedMotion();
if (useMotion) {
host.style.opacity = '0';
host.style.transition = 'opacity 0.3s ease';
setTimeout(() => host.remove(), 300);
} else {
host.remove();
}
}
// -- Floating "manage preferences" button ---------------------------------
const _PREFERENCES_BUTTON_ID = 'cmp-preferences-button';
/** Remove the floating preferences button if present. */
function removePreferencesButton(): void {
const existing = document.getElementById(_PREFERENCES_BUTTON_ID);
if (existing) {
existing.remove();
}
}
/**
* Render a persistent floating button that re-opens the banner.
*
* Required by GDPR Art. 7(3) — withdrawing consent must be as easy
* as giving it. Positioned opposite the banner's corner by default
* so it doesn't sit behind the initial banner if displayed together.
*/
function showPreferencesButton(config: SiteConfig, t: TranslationStrings): void {
removePreferencesButton();
// Honour the site's opt-out: operators can disable the floating
// button via ``banner_config.show_preferences_button = false``.
const bc = config.banner_config ?? null;
if (bc && (bc as Record<string, unknown>).show_preferences_button === false) {
return;
}
const position =
(bc as Record<string, unknown> | null)?.preferences_button_position === 'left'
? 'left: 20px;'
: 'right: 20px;';
const host = document.createElement('div');
host.id = _PREFERENCES_BUTTON_ID;
const shadow = host.attachShadow({ mode: 'open' });
const label =
t.managePreferences || 'Cookie preferences';
shadow.innerHTML = `
<style>
:host {
position: fixed;
bottom: 20px;
${position}
z-index: 2147483646;
}
button {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 0.9rem;
background: #111;
color: #fff;
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 999px;
font: 500 0.85rem system-ui, -apple-system, sans-serif;
cursor: pointer;
box-shadow: 0 2px 12px rgba(0, 0, 0, 0.2);
}
button:hover { opacity: 0.92; }
button:focus-visible {
outline: 2px solid #4a90e2;
outline-offset: 2px;
}
svg { width: 1rem; height: 1rem; flex-shrink: 0; }
@media (prefers-reduced-motion: no-preference) {
button { transition: transform 0.15s ease; }
button:hover { transform: translateY(-1px); }
}
</style>
<button type="button" aria-label="${label}" title="${label}">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="12" cy="12" r="10"/>
<path d="M8.5 8.5v.01"/>
<path d="M16 15.5v.01"/>
<path d="M12 12v.01"/>
<path d="M11 17v.01"/>
<path d="M7 14v.01"/>
</svg>
<span>${label}</span>
</button>
`;
const btn = shadow.querySelector('button') as HTMLButtonElement;
btn.addEventListener('click', () => {
if (_openPreferences) {
_openPreferences();
}
});
document.body.appendChild(host);
}
/** Resolve position CSS for the banner based on display mode. */
function getPositionCss(bc: BannerConfig | null): string {
const mode = bc?.displayMode ?? 'bottom_banner';
const radius = bc?.borderRadius ?? 6;
const cornerPos = bc?.cornerPosition ?? 'right';
switch (mode) {
case 'top_banner':
return 'position: fixed; top: 0; left: 0; right: 0; z-index: 2147483647;';
case 'overlay':
return `position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 2147483647; width: 90%; max-width: 600px; border-radius: ${radius}px;`;
case 'corner_popup': {
const side = cornerPos === 'left' ? 'left: 20px;' : 'right: 20px;';
return `position: fixed; bottom: 20px; ${side} z-index: 2147483647; width: 380px; max-width: calc(100% - 40px); border-radius: ${radius}px;`;
}
case 'bottom_banner':
default:
return 'position: fixed; bottom: 0; left: 0; right: 0; z-index: 2147483647;';
}
}
/** Resolve per-button inline style from ButtonConfig. */
function getButtonCss(
btnCfg: ButtonConfig | undefined,
fallbackBg: string,
fallbackColor: string,
fallbackBorder: string,
radius: number,
): string {
const style = btnCfg?.style;
const bg = style === 'text' || style === 'outline'
? 'transparent'
: btnCfg?.backgroundColour ?? fallbackBg;
const color = btnCfg?.textColour ?? fallbackColor;
const border = btnCfg?.borderColour
? `1px solid ${btnCfg.borderColour}`
: style === 'outline'
? `1px solid ${color}`
: style === 'text'
? 'none'
: fallbackBorder;
return `background: ${bg}; color: ${color}; border: ${border}; border-radius: ${radius}px;`;
}
/** Banner CSS — isolated inside Shadow DOM. */
function getBannerStyles(config: SiteConfig): string {
const bc = config.banner_config;
const bg = bc?.backgroundColour ?? '#ffffff';
const text = bc?.textColour ?? '#0E1929'; // ConsentOS Ink
const primary = bc?.primaryColour ?? '#2C6AE4'; // ConsentOS Action Blue
const font = bc?.fontFamily ?? '-apple-system, BlinkMacSystemFont, \'Segoe UI\', Roboto, sans-serif';
const radius = bc?.borderRadius ?? 6;
const mode = bc?.displayMode ?? 'bottom_banner';
const acceptCss = getButtonCss(bc?.acceptButton, primary, '#ffffff', 'none', radius);
const rejectCss = getButtonCss(bc?.rejectButton, 'transparent', text, '1px solid rgba(0,0,0,0.2)', radius);
const manageCss = getButtonCss(bc?.manageButton, 'transparent', text, '1px solid rgba(0,0,0,0.2)', radius);
return `
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
.consentos-banner {
${getPositionCss(bc)}
background: ${bg};
color: ${text};
font-family: ${font}, sans-serif;
font-size: 14px;
line-height: 1.5;
box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.15);
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: ${mode === 'overlay' || mode === 'corner_popup' ? radius + 'px' : '0'};
}
.consentos-banner__content {
max-width: 1200px;
margin: 0 auto;
padding: 20px 24px;
}
.consentos-banner__title {
font-size: 16px;
font-weight: 600;
margin-bottom: 8px;
}
.consentos-banner__description {
margin-bottom: 16px;
opacity: 0.85;
}
.consentos-banner__link {
color: ${primary};
text-decoration: underline;
}
.consentos-banner__actions {
display: flex;
gap: 10px;
flex-wrap: wrap;
}
.cmp-btn {
padding: 10px 20px;
border-radius: ${radius}px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
border: none;
transition: opacity 0.2s;
font-family: inherit;
}
.cmp-btn:hover { opacity: 0.9; }
.cmp-btn:focus-visible {
outline: 2px solid ${primary};
outline-offset: 2px;
}
.cmp-btn--primary { ${acceptCss} }
.cmp-btn--secondary[data-action="reject"] { ${rejectCss} }
.cmp-btn--secondary[data-action="settings"] { ${manageCss} }
.cmp-btn--secondary { ${rejectCss} }
.consentos-banner__categories {
margin-bottom: 16px;
}
.cmp-category {
display: flex;
align-items: center;
justify-content: space-between;
padding: 10px 0;
border-bottom: 1px solid rgba(0, 0, 0, 0.08);
cursor: pointer;
}
.cmp-category__info {
display: flex;
flex-direction: column;
flex: 1;
margin-right: 12px;
}
.cmp-category__name {
font-weight: 500;
}
.cmp-category__desc {
font-size: 12px;
opacity: 0.7;
}
.cmp-category input[type="checkbox"] {
width: 18px;
height: 18px;
accent-color: ${primary};
}
.cmp-btn--save {
margin-top: 12px;
width: 100%;
}
/* Visually hidden but accessible to screen readers */
.cmp-sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
@media (max-width: 640px) {
.consentos-banner__actions {
flex-direction: column;
}
.cmp-btn {
width: 100%;
text-align: center;
}
}
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 0s !important;
animation-duration: 0s !important;
}
}
`;
}
// Auto-init on load
init();