130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

0. The Problem & the Opportunity

How a missing API and an exposed debug protocol create an opportunity for custom theming.

CDP Arch

The Gap in Teams’ Theming

Microsoft Teams ships with exactly three appearance options: Light, Dark, and High Contrast. There is no theme API, no extension point, no CSS override mechanism, and no user-facing way to tweak colors. If you find the white background too bright, the dark mode too stark, or you simply want your chat app to match the VS Code theme you stare at all day — you’re out of luck. Or so it seems.

The “new” Teams (the Electron-to-WebView2 rewrite Microsoft shipped in late 2023) changed the runtime architecture in a way that opens the door to customization. Understanding that architecture is the key to everything that follows.

WebView2: Chromium Inside a Native Shell

Classic Teams was an Electron app — Node.js + Chromium bundled together. The new Teams replaces Electron with WebView2, Microsoft’s embeddable Chromium control. The native shell (written in C++ / .NET) hosts a WebView2 instance that renders the same React/Fluent UI web application you’d see in a browser at teams.microsoft.com.

WebView2 is built on Chromium, and Chromium exposes the Chrome DevTools Protocol (CDP) — a JSON-over-WebSocket API that lets external tools inspect, debug, and manipulate a running browser. CDP is how Chrome DevTools, Playwright, and Puppeteer work. If you can enable it on the Teams WebView2 instance, you gain the same level of access to Teams’ DOM that DevTools gives you on a regular web page.

CDP Concept

The Chrome DevTools Protocol exposes “domains” like Runtime, Page, DOM, and Network. Each domain has methods you can call over a WebSocket. For theme injection, we only need two: CDP.List() to discover page targets, and Runtime.evaluate() to run JavaScript inside the page context.

Enabling the Debug Port

WebView2 doesn’t expose CDP by default. You have to opt in by passing --remote-debugging-port=PORT to the Chromium engine at startup. For standalone apps like Teams and Outlook, Microsoft provides a per-application registry key mechanism:

# Registry path for per-app WebView2 arguments
$regPath = "HKCU:\Software\Policies\Microsoft\Edge\WebView2\AdditionalBrowserArguments"

# Assign dedicated ports to each app
New-Item -Path $regPath -Force | Out-Null
Set-ItemProperty -Path $regPath -Name "ms-teams.exe" -Value "--remote-debugging-port=9222"
Set-ItemProperty -Path $regPath -Name "olk.exe"       -Value "--remote-debugging-port=9223"

Each value name is the executable filename. When ms-teams.exe launches, the WebView2 loader reads this key and appends the flag. After a restart, Teams listens on localhost:9222 for CDP connections.

Warning

An older approach used the WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS environment variable. This is a global setting — every WebView2 app on your system reads it. If Teams and Outlook both try to use port 9222, one will fail to start. The per-app registry key approach avoids this entirely. The setup.ps1 script in the project automatically removes the legacy env var if it finds one.

Discovering Page Targets

Once the debug port is active, you can enumerate available targets by calling CDP.List(). This returns an array of objects describing each browsing context — pages, service workers, iframes. We filter for type: "page" to find the main Teams window:

const CDP = require("chrome-remote-interface");

// List all page targets on the Teams debug port
const targets = await CDP.List({ port: 9222 });
const pages = targets.filter(t => t.type === "page");

// Each target has:
// {
//   id: "E3F2A1...",
//   title: "Microsoft Teams",
//   url: "https://teams.microsoft.com/v2/...",
//   type: "page",
//   webSocketDebuggerUrl: "ws://localhost:9222/devtools/page/E3F2A1..."
// }

The webSocketDebuggerUrl is the WebSocket endpoint for CDP commands. The chrome-remote-interface npm package wraps this into a clean async API. In the codebase, the shared cdp.js module handles port scanning, target identification, and connection:

// From cdp.js — identifying which app a target belongs to
function identifyApp(target) {
  const url = (target.url || "").toLowerCase();
  const title = (target.title || "").toLowerCase();
  if (url.includes("teams.microsoft") || url.includes("teams.live")) return "Teams";
  if (url.includes("outlook.office") || url.includes("outlook.live"))  return "Outlook";
  if (title.includes("teams")) return "Teams";
  if (title.includes("outlook") || title.includes("mail")) return "Outlook";
  return "WebView2";
}

The module also implements a fallback port scan. If the configured ports yield no targets, it probes ports 9222–9231 looking for any WebView2 instance. This handles cases where the debug port was assigned a non-default value.

Connecting & Evaluating JavaScript

With a target identified, we open a CDP session and use Runtime.evaluate() to execute arbitrary JavaScript inside the Teams page context:

// From cdp.js — connecting to the first Teams target
async function connectToTeams() {
  const targets = await findTargets("Teams");
  if (targets.length === 0) {
    throw new Error("No Teams target found. Is CDP enabled?");
  }

  // Prefer the primary window (title contains "|")
  const target =
    targets.find(t => t.title && t.title.includes("|")) || targets[0];

  const client = await CDP({ port: target._port, target });
  const { Runtime, Page } = client;
  await Page.enable();

  return { client, Runtime, Page, target };
}

With this connection, any JavaScript you pass to Runtime.evaluate() runs as if you typed it in the browser console. You have full access to the DOM, computed styles, localStorage, and every API the page uses.

The Injection Strategy

The core idea is simple: create a <style> element, fill it with CSS custom property overrides, and append it to <head>. The injected element gets an ID so it can be found and replaced on subsequent injections:

// From injector.js — building the injection expression
function buildInjectionExpression(css) {
  const escaped = css.replace(/\\/g, "\\\\").replace(/`/g, "\\`");
  return `
    (() => {
      let el = document.getElementById('custom-teams-theme');
      if (el) el.remove();
      const style = document.createElement('style');
      style.id = 'custom-teams-theme';
      style.textContent = \`${escaped}\`;
      document.head.appendChild(style);
    })();
  `;
}

This pattern is idempotent — calling it again replaces the previous theme rather than stacking overrides. Removal is equally straightforward:

function buildRemoveExpression() {
  return `
    (() => {
      const el = document.getElementById('custom-teams-theme');
      if (el) { el.remove(); return 'removed'; }
      return 'not found';
    })();
  `;
}

Why .fui-FluentProvider and !important

If you try the naïve approach — setting CSS custom properties on :root — nothing happens. Teams uses Fluent UI React v9, which applies its design tokens (CSS custom properties) on the .fui-FluentProvider element, not on :root. The FluentProvider wrapper sits near the top of the React tree and scopes all token values to its subtree.

Design System Concept

Fluent UI v9 uses a library called Griffel for CSS-in-JS. Griffel generates atomic CSS classes at build time and applies them with high specificity. Its styles are injected into <style> tags in <head>, and they reference the custom properties set on .fui-FluentProvider. Our overrides must target the same element with enough specificity to win. The !important flag ensures our values take precedence over Griffel’s injected styles.

A theme CSS file targets the FluentProvider and overrides every token:

/* Generated theme file — targets the Fluent UI provider */
.fui-FluentProvider {
  --colorNeutralBackground1: #d4cfc7 !important;
  --colorNeutralBackground2: #cac5bd !important;
  --colorNeutralBackground3: #c0bbb3 !important;
  --colorNeutralForeground1: #1a1a1a !important;
  --colorNeutralForeground2: #2e2e2e !important;
  /* ... 490+ more token overrides ... */
}

Architecture Overview

The complete injection flow involves four components communicating over two protocols:

┌─────────────────────────────────────────────────────────┐ │ Your Machine │ │ │ │ ┌──────────────┐ WebSocket (CDP) ┌───────────┐ │ │ │ │◄──────────────────────►│ │ │ │ │ injector.js │ Runtime.evaluate() │ Teams │ │ │ │ (Node.js) │─────────────────────► │ WebView2 │ │ │ │ │ │ │ │ │ │ Reads: │ DOM Mutation │ Renders: │ │ │ │ themes/*.css│ ◄─ ─ ─ ─ ─ ─ ─ ─ ─ ─ │ Fluent UI │ │ │ │ theme.config│ │ React App │ │ │ └──────────────┘ └───────────┘ │ │ │ ▲ │ │ │ fs.watch() │ │ │ ▼ │ │ │ ┌──────────────┐ ┌────────┴──────┐ │ │ │ Theme CSS │ │ <style id= │ │ │ │ File on │ ── inject ──► │ "custom- │ │ │ │ Disk │ │ teams-theme">│ │ │ └──────────────┘ └───────────────┘ │ │ │ │ Registry: HKCU:\...\AdditionalBrowserArguments │ │ ms-teams.exe = "--remote-debugging-port=9222" │ └─────────────────────────────────────────────────────────┘

Live Reload via File Watcher

The injector doesn’t just inject once — it watches the theme CSS file for changes using fs.watch(). When you edit and save the theme file, the injector reads the new CSS, rebuilds the injection expression, and pushes it to all connected Teams windows. Changes appear in under a second, making iterative theme development fast and visual.

// From injector.js — file watcher for live reload
watcher = fs.watch(themeFile, { persistent: true }, (eventType) => {
  if (eventType === "change") {
    const css = fs.readFileSync(themeFile, "utf-8");
    expression = buildInjectionExpression(css);
    injectAll();  // Push to all connected clients
    console.log(`  Re-injected (file changed)`);
  }
});

Multi-Target Injection

Teams can have multiple WebView2 page targets — the main window, pop-out chats, meeting windows, and the calling overlay. The injector connects to all of them via connectToAllTeams() and periodically scans for new targets that may have appeared since startup:

// Periodic scan for new targets (pop-outs, meeting windows)
setInterval(async () => {
  const targets = await findTargets("Teams");
  for (const t of targets) {
    if (!connectedTargetIds.has(t.id)) {
      // New target discovered — connect and inject
      const client = await CDP({ port: t._port, target: t });
      await client.Page.enable();
      await client.Runtime.evaluate({ expression, returnByValue: true });
      connectedTargetIds.add(t.id);
    }
  }
}, TARGET_SCAN_INTERVAL_MS);
Tip

The theme must be re-injected each time Teams navigates internally (e.g., switching between Chat and Calendar). The injector handles this by polling and re-injecting on a 5-second interval, ensuring the theme persists across navigation events.

Mode Detection & Auto-Switching

Since dimmed themes are derived from the light palette, they look wrong if Teams is in Dark mode. The injector detects the native mode and can automatically switch Teams to the preferred mode by driving the Settings UI via CDP:

// From injector.js — detect native mode by temporarily removing injected style
async function checkMode(Runtime) {
  const result = await Runtime.evaluate({
    expression: `
      (() => {
        const injected = document.getElementById('custom-teams-theme');
        if (injected) injected.remove();

        // Force style recalc
        document.body.offsetHeight;

        const p = document.querySelector('.fui-FluentProvider');
        let mode = 'unknown';
        if (p) {
          const bg = getComputedStyle(p)
            .getPropertyValue('--colorNeutralBackground1').trim();
          // ... parse and check luminance ...
          mode = brightness > 0.5 ? 'light' : 'dark';
        }

        return mode;
      })()
    `,
    returnByValue: true,
  });
  return result.result.value;
}

The trick: temporarily remove the injected style, force a layout recalculation, read the native background color, and then re-inject. This gives the true underlying mode rather than the visually rendered one.

What We’ve Established

The approach is established. But what CSS values do we actually inject? We can’t just guess — Teams uses nearly 500 design tokens. In the next section, we’ll build an observation pipeline that extracts every token from a live Teams instance.