diff --git a/docusaurus.config.js b/docusaurus.config.js index 6efc36723..22c2d5d49 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -552,6 +552,8 @@ fbq('track', 'PageView');`, // - Meta Pixel -> eager via the inline snippet in headTags; SPA re-fire // from the client module // - Clarity + Apollo -> lazy, on first user interaction (client module) + // - Chatwoot -> lazy, on first click/key/touch (client module); + // deliberately not on scroll, see that module // - Hotjar -> removed // keploy's own first-party telemetry (~2 KiB) stays eager below. { diff --git a/src/css/custom.css b/src/css/custom.css index 62c965b8c..d1a620b60 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -1034,17 +1034,56 @@ textarea { .theme-back-to-top-button { background-color: #ff914d !important; - color: white !important; -} - -.theme-back-to-top-button svg { - fill: white !important; } .theme-back-to-top-button:hover { background-color: #e67643 !important; } +/* + * Match the Chatwoot support bubble, and stack above it. + * + * The bubble is a fixed 64px square sitting 20px in from the bottom-right + * corner at z-index 2147483000. Left at the theme's defaults this button is a + * 48px square at a 1.3rem (20.8px) inset, so it lands in the same corner but + * smaller and off the bubble's centreline, and the bubble covers it and + * swallows every click meant for it. + * + * The button is less transient than it looks, so this is not a rare overlap: + * it appears on any upward scroll past 300px and only hides when the reader + * scrolls back down, drops below the threshold, or clicks an anchor. There is + * no timeout, so the two share the corner for most of a page visit. + * + * Gated on .chatwoot-ready, which static/scripts/chatwoot.js sets on + * from the SDK's ready event. If Chatwoot is down, blocked or never loads, + * none of this applies and the button keeps the theme's own position rather + * than floating above an empty 84px gap. + * + * Matching the bubble's 64px width means its 20px inset also lines the two up + * on a shared centreline. `bottom` is 20px inset + 64px bubble + 20px gap, the + * same clearance the enterprise app uses to keep toasts clear of the bubble. + * + * px rather than rem because the bubble's own geometry is fixed px and does + * not scale with root font size. + */ +.chatwoot-ready .theme-back-to-top-button { + width: 64px; + height: 64px; + right: 20px; + bottom: 104px; +} + +/* + * The theme draws the chevron as a fixed 2rem (32px) mask on ::after, so + * growing the button 48px -> 64px would shrink the glyph from 67% of the + * button to 50% and leave a small arrow in a big circle. Scale it back up to + * keep roughly the theme's original proportion. + */ +.chatwoot-ready .theme-back-to-top-button::after { + -webkit-mask-size: 2.5rem 2.5rem; + mask-size: 2.5rem 2.5rem; +} + /* ============================================ MODERN SIDEBAR NAVIGATION PANEL ============================================ */ diff --git a/src/metaPixelRouteTracker.js b/src/metaPixelRouteTracker.js index 1ef204ae0..845349bb0 100644 --- a/src/metaPixelRouteTracker.js +++ b/src/metaPixelRouteTracker.js @@ -9,26 +9,50 @@ // - Microsoft Clarity + Apollo -> lazy-loaded on the FIRST user interaction // (scroll / click / key / touch): engaged sessions only, so they stay off // the initial load. +// - Chatwoot -> same idea, but a NARROWER gate that excludes +// scroll. See ENGAGEMENT_TIERS below. // - Hotjar -> removed. +// Two tiers, because not every third party deserves the same trigger. +// +// Analytics wants scroll: a reader who scrolls is a session worth measuring, +// and Clarity's whole job is recording that scroll. +// +// Chatwoot does not. It is far heavier than a tag -- it opens a persistent +// websocket and keeps it open -- and on a docs site practically every session +// scrolls, so including scroll would boot the SDK for effectively every +// reader and undo the point of gating it. Support chat is worth loading for +// someone who clicks, types or taps, which is also exactly the gate the +// landing page uses for the same widget. +// +// NB: no "mousemove" in either tier. On desktop the pointer moves within +// milliseconds of paint, which would defeat the gate and load almost +// immediately. These are genuine "engaged intent" signals only. +// // baseUrl is /docs/, so these resolve under the docs site root. -const INTERACTION_SCRIPTS = [ - "/docs/scripts/clarity.js", // Microsoft Clarity - "/docs/js/apollo-init.js", // Apollo +const ENGAGEMENT_TIERS = [ + { + events: ["pointerdown", "keydown", "scroll", "touchstart"], + scripts: [ + "/docs/scripts/clarity.js", // Microsoft Clarity + "/docs/js/apollo-init.js", // Apollo + ], + loaded: false, + }, + { + events: ["pointerdown", "keydown", "touchstart"], + scripts: [ + "/docs/scripts/chatwoot.js", // Chatwoot support widget + ], + loaded: false, + }, ]; -// NB: no "mousemove" — on desktop the pointer moves within milliseconds of -// paint, which would defeat the gate and load Clarity/Apollo almost immediately. -// These are genuine "engaged intent" signals only. -const INTERACTION_EVENTS = ["pointerdown", "keydown", "scroll", "touchstart"]; -let engagementLoaded = false; -function loadEngagement() { - if (engagementLoaded || typeof window === "undefined") return; - engagementLoaded = true; - INTERACTION_EVENTS.forEach((e) => - window.removeEventListener(e, loadEngagement) - ); - for (const src of INTERACTION_SCRIPTS) { +function loadTier(tier) { + if (tier.loaded || typeof window === "undefined") return; + tier.loaded = true; + tier.events.forEach((e) => window.removeEventListener(e, tier.handler)); + for (const src of tier.scripts) { const el = document.createElement("script"); el.src = src; el.async = true; @@ -36,11 +60,18 @@ function loadEngagement() { } } +function loadEngagement() { + ENGAGEMENT_TIERS.forEach(loadTier); +} + function armEngagement() { if (typeof window === "undefined") return; - INTERACTION_EVENTS.forEach((e) => - window.addEventListener(e, loadEngagement, {passive: true}) - ); + ENGAGEMENT_TIERS.forEach((tier) => { + tier.handler = () => loadTier(tier); + tier.events.forEach((e) => + window.addEventListener(e, tier.handler, {passive: true}) + ); + }); } export function onRouteDidUpdate({location, previousLocation}) { diff --git a/static/scripts/chatwoot.js b/static/scripts/chatwoot.js new file mode 100644 index 000000000..c3574d7a1 --- /dev/null +++ b/static/scripts/chatwoot.js @@ -0,0 +1,43 @@ +// Chatwoot support widget (self-hosted). +// +// No interaction gate in here on purpose -- this file is loaded by the +// interaction loader in src/metaPixelRouteTracker.js, which already holds it +// off the initial page load until the reader engages. +(function initChatwoot() { + var BASE_URL = "https://chatwoot.keploy.io"; + + // `position` is declared rather than left to default because the + // .theme-back-to-top-button rule in src/css/custom.css is built around the + // bubble sitting bottom-right. Making it explicit means that CSS breaks + // loudly here rather than silently there if it ever changes. + window.chatwootSettings = { + position: "right", + type: "standard", + darkMode: "auto", + }; + + // The back-to-top button is only lifted clear of the bubble once the bubble + // actually exists. If Chatwoot is down, blocked, or the SDK 404s, the button + // keeps the theme's default position instead of floating above a gap. + window.addEventListener("chatwoot:ready", function onReady() { + document.documentElement.classList.add("chatwoot-ready"); + }); + + var scriptEl = document.createElement("script"); + scriptEl.src = BASE_URL + "/packs/js/sdk.js"; + scriptEl.async = true; + scriptEl.onload = function () { + // Checked here rather than at the top of the IIFE, where it would be + // useless: two injections in the same tick both run before either sdk.js + // has loaded, so $chatwoot is undefined for both. By onload the first + // run() has set it, so the second one stops. The loader's per-tier flag + // already prevents that case, and run() self-guards too, but this does + // not depend on either staying true. + if (window.$chatwoot) return; + window.chatwootSDK?.run({ + websiteToken: "DNsHCafpdxqz3dDU1SPggAon", + baseUrl: BASE_URL, + }); + }; + document.head.appendChild(scriptEl); +})(); diff --git a/vercel.json b/vercel.json index aa91a5123..39b51eb9b 100644 --- a/vercel.json +++ b/vercel.json @@ -12,7 +12,7 @@ }, { "key": "Content-Security-Policy-Report-Only", - "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://connect.facebook.net https://www.clarity.ms https://*.clarity.ms https://telemetry.keploy.io https://assets.apollo.io https://*.algolia.net https://*.algolianet.com; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self' https://www.google-analytics.com https://*.google-analytics.com https://www.googletagmanager.com https://connect.facebook.net https://www.facebook.com https://*.clarity.ms https://telemetry.keploy.io https://keploy.io https://assets.apollo.io https://*.algolia.net https://*.algolianet.com; frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com; upgrade-insecure-requests" + "value": "default-src 'self'; base-uri 'self'; object-src 'none'; frame-ancestors 'self'; script-src 'self' 'unsafe-inline' https://www.googletagmanager.com https://www.google-analytics.com https://connect.facebook.net https://www.clarity.ms https://*.clarity.ms https://telemetry.keploy.io https://assets.apollo.io https://*.algolia.net https://*.algolianet.com https://chatwoot.keploy.io; style-src 'self' 'unsafe-inline'; font-src 'self' data:; img-src 'self' data: https:; connect-src 'self' https://www.google-analytics.com https://*.google-analytics.com https://www.googletagmanager.com https://connect.facebook.net https://www.facebook.com https://*.clarity.ms https://telemetry.keploy.io https://keploy.io https://assets.apollo.io https://*.algolia.net https://*.algolianet.com https://chatwoot.keploy.io wss://chatwoot.keploy.io; frame-src 'self' https://www.youtube.com https://www.youtube-nocookie.com https://chatwoot.keploy.io; upgrade-insecure-requests" } ] },