docs: Add consent banner (#50302)

Gaauwe Rombouts created

Adds a consent banner, similar to the one on zed.dev

Before you mark this PR as ready for review, make sure that you have:
- [x] Added a solid test coverage and/or screenshots from doing manual
testing
- [x] Done a self-review taking into account security and performance
aspects
- [x] Aligned any UI changes with the [UI
checklist](https://github.com/zed-industries/zed/blob/main/CONTRIBUTING.md#uiux-checklist)

Release Notes:

- N/A

Change summary

.github/workflows/deploy_cloudflare.yml |   1 
crates/docs_preprocessor/src/main.rs    |   2 
docs/.prettierignore                    |   3 
docs/README.md                          |  16 +
docs/book.toml                          |   4 
docs/theme/analytics.js                 |  93 ++++++++
docs/theme/c15t@2.0.0-rc.3.js           |   0 
docs/theme/consent-banner.css           | 292 +++++++++++++++++++++++++++
docs/theme/index.hbs                    | 102 +++++++-
typos.toml                              |   2 
10 files changed, 496 insertions(+), 19 deletions(-)

Detailed changes

.github/workflows/deploy_cloudflare.yml 🔗

@@ -26,6 +26,7 @@ jobs:
           CC: clang
           CXX: clang++
           DOCS_AMPLITUDE_API_KEY: ${{ secrets.DOCS_AMPLITUDE_API_KEY }}
+          DOCS_CONSENT_IO_INSTANCE: ${{ secrets.DOCS_CONSENT_IO_INSTANCE }}
 
       - name: Deploy Docs
         uses: cloudflare/wrangler-action@da0e0dfe58b7a431659754fdf3f186c529afbe65 # v3

crates/docs_preprocessor/src/main.rs 🔗

@@ -578,6 +578,7 @@ fn handle_postprocessing() -> Result<()> {
         .expect("Default title not a string")
         .to_string();
     let amplitude_key = std::env::var("DOCS_AMPLITUDE_API_KEY").unwrap_or_default();
+    let consent_io_instance = std::env::var("DOCS_CONSENT_IO_INSTANCE").unwrap_or_default();
 
     output.insert("html".to_string(), zed_html);
     mdbook::Renderer::render(&mdbook::renderer::HtmlHandlebars::new(), &ctx)?;
@@ -647,6 +648,7 @@ fn handle_postprocessing() -> Result<()> {
         zlog::trace!(logger => "Updating {:?}", pretty_path(&file, &root_dir));
         let contents = contents.replace("#description#", meta_description);
         let contents = contents.replace("#amplitude_key#", &amplitude_key);
+        let contents = contents.replace("#consent_io_instance#", &consent_io_instance);
         let contents = title_regex()
             .replace(&contents, |_: &regex::Captures| {
                 format!("<title>{}</title>", meta_title)

docs/.prettierignore 🔗

@@ -1,2 +1,5 @@
 # Handlebars partials are not supported by Prettier.
 *.hbs
+
+# Automatically generated
+theme/c15t@*.js

docs/README.md 🔗

@@ -64,6 +64,22 @@ This will render a human-readable version of the action name, e.g., "zed: open s
 Templates are functions that modify the source of the docs pages (usually with a regex match and replace).
 You can see how the actions and keybindings are templated in `crates/docs_preprocessor/src/main.rs` for reference on how to create new templates.
 
+## Consent Banner
+
+We pre-bundle the `c15t` package because the docs pipeline does not include a JS bundler. If you need to update `c15t` and rebuild the bundle, use:
+
+```
+mkdir c15t-bundle && cd c15t-bundle
+npm init -y
+npm install c15t@<version> esbuild
+echo "import { getOrCreateConsentRuntime } from 'c15t'; window.c15t = { getOrCreateConsentRuntime };" > entry.js
+npx esbuild entry.js --bundle --format=iife --minify --outfile=c15t@<version>.js
+cp c15t@<version>.js ../theme/c15t@<version>.js
+cd .. && rm -rf c15t-bundle
+```
+
+Replace `<version>` with the new version of `c15t` you are installing. Then update `book.toml` to reference the new bundle filename.
+
 ### References
 
 - Template Trait: `crates/docs_preprocessor/src/templates.rs`

docs/book.toml 🔗

@@ -23,8 +23,8 @@ default-description = "Learn how to use and customize Zed, the fast, collaborati
 default-title = "Zed Code Editor Documentation"
 no-section-label = true
 preferred-dark-theme = "dark"
-additional-css = ["theme/page-toc.css", "theme/plugins.css", "theme/highlight.css"]
-additional-js  = ["theme/page-toc.js", "theme/plugins.js"]
+additional-css = ["theme/page-toc.css", "theme/plugins.css", "theme/highlight.css", "theme/consent-banner.css"]
+additional-js  = ["theme/page-toc.js", "theme/plugins.js", "theme/c15t@2.0.0-rc.3.js", "theme/analytics.js"]
 
 [output.zed-html.print]
 enable = false

docs/theme/analytics.js 🔗

@@ -0,0 +1,93 @@
+const amplitudeKey = document.querySelector(
+  'meta[name="amplitude-key"]',
+)?.content;
+const consentInstance = document.querySelector(
+  'meta[name="consent-io-instance"]',
+)?.content;
+
+document.addEventListener("DOMContentLoaded", () => {
+  if (!consentInstance || consentInstance.length === 0) return;
+  const { getOrCreateConsentRuntime } = window.c15t;
+
+  const { consentStore } = getOrCreateConsentRuntime({
+    mode: "c15t",
+    backendURL: consentInstance,
+    consentCategories: ["necessary", "measurement", "marketing"],
+    storageConfig: {
+      crossSubdomain: true,
+    },
+    scripts: [
+      {
+        id: "amplitude",
+        src: `https://cdn.amplitude.com/script/${amplitudeKey}.js`,
+        category: "measurement",
+        onLoad: () => {
+          window.amplitude.init(amplitudeKey, {
+            fetchRemoteConfig: true,
+            autocapture: true,
+          });
+        },
+      },
+    ],
+  });
+
+  let previousActiveUI = consentStore.getState().activeUI;
+  const banner = document.getElementById("c15t-banner");
+  const configureSection = document.getElementById("c15t-configure-section");
+  const configureBtn = document.getElementById("c15t-configure-btn");
+  const measurementToggle = document.getElementById("c15t-toggle-measurement");
+  const marketingToggle = document.getElementById("c15t-toggle-marketing");
+
+  const toggleConfigureMode = () => {
+    const currentConsents = consentStore.getState().consents;
+    measurementToggle.checked = currentConsents
+      ? (currentConsents.measurement ?? false)
+      : false;
+    marketingToggle.checked = currentConsents
+      ? (currentConsents.marketing ?? false)
+      : false;
+    configureSection.style.display = "flex";
+    configureBtn.innerHTML = "Save";
+    configureBtn.className = "c15t-button secondary";
+    configureBtn.title = "";
+  };
+
+  consentStore.subscribe((state) => {
+    const hideBanner =
+      state.activeUI === "none" ||
+      (state.activeUI === "banner" && state.mode === "opt-out");
+    banner.style.display = hideBanner ? "none" : "block";
+
+    if (state.activeUI === "dialog" && previousActiveUI !== "dialog") {
+      toggleConfigureMode();
+    }
+
+    previousActiveUI = state.activeUI;
+  });
+
+  configureBtn.addEventListener("click", () => {
+    if (consentStore.getState().activeUI === "dialog") {
+      consentStore
+        .getState()
+        .setConsent("measurement", measurementToggle.checked);
+      consentStore.getState().setConsent("marketing", marketingToggle.checked);
+      consentStore.getState().saveConsents("custom");
+    } else {
+      consentStore.getState().setActiveUI("dialog");
+    }
+  });
+
+  document.getElementById("c15t-accept").addEventListener("click", () => {
+    consentStore.getState().saveConsents("all");
+  });
+
+  document.getElementById("c15t-decline").addEventListener("click", () => {
+    consentStore.getState().saveConsents("necessary");
+  });
+
+  document
+    .getElementById("c15t-manage-consent-btn")
+    .addEventListener("click", () => {
+      consentStore.getState().setActiveUI("dialog");
+    });
+});

docs/theme/consent-banner.css 🔗

@@ -0,0 +1,292 @@
+#c15t-banner {
+  --color-offgray-50: hsl(218, 12%, 95%);
+  --color-offgray-100: hsl(218, 12%, 88%);
+  --color-offgray-200: hsl(218, 12%, 80%);
+  --color-offgray-300: hsl(218, 12%, 75%);
+  --color-offgray-400: hsl(218, 12%, 64%);
+  --color-offgray-500: hsl(218, 12%, 56%);
+  --color-offgray-600: hsl(218, 12%, 48%);
+  --color-offgray-700: hsl(218, 12%, 40%);
+  --color-offgray-800: hsl(218, 12%, 34%);
+  --color-offgray-900: hsl(218, 12%, 24%);
+  --color-offgray-950: hsl(218, 12%, 15%);
+  --color-offgray-1000: hsl(218, 12%, 5%);
+
+  --color-blue-50: oklch(97% 0.014 254.604);
+  --color-blue-100: oklch(93.2% 0.032 255.585);
+  --color-blue-200: oklch(88.2% 0.059 254.128);
+  --color-blue-300: oklch(80.9% 0.105 251.813);
+  --color-blue-400: oklch(70.7% 0.165 254.624);
+  --color-blue-500: oklch(62.3% 0.214 259.815);
+  --color-blue-600: oklch(54.6% 0.245 262.881);
+  --color-blue-700: oklch(48.8% 0.243 264.376);
+  --color-blue-800: oklch(42.4% 0.199 265.638);
+  --color-blue-900: oklch(37.9% 0.146 265.522);
+  --color-blue-950: oklch(28.2% 0.091 267.935);
+
+  --color-accent-blue: hsla(218, 93%, 42%, 1);
+
+  position: fixed;
+  z-index: 9999;
+  bottom: 16px;
+  right: 16px;
+  border-radius: 4px;
+  max-width: 300px;
+  background: white;
+  border: 1px solid
+    color-mix(in oklab, var(--color-offgray-200) 50%, transparent);
+  box-shadow: 6px 6px 0
+    color-mix(in oklab, var(--color-accent-blue) 6%, transparent);
+}
+
+.dark #c15t-banner {
+  border-color: color-mix(in oklab, var(--color-offgray-600) 14%, transparent);
+  background: var(--color-offgray-1000);
+  box-shadow: 5px 5px 0
+    color-mix(in oklab, var(--color-accent-blue) 8%, transparent);
+}
+
+#c15t-banner > div:first-child {
+  padding: 12px;
+  display: flex;
+  flex-direction: column;
+}
+
+#c15t-banner a {
+  color: var(--links);
+  text-decoration: underline;
+  text-decoration-color: var(--link-line-decoration);
+}
+
+#c15t-banner a:hover {
+  text-decoration-color: var(--link-line-decoration-hover);
+}
+
+#c15t-description {
+  font-size: 12px;
+  margin: 0;
+  margin-top: 4px;
+}
+
+#c15t-configure-section {
+  display: flex;
+  flex-direction: column;
+  gap: 8px;
+  border-top: 1px solid var(--divider);
+  padding: 12px;
+}
+
+#c15t-configure-section > div {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+#c15t-configure-section label {
+  text-transform: uppercase;
+  font-size: 11px;
+}
+
+#c15t-footer {
+  padding: 12px;
+  display: flex;
+  justify-content: space-between;
+  border-top: 1px solid var(--divider);
+  background-color: color-mix(
+    in oklab,
+    var(--color-offgray-50) 50%,
+    transparent
+  );
+}
+
+.dark #c15t-footer {
+  background-color: color-mix(
+    in oklab,
+    var(--color-offgray-600) 4%,
+    transparent
+  );
+}
+
+.c15t-button {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  max-height: 28px;
+  color: black;
+  padding: 4px 8px;
+  font-size: 14px;
+  border-radius: 4px;
+  background: transparent;
+  border: 1px solid transparent;
+  transition: 100ms;
+  transition-property: box-shadow, border-color, background-color;
+}
+
+.c15t-button:hover {
+  background: color-mix(in oklab, var(--color-offgray-100) 50%, transparent);
+}
+
+.dark .c15t-button {
+  color: var(--color-offgray-50);
+}
+
+.dark .c15t-button:hover {
+  background: color-mix(in oklab, var(--color-offgray-500) 10%, transparent);
+}
+
+.c15t-button.icon {
+  padding: 0;
+  width: 24px;
+  height: 24px;
+}
+
+.c15t-button.primary {
+  color: var(--color-blue-700);
+  background: color-mix(in oklab, var(--color-blue-50) 60%, transparent);
+  border-color: color-mix(in oklab, var(--color-blue-500) 20%, transparent);
+  box-shadow: color-mix(in oklab, var(--color-blue-400) 10%, transparent) 0 -2px
+    0 0 inset;
+}
+
+.c15t-button.primary:hover {
+  background: color-mix(in oklab, var(--color-blue-100) 50%, transparent);
+  box-shadow: none;
+}
+
+.dark .c15t-button.primary {
+  color: var(--color-blue-50);
+  background: color-mix(in oklab, var(--color-blue-500) 10%, transparent);
+  border-color: color-mix(in oklab, var(--color-blue-300) 10%, transparent);
+  box-shadow: color-mix(in oklab, var(--color-blue-300) 8%, transparent) 0 -2px
+    0 0 inset;
+}
+
+.dark .c15t-button.primary:hover {
+  background: color-mix(in oklab, var(--color-blue-500) 20%, transparent);
+  box-shadow: none;
+}
+
+.c15t-button.secondary {
+  background: color-mix(in oklab, var(--color-offgray-50) 60%, transparent);
+  border-color: color-mix(in oklab, var(--color-offgray-200) 50%, transparent);
+  box-shadow: color-mix(in oklab, var(--color-offgray-500) 10%, transparent)
+    0 -2px 0 0 inset;
+}
+
+.c15t-button.secondary:hover {
+  background: color-mix(in oklab, var(--color-offgray-100) 50%, transparent);
+  box-shadow: none;
+}
+
+.dark .c15t-button.secondary {
+  background: color-mix(in oklab, var(--color-offgray-300) 5%, transparent);
+  border-color: color-mix(in oklab, var(--color-offgray-400) 20%, transparent);
+  box-shadow: color-mix(in oklab, var(--color-offgray-300) 8%, transparent)
+    0 -2px 0 0 inset;
+}
+
+.dark .c15t-button.secondary:hover {
+  background: color-mix(in oklab, var(--color-offgray-200) 10%, transparent);
+  box-shadow: none;
+}
+
+.c15t-switch {
+  position: relative;
+  display: inline-block;
+  width: 32px;
+  height: 20px;
+  flex-shrink: 0;
+}
+
+.c15t-switch input {
+  opacity: 0;
+  width: 0;
+  height: 0;
+  position: absolute;
+}
+
+.c15t-slider {
+  position: absolute;
+  cursor: pointer;
+  inset: 0;
+  background-color: color-mix(
+    in oklab,
+    var(--color-offgray-100) 80%,
+    transparent
+  );
+  border-radius: 20px;
+  box-shadow: inset 0 0 0 1px color-mix(in oklab, #000 5%, transparent);
+  transition: background-color 0.2s;
+}
+
+.c15t-slider:hover {
+  background-color: var(--color-offgray-100);
+}
+
+.dark .c15t-slider {
+  background-color: color-mix(in oklab, #fff 5%, transparent);
+  box-shadow: inset 0 0 0 1px color-mix(in oklab, #fff 15%, transparent);
+}
+
+.dark .c15t-slider:hover {
+  background-color: color-mix(in oklab, #fff 10%, transparent);
+}
+
+.c15t-slider:before {
+  position: absolute;
+  content: "";
+  height: 14px;
+  width: 14px;
+  left: 3px;
+  bottom: 3px;
+  background-color: white;
+  border-radius: 50%;
+  box-shadow:
+    0 1px 3px 0 rgb(0 0 0 / 0.1),
+    0 1px 2px -1px rgb(0 0 0 / 0.1);
+  transition: transform 0.2s;
+}
+
+.c15t-switch input:checked + .c15t-slider {
+  background-color: var(--color-accent-blue);
+  box-shadow: inset 0 0 0 1px color-mix(in oklab, #000 5%, transparent);
+}
+
+.c15t-switch input:checked + .c15t-slider:hover {
+  background-color: var(--color-accent-blue);
+}
+
+.dark .c15t-switch input:checked + .c15t-slider {
+  background-color: var(--color-accent-blue);
+  box-shadow: inset 0 0 0 1px color-mix(in oklab, #fff 15%, transparent);
+}
+
+.c15t-switch input:checked + .c15t-slider:before {
+  transform: translateX(12px);
+}
+
+.c15t-switch input:disabled + .c15t-slider {
+  opacity: 0.5;
+  cursor: default;
+  pointer-events: none;
+}
+
+.c15t-switch input:disabled + .c15t-slider:hover {
+  background-color: color-mix(
+    in oklab,
+    var(--color-offgray-100) 80%,
+    transparent
+  );
+}
+
+#c15t-manage-consent-btn {
+  appearance: none;
+  background: none;
+  border: none;
+  padding: 0;
+  cursor: pointer;
+}
+
+#c15t-manage-consent-btn:hover {
+  text-decoration-color: var(--link-line-decoration-hover);
+}

docs/theme/index.hbs 🔗

@@ -70,6 +70,8 @@
         <!-- MathJax -->
         <script async src="https://cdnjs.cloudflare.com/ajax/libs/mathjax/2.7.1/MathJax.js?config=TeX-AMS-MML_HTMLorMML"></script>
         {{/if}}
+        <meta name="amplitude-key" content="#amplitude_key#" />
+        <meta name="consent-io-instance" content="#consent_io_instance#" />
     </head>
     <body class="no-js">
     <div id="body-container">
@@ -343,6 +345,13 @@
                                 href="https://zed.dev/blog"
                                 >Blog</a
                             >
+                            <span class="footer-separator">•</span>
+                            <button
+                                id="c15t-manage-consent-btn"
+                                class="footer-link"
+                            >
+                                Manage Site Cookies
+                            </button>
                         </footer>
                     </main>
                     <div class="toc-container">
@@ -444,23 +453,82 @@
         {{/if}}
         {{/if}}
 
-        <!-- Amplitude Analytics -->
-        <script>
-          (function() {
-            var amplitudeKey = '#amplitude_key#';
-            if (amplitudeKey && amplitudeKey.indexOf('#') === -1) {
-              var script = document.createElement('script');
-              script.src = 'https://cdn.amplitude.com/script/' + amplitudeKey + '.js';
-              script.onload = function() {
-                window.amplitude.init(amplitudeKey, {
-                  fetchRemoteConfig: true,
-                  autocapture: true
-                });
-              };
-              document.head.appendChild(script);
-            }
-          })();
-        </script>
+        <!-- c15t Consent Banner -->
+        <div id="c15t-banner" style="display: none;">
+            <div>
+                <p id="c15t-description">
+                    Zed uses cookies to improve your experience and for marketing. Read <a href="https://zed.dev/cookie-policy">our cookie policy</a> for more details.
+                </p>
+            </div>
+            <div id="c15t-configure-section" style="display: none">
+                <div>
+                    <label for="c15t-toggle-necessary"
+                        >Strictly Necessary</label
+                    >
+                    <label class="c15t-switch">
+                        <input
+                            type="checkbox"
+                            id="c15t-toggle-necessary"
+                            checked
+                            disabled
+                        />
+                        <span class="c15t-slider"></span>
+                    </label>
+                </div>
+                <div>
+                    <label for="c15t-toggle-measurement">Analytics</label>
+                    <label class="c15t-switch">
+                        <input
+                            type="checkbox"
+                            id="c15t-toggle-measurement"
+                        />
+                        <span class="c15t-slider"></span>
+                    </label>
+                </div>
+                <div>
+                    <label for="c15t-toggle-marketing">Marketing</label>
+                    <label class="c15t-switch">
+                        <input
+                            type="checkbox"
+                            id="c15t-toggle-marketing"
+                        />
+                        <span class="c15t-slider"></span>
+                    </label>
+                </div>
+            </div>
+            <div id="c15t-footer">
+                <button
+                    id="c15t-configure-btn"
+                    class="c15t-button icon"
+                    title="Configure"
+                >
+                    <svg
+                        xmlns="http://www.w3.org/2000/svg"
+                        width="14"
+                        height="14"
+                        viewBox="0 0 24 24"
+                        fill="none"
+                        stroke="currentColor"
+                        stroke-width="2"
+                        stroke-linecap="round"
+                        stroke-linejoin="round"
+                    >
+                        <path d="M20 7h-9" />
+                        <path d="M14 17H5" />
+                        <circle cx="17" cy="17" r="3" />
+                        <circle cx="7" cy="7" r="3" />
+                    </svg>
+                </button>
+                <div>
+                    <button id="c15t-decline" class="c15t-button">
+                        Reject all
+                    </button>
+                    <button id="c15t-accept" class="c15t-button primary">
+                        Accept all
+                    </button>
+                </div>
+            </div>
+        </div>
     </div>
     </body>
 </html>

typos.toml 🔗

@@ -42,6 +42,8 @@ extend-exclude = [
     "crates/gpui_windows/src/window.rs",
     # Some typos in the base mdBook CSS.
     "docs/theme/css/",
+    # Automatically generated JS.
+    "docs/theme/c15t@*.js",
     # Spellcheck triggers on `|Fixe[sd]|` regex part.
     "script/danger/dangerfile.ts",
     # Eval examples for prompts and criteria