From 1802c11dcba7758251b2d3fdd5b9afa03f5d87dc Mon Sep 17 00:00:00 2001 From: amaan-bhati Date: Mon, 21 Sep 2026 17:58:53 +0530 Subject: [PATCH 1/4] feat: add Chatwoot support widget to docs, stacked above back-to-top button Signed-off-by: amaan-bhati --- src/css/custom.css | 17 +++++++++++++++++ src/metaPixelRouteTracker.js | 7 ++++--- static/scripts/chatwoot.js | 19 +++++++++++++++++++ 3 files changed, 40 insertions(+), 3 deletions(-) create mode 100644 static/scripts/chatwoot.js diff --git a/src/css/custom.css b/src/css/custom.css index 62c965b8c..639f5d333 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -1035,6 +1035,23 @@ textarea { .theme-back-to-top-button { background-color: #ff914d !important; color: white !important; + + /* + * Stack this above the Chatwoot support bubble. + * + * Both are fixed to the bottom-right corner. The bubble is 64px square with + * a 20px inset and a very high z-index, so left alone it covers this button + * and swallows every click meant for it. + * + * The button is less transient than it looks: it appears on any upward + * scroll past 300px and only hides when the reader scrolls back down, so the + * two share the corner for most of a page visit. Clearing it vertically + * keeps both usable -- 20px inset + 64px bubble + 20px gap. + * + * !important matches the rest of this block and beats the theme's own + * `bottom`, whose CSS module is bundled after this file. + */ + bottom: 104px !important; } .theme-back-to-top-button svg { diff --git a/src/metaPixelRouteTracker.js b/src/metaPixelRouteTracker.js index 1ef204ae0..12c418833 100644 --- a/src/metaPixelRouteTracker.js +++ b/src/metaPixelRouteTracker.js @@ -6,15 +6,16 @@ // - Meta Pixel -> fires eagerly via the inline snippet in // headTags (init + PageView on load). Because that only fires once, this // module re-fires PageView on client-side (SPA) route changes. -// - Microsoft Clarity + Apollo -> lazy-loaded on the FIRST user interaction -// (scroll / click / key / touch): engaged sessions only, so they stay off -// the initial load. +// - Microsoft Clarity + Apollo + Chatwoot -> lazy-loaded on the FIRST user +// interaction (scroll / click / key / touch): engaged sessions only, so +// they stay off the initial load. // - Hotjar -> removed. // 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 + "/docs/scripts/chatwoot.js", // Chatwoot support widget ]; // NB: no "mousemove" — on desktop the pointer moves within milliseconds of // paint, which would defeat the gate and load Clarity/Apollo almost immediately. diff --git a/static/scripts/chatwoot.js b/static/scripts/chatwoot.js new file mode 100644 index 000000000..e588a38bb --- /dev/null +++ b/static/scripts/chatwoot.js @@ -0,0 +1,19 @@ +// 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"; + + var scriptEl = document.createElement("script"); + scriptEl.src = BASE_URL + "/packs/js/sdk.js"; + scriptEl.async = true; + scriptEl.onload = function () { + window.chatwootSDK?.run({ + websiteToken: "DNsHCafpdxqz3dDU1SPggAon", + baseUrl: BASE_URL, + }); + }; + document.head.appendChild(scriptEl); +})(); From ce2fe81d4133313fa9140b0b220e1b33b176840a Mon Sep 17 00:00:00 2001 From: amaan-bhati Date: Mon, 21 Sep 2026 18:09:18 +0530 Subject: [PATCH 2/4] perf: align scoll btn and increase size wrt chat btn Signed-off-by: amaan-bhati --- src/css/custom.css | 31 +++++++++++++++++++++---------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/css/custom.css b/src/css/custom.css index 639f5d333..538e6f36f 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -1037,20 +1037,31 @@ textarea { color: white !important; /* - * Stack this above the Chatwoot support bubble. + * Match the Chatwoot support bubble, and stack above it. * - * Both are fixed to the bottom-right corner. The bubble is 64px square with - * a 20px inset and a very high z-index, so left alone it covers this button - * and swallows every click meant for 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: it appears on any upward - * scroll past 300px and only hides when the reader scrolls back down, so the - * two share the corner for most of a page visit. Clearing it vertically - * keeps both usable -- 20px inset + 64px bubble + 20px gap. + * 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. * - * !important matches the rest of this block and beats the theme's own - * `bottom`, whose CSS module is bundled after this file. + * 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. !important matches the rest of this block + * and beats the theme's CSS module, which is bundled after this file. */ + width: 64px !important; + height: 64px !important; + right: 20px !important; bottom: 104px !important; } From c0bbc7cbd854e1ee309e2391bf6634720e5a4c05 Mon Sep 17 00:00:00 2001 From: amaan-bhati Date: Mon, 21 Sep 2026 18:28:25 +0530 Subject: [PATCH 3/4] fix: add Chatwoot to CSP, gate back-to-top offset on SDK load, drop scroll trigger Signed-off-by: amaan-bhati --- docusaurus.config.js | 2 + src/css/custom.css | 77 ++++++++++++++++++++---------------- src/metaPixelRouteTracker.js | 74 +++++++++++++++++++++++----------- static/scripts/chatwoot.js | 22 +++++++++++ vercel.json | 2 +- 5 files changed, 121 insertions(+), 56 deletions(-) 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 538e6f36f..d1a620b60 100644 --- a/src/css/custom.css +++ b/src/css/custom.css @@ -1034,45 +1034,56 @@ textarea { .theme-back-to-top-button { background-color: #ff914d !important; - color: white !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. - * - * 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. !important matches the rest of this block - * and beats the theme's CSS module, which is bundled after this file. - */ - width: 64px !important; - height: 64px !important; - right: 20px !important; - bottom: 104px !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 12c418833..845349bb0 100644 --- a/src/metaPixelRouteTracker.js +++ b/src/metaPixelRouteTracker.js @@ -6,30 +6,53 @@ // - Meta Pixel -> fires eagerly via the inline snippet in // headTags (init + PageView on load). Because that only fires once, this // module re-fires PageView on client-side (SPA) route changes. -// - Microsoft Clarity + Apollo + Chatwoot -> lazy-loaded on the FIRST user -// interaction (scroll / click / key / touch): engaged sessions only, so -// they stay off the initial load. +// - 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 - "/docs/scripts/chatwoot.js", // Chatwoot support widget +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; @@ -37,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 index e588a38bb..a70e8a840 100644 --- a/static/scripts/chatwoot.js +++ b/static/scripts/chatwoot.js @@ -6,6 +6,28 @@ (function initChatwoot() { var BASE_URL = "https://chatwoot.keploy.io"; + // The loader guards against double injection, but guard here too so this + // file is safe on its own: a second run() would mean two bubbles and two + // websockets. + if (window.$chatwoot) return; + + // `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; diff --git a/vercel.json b/vercel.json index aa91a5123..6cd76ce06 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' https://chatwoot.keploy.io; font-src 'self' data: https://chatwoot.keploy.io; 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" } ] }, From 1be5b0bd266b840c054f1e2a19c3e5e326c613b7 Mon Sep 17 00:00:00 2001 From: amaan-bhati Date: Mon, 21 Sep 2026 23:57:28 +0530 Subject: [PATCH 4/4] fix: move Chatwoot re-entry guard into onload and drop inert style/font CSP entries Signed-off-by: amaan-bhati --- static/scripts/chatwoot.js | 12 +++++++----- vercel.json | 2 +- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/static/scripts/chatwoot.js b/static/scripts/chatwoot.js index a70e8a840..c3574d7a1 100644 --- a/static/scripts/chatwoot.js +++ b/static/scripts/chatwoot.js @@ -6,11 +6,6 @@ (function initChatwoot() { var BASE_URL = "https://chatwoot.keploy.io"; - // The loader guards against double injection, but guard here too so this - // file is safe on its own: a second run() would mean two bubbles and two - // websockets. - if (window.$chatwoot) return; - // `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 @@ -32,6 +27,13 @@ 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, diff --git a/vercel.json b/vercel.json index 6cd76ce06..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 https://chatwoot.keploy.io; style-src 'self' 'unsafe-inline' https://chatwoot.keploy.io; font-src 'self' data: https://chatwoot.keploy.io; 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" + "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" } ] },