would be function-scoped and the cta_click tracker below
// (which guards on
typeof gtag) would never fire. On live (Webflow) this
// snippet is top-level, making gtag global — window.gtag restores that.
window.dataLayer = window.dataLayer || [];
window.gtag = function(){dataLayer.push(arguments);};
// Consent Mode v2 defaults. MUST be queued before gtag('config') — Google processes
// dataLayer in order and ignores a 'default' that arrives after config, so flipping
// these two lines silently disables consent mode rather than erroring.
//
// Everything starts denied. CookieConsent.astro resolves the tier and pushes the
// 'update' → granted. Starting denied and upgrading is always safe; the reverse leaks.
gtag('consent', 'default', {
ad_storage: 'denied',
ad_user_data: 'denied',
ad_personalization: 'denied',
analytics_storage: 'denied',
functionality_storage: 'denied',
personalization_storage: 'denied',
security_storage: 'granted'
});
// ⚠️ gtag('js') + gtag('config') are DELIBERATELY NOT called here.
//
// gtag.js drains dataLayer in order, and 'config' is what fires the first page_view.
// Pushing config synchronously here puts it AHEAD of the async consent update (which
// can only happen after the geo lookup), so the session-establishing hit goes out under
// the denied default. Verified on the wire before this was fixed: the first collect had
// gcs=G100 and only the follow-up user_engagement hit carried gcs=G111&gcut=3.
//
// Instead __rkApplyConsent() calls this once analytics consent is known to be granted,
// guaranteeing the queue order default → update → config for every path (Standard on
// boot, Strict on Accept, and either tier restoring a stored grant). When consent is
// denied we never call it at all — gtag.js is gated and never loads, so there is nothing
// to configure. Idempotent: safe to call on every consent transition.
var gtagStarted = false;
var gtagConfigured = false;
// deferConfig=true pushes gtag('js') but HOLDS gtag('config') — the call that fires the first
// page_view — until __rkGtagConfig() runs on gtag.js's own load event. The Standard direct-
// activation path uses this so a Reject that lands while gtag.js is still downloading is queued
// AHEAD of config, and the page_view then carries the denied signals instead of granted. Strict
// (Finsweet-activated) keeps the eager path: gtag.js only loads post-Accept, so config is
// legitimately granted and there is no in-flight-Reject window to lose.
window.__rkGtagConfig = function () {
if (gtagConfigured) return;
gtagConfigured = true;
gtag('config', GA_ID);
};
window.__rkGtagInit = function (deferConfig) {
if (gtagStarted) return;
gtagStarted = true;
gtag('js', new Date());
if (!deferConfig) window.__rkGtagConfig();
};
})(); does not exist in the bundle — an earlier
// draft called opt_out_capturing({clear_persistence:true}), which is silently ignored.
// Stopping capture, clearing stored state and disabling future persistence are three
// separate calls.
(function () {
'use strict';
function withPostHog(fn) {
// The grant signal can fire before Finsweet has activated the PostHog snippet, so
// latch and replay rather than assuming it exists.
var tries = 0;
(function attempt() {
if (window.posthog && window.posthog.__loaded) { try { fn(window.posthog); } catch (e) {} return; }
if (++tries > 40) return; // ~4s, then give up quietly
setTimeout(attempt, 100);
})();
}
// c = { analytics, marketing, personalization } — the same category→storage mapping Finsweet
// uses in loadConsents(), mirrored so our signals and any future Finsweet-native ones can't
// disagree. opts.deferGtagConfig holds gtag('config') until gtag.js loads (Standard direct-
// activation), so a Reject mid-load isn't lost. phDesired is the latest analytics intent;
// whichever PostHog latch fires reads it, so grant-then-quick-Reject ends opted OUT, not in.
var phDesired = null;
function applyPostHogState(ph) {
if (phDesired) {
if (ph.set_config) ph.set_config({ persistence: 'localStorage+cookie' });
if (ph.opt_in_capturing) ph.opt_in_capturing();
} else {
// ORDER MATTERS: reset() clears stored state incl. the opt-out flag, so record the opt-out
// LAST or it is silently wiped (verified: has_opted_out_capturing()===false with the
// intuitive order). Clear, stop persisting, then opt out.
if (ph.reset) ph.reset();
if (ph.set_config) ph.set_config({ persistence: 'memory' });
if (ph.opt_out_capturing) ph.opt_out_capturing();
}
}
window.__rkApplyConsent = function (c, opts) {
c = c || {};
opts = opts || {};
var ads = c.marketing ? 'granted' : 'denied';
var prefs = c.personalization ? 'granted' : 'denied';
if (typeof window.gtag === 'function') {
gtag('consent', 'update', {
ad_storage: ads,
ad_user_data: ads,
ad_personalization: ads,
analytics_storage: c.analytics ? 'granted' : 'denied',
functionality_storage: prefs,
personalization_storage: prefs
});
}
phDesired = !!c.analytics;
if (c.analytics) {
// Push gtag('js'); config fires now (Strict) or on gtag.js load (Standard: deferGtagConfig).
if (window.__rkGtagInit) window.__rkGtagInit(opts.deferGtagConfig);
withPostHog(applyPostHogState); // opt in once the SDK is live
} else if (window.posthog) {
// Revoke — latch even if not __loaded yet: a Reject during load must still opt out, or the
// grant latch opts it back in once the SDK loads. applyPostHogState reads the latest
// phDesired, so whichever latch fires applies the final intent. Guard on the stub existing
// so a stored-Reject visitor (snippet never ran) doesn't poll pointlessly every page load.
withPostHog(applyPostHogState);
}
};
})();
!function(t,e){var o,n,p,r;e.__SV||(window.posthog=e,e._i=[],e.init=function(i,s,a){function g(t,e){var o=e.split(".");2==o.length&&(t=t[o[0]],e=o[1]),t[e]=function(){t.push([e].concat(Array.prototype.slice.call(arguments,0)))}}(p=t.createElement("script")).type="text/javascript",p.async=!0,p.src=s.api_host+"/static/array.js",(r=t.getElementsByTagName("script")[0]).parentNode.insertBefore(p,r);var u=e;for(void 0!==a?u=e[a]=[]:a="posthog",u.people=u.people||[],u.toString=function(t){var e="posthog";return"posthog"!==a&&(e+="."+a),t||(e+=" (stub)"),e},u.people.toString=function(){return u.toString(1)+".people (stub)"},o="capture identify alias people.set people.set_once set_config register register_once unregister opt_out_capturing has_opted_out_capturing opt_in_capturing reset isFeatureEnabled onFeatureFlags getFeatureFlag getFeatureFlagPayload reloadFeatureFlags group updateEarlyAccessFeatureEnrollment getEarlyAccessFeatures getActiveMatchingSurveys getSurveys onSessionId".split(" "),n=0;n<o.length;n++)g(u,o[n]);e._i.push([i,s,a])},e.__SV=1)}(document,window.posthog||[]);
posthog.init(POSTHOG_KEY,{api_host:'https://app.posthog.com'} );
})();
// Named, destination-classified CTA click tracking for the rock.so marketing
// site + blog/templates. Replaces the signal lost when $autocapture was turned
// off (June 2026). One event, richly propertied, so signup intent that used to
// be split across "Sign up" / "Start for Free" / "Get Started" collapses into a
// single cta_type you can break down by location and page.
//
// Part A: fire cta_clicked on conversion CTAs.
// Part B: append UTM attribution to outbound web.rock.so links at click time so
// the app side can attribute the eventual signup to the source page/button.
function ph(event, props) {
if (typeof posthog === 'undefined' || !posthog.capture) return;
try { posthog.capture(event, props, { send_instantly: true }); } catch (e) {}
}
// Strip a leading /es locale segment so page typing/campaigns are locale-agnostic.
function normPath(p) {
return p.replace(/^/es(?=/|$)/, '') || '/';
}
function pageType(path) {
var p = normPath(path);
if (p === '/' || p === '') return 'home';
// Templates ride with blog: both are content-marketing surfaces / end destinations.
if (/^/(blog|templates)(/|$)/.test(p)) return 'blog';
if (/^/(plans/pricing|plans/unlimited|pricing)(/|$)/.test(p)) return 'pricing';
if (/^/download(/|$)/.test(p)) return 'download';
if (/^/usecases(/|$)/.test(p)) return 'usecase';
if (/^/product(/|$)/.test(p)) return 'product';
if (/^/compare(/|$)/.test(p)) return 'compare';
if (/^/contact(/|$)/.test(p)) return 'contact';
return 'other';
}
function localeOf(path) {
return /^/es(/|$)/.test(path) ? 'es' : 'en';
}
function textOf(el) {
return (el.textContent || '').replace(/\s+/g, ' ').trim();
}
var LOGIN_RE = /(log\s?in|sign\s?in|iniciar\s+sesi)/i;
var SIGNUP_RE = /(sign\s?up|get\s+started|start\s+for\s+free|empezar|registr|crear\s+cuenta)/i;
var DOWNLOAD_RE = /(download|descargar)/i;
// Returns { type, url } for tracked CTAs, else null. Destination first (reliable),
// text only as a fallback for JS-driven s with no href.
function classify(el, href, text) {
if (href) {
var url = null;
try { url = new URL(href, location.href); } catch (e) { url = null; }
if (url) {
var host = url.hostname;
var hp = url.pathname;
if (/(^|.)web.rock.so$/.test(host)) {
if (/quick-connect/support/.test(hp)) return { type: 'support', url: url };
if (LOGIN_RE.test(text)) return { type: 'login', url: url };
return { type: 'signup', url: url };
}
if (/(^|.)rock.so$/.test(host)) {
if (/^/(es/)?download/.test(hp)) return { type: 'download', url: url };
if (/^/(es/)?(plans/pricing|plans/unlimited|pricing)/.test(hp)) return { type: 'pricing', url: url };
if (/^/(es/)?contact/.test(hp)) return { type: 'contact', url: url };
}
// Direct desktop-app binaries hosted off-domain (CDN / storage).
if (DOWNLOAD_RE.test(text) && /.(dmg|exe|appimage|deb|zip)(?|$)/i.test(hp)) {
return { type: 'download', url: url };
}
}
} else {
// No href: only track buttons whose label is an unambiguous conversion CTA.
if (SIGNUP_RE.test(text)) return { type: 'signup', url: null };
if (LOGIN_RE.test(text)) return { type: 'login', url: null };
if (DOWNLOAD_RE.test(text)) return { type: 'download', url: null };
}
return null;
}
function downloadOs(url, text) {
var s = ((url && url.pathname) || '') + ' ' + text;
if (/win/i.test(s)) return 'windows';
if (/mac|osx|apple|dmg/i.test(s)) return 'mac';
if (/linux|appimage|deb/i.test(s)) return 'linux';
return 'generic';
}
function ctaLocation(el, ptype) {
if (el.closest('.rk-callout, .rk-cta')) return 'blog_callout';
if (el.closest('footer, [role="contentinfo"], .footer, .footer-2')) return 'footer';
if (el.closest('header, nav, .w-nav, [role="banner"], .navbar, .navigation-2, .navigation-wrap-2')) return 'nav';
if (el.closest('[class*="pricing"], [class*="plan-"], [class*="-plan"]')) return 'pricing_table';
if (ptype === 'blog' && el.closest('[data-rt-embed-type], .blog-content, .w-richtext, .rich-text')) return 'content';
var top = 0;
try { top = el.getBoundingClientRect().top + (window.pageYOffset || 0); } catch (e) {}
if (top > 0 && top < 800) return 'hero';
return 'body';
}
// Part B: stamp UTM attribution onto a cross-domain app link, preserving any
// existing query and never clobbering an inbound campaign.
function addAttribution(url, ptype, type, loc, path) {
if (url.searchParams.get('utm_source')) return; // respect inbound campaign
var isBlog = ptype === 'blog';
url.searchParams.set('utm_source', isBlog ? 'blog' : 'marketing_site');
url.searchParams.set('utm_medium', 'cta');
var campaign = ptype;
if (isBlog) {
var seg = normPath(path).split('/').filter(Boolean);
campaign = seg[seg.length - 1] || 'blog'; // article / template slug
}
url.searchParams.set('utm_campaign', campaign);
url.searchParams.set('utm_content', type + '_' + loc);
url.searchParams.delete('_ga'); // drop stale 2022 GA linker cruft
url.searchParams.delete('_gl');
}
function onClick(e) {
var el = e.target && e.target.closest && e.target.closest('a[href], button');
if (!el) return;
if (el.closest('.w-locales-list')) return; // locale toggle: tracked separately
var href = el.getAttribute('href');
var text = textOf(el);
var c = classify(el, href, text);
if (!c) return;
var path = location.pathname;
var ptype = pageType(path);
var loc = ctaLocation(el, ptype);
var props = {
cta_type: c.type,
cta_label: text.slice(0, 120),
cta_label_norm: text.toLowerCase().slice(0, 120),
cta_location: loc,
page_type: ptype,
page_path: path,
locale: localeOf(path)
};
if (c.type === 'download') props.download_os = downloadOs(c.url, text);
if (c.url) {
var isApp = /(^|\.)web\.rock\.so$/.test(c.url.hostname);
if (isApp && el.tagName === 'A') {
try {
addAttribution(c.url, ptype, c.type, loc, path);
el.setAttribute('href', c.url.href);
} catch (e2) {}
}
props.destination = c.url.href;
}
ph('cta_clicked', props);
}
document.addEventListener('click', onClick, true);
})();
if (!/^/(es/)?blog//.test(location.pathname)) return;
var pageLoadedAt = Date.now();
var seenViewport = new WeakSet();
var seenInteract = new WeakSet();
function ph(event, props) {
if (typeof posthog === 'undefined' || !posthog.capture) return;
try { posthog.capture(event, props); } catch (e) {}
}
function isInteractive(el) {
return el.querySelector('script') !== null;
}
function detectType(el) {
// Universal class prefixes (every article shares them). Interactive widgets
// use article-specific prefixes (mscw-, pi-, iz-, ksb-...) so we can't
// subcategorize them client-side — they all become 'interactive' here and
// get joined to the local inventory in post-hoc analysis by path.
var html = el.innerHTML.slice(0, 1500);
if (/class=["'][^"']\brtoc-/.test(html)) return 'toc';
if (/class=["'][^"'] \b(pitfall|pf-(title|body))\b/.test(html)) return 'pitfalls';
if (/class=["'][^"']\bproc(-title|-body|-step)?\b/.test(html)) return 'stepper';
if (/class=["'][^"'] \bfaq(-item|-q|-a)?\b/.test(html)) return 'faq';
if (isInteractive(el)) return 'interactive';
if (/class=["'][^"']*-tbl\b/.test(html) || /<table/.test(html)) return 'table';
return 'unknown';
}
function init() {
var embeds = Array.prototype.slice.call(
document.querySelectorAll('[data-rt-embed-type]')
);
if (!embeds.length) return;
var meta = embeds.map(function (el, i) {
return {
el: el,
index: i,
type: detectType(el),
interactive: isInteractive(el)
};
}).filter(function (m) {
// Skip the small inline callout/figure embeds. We track interactive
// widgets and the canonical static content types only.
return m.interactive || ['toc','stepper','pitfalls','faq','table'].indexOf(m.type) !== -1;
});
if (!meta.length) return;
if ('IntersectionObserver' in window) {
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (entry) {
if (!entry.isIntersecting) return;
var rec;
for (var i = 0; i < meta.length; i++) {
if (meta[i].el === entry.target) { rec = meta[i]; break; }
}
if (!rec || seenViewport.has(rec.el)) return;
seenViewport.add(rec.el);
ph('blog_widget_viewport', {
path: location.pathname,
widget_index: rec.index,
widget_type: rec.type,
widget_interactive: rec.interactive
});
io.unobserve(rec.el);
});
}, { threshold: 0.25 });
meta.forEach(function (m) { io.observe(m.el); });
}
var interactive = meta.filter(function (m) { return m.interactive; });
if (!interactive.length) return;
function onAction(action) {
return function (e) {
for (var i = 0; i < interactive.length; i++) {
var rec = interactive[i];
if (rec.el.contains(e.target) && !seenInteract.has(rec.el)) {
seenInteract.add(rec.el);
ph('blog_widget_interact', {
path: location.pathname,
widget_index: rec.index,
widget_type: rec.type,
action: action,
time_to_interact_sec: Math.round((Date.now() - pageLoadedAt) / 1000)
});
return;
}
}
};
}
document.addEventListener('click', onAction('click'), true);
document.addEventListener('change', onAction('change'), true);
document.addEventListener('pointerdown', onAction('pointerdown'), true);
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
is the Model Context Protocol (MCP)?","acceptedAnswer":{"@type":"Answer","text":"MCP is an open standard that lets AI assistants connect to outside tools and data in a consistent way. Rock runs an MCP server, so any MCP-compatible assistant can securely read and act in your Rock spaces."}},{"@type":"Question","name":"How do I connect Rock to my AI assistant?","acceptedAnswer":{"@type":"Answer","text":"Create a Personal Access Token in Rock under Settings, then add the Rock MCP endpoint (https://mcp.rock.so/mcp ) to your AI client with that token as a Bearer header. Ready-made configs for Claude Code, Claude Desktop, Cursor and VS Code are on this page, and the full guide is in our help center."}},{"@type":"Question","name":"Which AI assistants work with the Rock MCP server?","acceptedAnswer":{"@type":"Answer","text":"Any MCP-compatible client. Rock ships ready-made setup for Claude Code, Claude Desktop, Cursor and VS Code, and any other app that speaks MCP works too."}},{"@type":"Question","name":"What can my AI do in Rock through MCP?","acceptedAnswer":{"@type":"Answer","text":"Create, assign and complete tasks, search messages, notes and tasks across your spaces, write notes, send messages and react, and find files, all inside the spaces you already use."}},{"@type":"Question","name":"Is the Rock MCP server secure?","acceptedAnswer":{"@type":"Answer","text":"You connect with a Personal Access Token that acts as you and only reaches the spaces you can access. Treat it like a password: tokens have an expiry, and you can revoke one at any time from your settings."}},{"@type":"Question","name":"Do I need a paid plan to use MCP?","acceptedAnswer":{"@type":"Answer","text":"No. Rock MCP works on the free plan, with no per-seat fees. Connect your AI assistant and work across your spaces."}}]}
Connect any AI to Rock over MCP Rock runs a Model Context Protocol server, so Claude, Cursor, VS Code or any MCP client can create tasks, search and write across every space you run, with a single access token.
JustJust apparel Kickoff → tasks Rock MCP Turn the JustJust kickoff call into tasks in their space.
Done. I pulled three tasks from the call and added them to JustJust apparel , with owners and due dates:
• Draft Q4 campaign brief Fri · You
• Source 5 product photos Mon · Maya
• Set up IG content calendar Wed · You
Create and track tasks
Turn a request into tasks in the right space, with owners, due dates, sprints and status.
create · assign · complete · sprints Draft Q4 campaign brief Fri · You Source 5 product photos Mon · Maya Set up IG content calendar Wed · You
Search across every space
One question pulls up any message, note, task or file from every space you can access.
messages · notes · tasks · files Where did we land on JustJust pricing?
JustJust Locked at $2.4k / mo · note
Acme Homepage copy approved · chat
Write notes and messages
Draft notes, post updates and react, all acting as you in the spaces you already use.
notes · messages · reactions · comments note Weekly update · drafted
message posted to JustJust
acts_as "you"
access your spaces only
Connect in two steps Create a token, paste one config, and your assistant is ready to work in Rock.
1 Create a personal access token In Rock, open Settings and go to Personal Access Tokens, then create a new token. Copy it, you will only see it once.
2 Add Rock to your AI client Paste the config for your client below and replace <your token> with the token you created.
Tokens act as you and reach only the spaces you can access. Revoke a token any time from settings.
Read the full setup guide .
Each client has its own page: connect over MCP, test it, and see what your assistant can do in Rock.
What is the Model Context Protocol (MCP)? MCP is an open standard that lets AI assistants connect to outside tools and data in a consistent way. Rock runs an MCP server, so any MCP-compatible assistant can securely read and act in your Rock spaces.
How do I connect Rock to my AI assistant? Create a Personal Access Token in Rock under Settings, then add the Rock MCP endpoint (https://mcp.rock.so/mcp) to your AI client with that token as a Bearer header. Ready-made configs for Claude Code, Claude Desktop, Cursor and VS Code are on this page, and the full guide is in our help center.
Which AI assistants work with the Rock MCP server? Any MCP-compatible client. Rock ships ready-made setup for Claude Code, Claude Desktop, Cursor and VS Code, and any other app that speaks MCP works too.
What can my AI do in Rock through MCP? Create, assign and complete tasks, search messages, notes and tasks across your spaces, write notes, send messages and react, and find files, all inside the spaces you already use.
Is the Rock MCP server secure? You connect with a Personal Access Token that acts as you and only reaches the spaces you can access. Treat it like a password: tokens have an expiry, and you can revoke one at any time from your settings.
Do I need a paid plan to use MCP? No. Rock MCP works on the free plan, with no per-seat fees. Connect your AI assistant and work across your spaces.
Put your AI to work in Rock Connect your assistant over MCP and let it handle the busywork across your spaces.
Start today for free ';
var FS_SRI = 'sha384-uVCHlrcqYHCyMp7IK+OFleud6CX5z6NneNY+tG1uuluYcAQRkhDKthu6wu3XWQoR';
var FS_COOKIE = 'fs-cc';
var TIER_COOKIE = 'rk_cc_tier'; // strictly necessary; disclosed in the Essential category
var MAX_AGE_DAYS = 180; // matches Finsweet's own default (Ue="180")
// Countries where analytics requires opt-in. Anything not listed gets Standard (opt-out).
// IN is included pending a read on India's DPDP consent model — safe default, and the
// cheapest possible thing to reverse (delete two characters). See ANALYTICS-CONSENT.md §7.1.
var STRICT_CC = [
// EU27
'AT','BE','BG','HR','CY','CZ','DK','EE','FI','FR','DE','GR','HU','IE','IT','LV','LT',
'LU','MT','NL','PL','PT','RO','SK','SI','ES','SE',
// EEA non-EU + UK
'IS','LI','NO','GB',
// Crown Dependencies + Gibraltar
'JE','GG','IM','GI',
// EU outermost regions (own ISO codes, still EU law)
'GF','GP','MQ','RE','YT','MF',
// Not EEA, but not worth arguing over at this traffic share
'CH',
// Pending legal read
'IN',
// Tor exit / unknown — fail closed
'T1','XX'
];
function getCookie(name) {
var m = document.cookie.match('(^|;)\s*' + name + '\s*=\s*([^;]+)');
return m ? m.pop() : null;
}
function setCookie(name, value) {
document.cookie = name + '=' + value + ';path=/;max-age=' + (MAX_AGE_DAYS * 86400) +
';SameSite=Lax' + (location.protocol === 'https:' ? ';Secure' : '');
}
function deleteCookie(name) {
document.cookie = name + '=;path=/;max-age=0;SameSite=Lax';
}
// Finsweet stores encodeURIComponent(JSON.stringify({id, consents})) and then js-cookie
// percent-encodes it AGAIN on write, so reading document.cookie directly yields a
// DOUBLE-encoded value (%257B%2522id%2522… — %25 is an encoded %). A single
// decodeURIComponent leaves %7B%22id%22…, which JSON.parse rejects; the resulting null
// silently disabled every consent transition. Decode iteratively until it parses, so this
// keeps working whether Finsweet single- or double-encodes in a future release.
function readStoredConsents() {
var s = getCookie(FS_COOKIE);
if (!s) return null;
for (var i = 0; i < 4; i++) {
try {
var parsed = JSON.parse(s);
return (parsed && parsed.consents) || null;
} catch (e) { /* not JSON yet — decode another layer */ }
var d;
try { d = decodeURIComponent(s); } catch (e) { return null; }
if (d === s) return null;
s = d;
}
return null;
}
function resolveTier(cb) {
// Global Privacy Control is an explicit signal — honor it over geography.
try { if (navigator.globalPrivacyControl === true) return cb('strict'); } catch (e) {}
// No sessionStorage cache: it would survive VPN changes, GPC toggles and bfcache
// restores within a tab and pin a stale Standard tier for the whole session.
var done = false;
var finish = function (tier) { if (!done) { done = true; cb(tier); } };
var timer = setTimeout(function () { finish('strict'); }, 1500); // fail closed
try {
fetch('/cdn-cgi/trace', { credentials: 'omit' })
.then(function (r) { return r.ok ? r.text() : Promise.reject(); })
.then(function (txt) {
clearTimeout(timer);
var m = /(?:^|\n)loc=([A-Z0-9]{2})/.exec(txt);
var cc = m && m[1];
finish(!cc || STRICT_CC.indexOf(cc) !== -1 ? 'strict' : 'standard');
})
.catch(function () { clearTimeout(timer); finish('strict'); });
} catch (e) { clearTimeout(timer); finish('strict'); }
}
function applyBannerCopy(tier) {
// Both variants ship in the markup; drop the one that doesn't apply. Runs before
// Finsweet is injected, and the banner is display:none until Finsweet shows it, so
// there is no flash.
var nodes = document.querySelectorAll('[data-cc-tier]');
for (var i = 0; i < nodes.length; i++) {
if (nodes[i].getAttribute('data-cc-tier') !== tier) nodes[i].remove();
}
}
var analyticsActivated = false; // set by activateGatedAnalytics once it turns a tracker on
var fsccLoaded = false;
var fsccFailHandled = false;
// Standard tier activates analytics BEFORE Finsweet loads (activateGatedAnalytics). If Finsweet is
// the piece that then fails — blocked by an extension/network, SRI mismatch, CDN outage — its
// banner + opt-out UI never appear, so we'd be tracking with no notice. Detect that and revoke:
// no consent UI => don't track. onerror catches blocked/SRI/network; a timeout backstops a silent
// failure without false-positiving on a merely slow-but-working load (onload clears it). No-op for
// Strict, which activated nothing pre-consent.
function onFinsweetUnavailable() {
if (fsccFailHandled || !analyticsActivated) return;
fsccFailHandled = true;
if (window.__rkApplyConsent) {
window.__rkApplyConsent({ analytics: false, marketing: false, personalization: false });
}
}
function injectFinsweet(tier) {
var s = document.createElement('script');
// Every attribute must be set BEFORE insertion: Finsweet reads its whole config off
// document.currentScript when it executes.
s.src = FS_SRC;
s.integrity = FS_SRI;
s.crossOrigin = 'anonymous';
s.async = true;
s.setAttribute('fs-cc-mode', tier === 'strict' ? 'opt-in' : 'opt-out');
// Deliberately NOT fs-cc-consentmode: it derives Google's consent default from the
// stored cookie rather than the mode, so a first-time opt-out visitor would get
// denied. Analytics.astro owns the Google consent signals instead.
s.onload = function () { fsccLoaded = true; };
s.onerror = onFinsweetUnavailable;
document.head.appendChild(s);
if (analyticsActivated) {
setTimeout(function () { if (!fsccLoaded) onFinsweetUnavailable(); }, 10000);
}
}
// Standard tier only: activate the analytics scripts NOW instead of waiting for Finsweet to
// clone-activate them. Measured on the shipped build (fast network, instant trace): Finsweet is
// ready at ~0.7s but does not activate the gated
// guard makes a re-run a no-op, so this is safe even if a script is somehow activated twice.
live.textContent = orig.textContent;
}
orig.parentNode.replaceChild(live, orig);
did = true;
}
if (did) analyticsActivated = true;
return did;
}
function boot(tier) {
var stored = readStoredConsents();
// Jurisdiction is not recorded in the fs-cc cookie, so a visitor who consented under
// Standard and later loads from a Strict country would arrive with stored `granted`.
// In Strict, honor a stored consent ONLY if rk_cc_tier proves it was collected under
// Strict (opt-in). A 'standard' value — OR a missing/purged tier cookie (Safari ITP,
// cleared cookies) — cannot prove opt-in provenance, so void it and re-prompt: fail
// closed. MUST happen before Finsweet is injected — clearing the cookie afterwards does
// nothing, since the booted instance keeps its consents in memory.
if (tier === 'strict' && stored && getCookie(TIER_COOKIE) !== 'strict') {
deleteCookie(FS_COOKIE);
stored = null;
}
setCookie(TIER_COOKIE, tier);
// Mirror the effective consent into Google BEFORE Finsweet is injected — injecting is
// what un-gates gtag.js, so queueing the update first is what makes wait_for_update
// unnecessary. A stored choice always wins: Finsweet restores stored consents even in
// opt-out mode (verified in loadConsents), so blindly granting here would silently
// override an explicit Reject.
var effective = stored || (tier === 'standard'
? { analytics: true, marketing: true, personalization: true }
: null);
if (effective && window.__rkApplyConsent) {
window.__rkApplyConsent(effective, { deferGtagConfig: tier === 'standard' });
}
// Standard tier: activate trackers directly rather than waiting on Finsweet (activateGatedAnalytics
// — measured ~6s, and skipped on a failed trace / blocked Finsweet). Per-script AND semantics mean
// a stored Reject (analytics:false) activates nothing; strict tier never reaches here. Finsweet is
// still injected below for the banner + revoke, and if it fails to load we revoke (injectFinsweet).
if (tier === 'standard' && effective) activateGatedAnalytics(effective);
applyBannerCopy(tier);
injectFinsweet(tier);
}
// Re-read consent after any banner interaction.
//
// Finsweet writes the fs-cc cookie from its own async applyConsents() path, so the new
// value is NOT available on the next tick — an earlier setTimeout(0) version read null
// and silently did nothing, leaving gtag.js loaded but never configured (no page_view at
// all). Poll until the cookie actually CHANGES rather than merely exists: on a repeat
// interaction the old value is already present, so an existence check would read the
// stale consents and apply the previous choice.
window.__rkSyncConsent = function () {
var before = getCookie(FS_COOKIE);
var tries = 0;
(function attempt() {
var now = getCookie(FS_COOKIE);
if (now && now !== before) {
var c = readStoredConsents();
if (c && window.__rkApplyConsent) window.__rkApplyConsent(c);
return;
}
if (++tries > 25) { // ~2.5s, then settle for whatever is stored
var fallback = readStoredConsents();
if (fallback && window.__rkApplyConsent) window.__rkApplyConsent(fallback);
return;
}
setTimeout(attempt, 100);
})();
};
document.addEventListener('click', function (e) {
var el = e.target && e.target.closest && e.target.closest('[fs-cc="allow"],[fs-cc="deny"],[fs-cc="submit"]');
if (el) window.__rkSyncConsent();
}, true);
// Cross-tab + bfcache reconciliation. The fs-cc cookie is shared across tabs, so if consent
// changes in another tab (most importantly a REVOKE), re-apply here when this tab regains
// focus or is restored from bfcache. Guarded on the raw cookie so it only acts on a real
// change. This does NOT re-resolve the tier or re-inject Finsweet — consent state only.
// Revoke propagates reliably (opt-out an already-running tracker); a cross-tab grant only
// fully initializes trackers on this tab's next navigation, which is acceptable.
var lastSeenFscc = getCookie(FS_COOKIE);
function reapplyIfChanged() {
var now = getCookie(FS_COOKIE);
if (now === lastSeenFscc) return;
lastSeenFscc = now;
var c = readStoredConsents();
if (c && window.__rkApplyConsent) window.__rkApplyConsent(c);
}
document.addEventListener('visibilitychange', function () {
if (document.visibilityState === 'visible') reapplyIfChanged();
});
window.addEventListener('pageshow', function (e) { if (e.persisted) reapplyIfChanged(); });
// Finsweet's controls are anchors carrying href="#" (Accept, Reject, open-preferences,
// allow/deny all, submit). Clicking one navigates to the empty fragment, which throws the
// reader back to the top of the page mid-article. Cancel just the default navigation;
// Finsweet binds its own click listener on [fs-cc], and preventDefault does not stop it.
// Capture phase so this runs regardless of listener registration order.
document.addEventListener('click', function (e) {
var a = e.target && e.target.closest && e.target.closest('a[fs-cc]');
if (a && (a.getAttribute('href') || '') === '#') e.preventDefault();
}, true);
resolveTier(boot);
})();
By clicking Accept , you agree to the storing of cookies on your device to enhance site navigation, analyze site usage, and assist in our marketing efforts. View our Privacy Policy or manage preferences.
We use cookies to enhance site navigation, analyze site usage, and assist in our marketing efforts. You can reject them here, or change your choice at any time. View our Privacy Policy or manage preferences.