Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docusaurus.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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.
{
Expand Down
49 changes: 44 additions & 5 deletions src/css/custom.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 <html>
* 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
============================================ */
Expand Down
67 changes: 49 additions & 18 deletions src/metaPixelRouteTracker.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,38 +9,69 @@
// - 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;
document.head.appendChild(el);
}
}

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}) {
Expand Down
43 changes: 43 additions & 0 deletions static/scripts/chatwoot.js
Original file line number Diff line number Diff line change
@@ -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);
})();
2 changes: 1 addition & 1 deletion vercel.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
}
]
},
Expand Down
Loading