Building tools. Learning to build tools. Learning to build learning tools.
Debugging the video feed bug, discovering multiple FluentProviders, and the exhaustive search for a way to switch Teams’ native theme programmatically.
CDP Arch
Theme injection worked perfectly in chat, channels, search — every view in Teams except one. Join a call, and the video feeds disappear. Other participants’ cameras show a solid dark rectangle. Your own self-view is gone. The theme CSS was painting opaque backgrounds over the video elements.
This was the most user-visible bug in v2’s early development. A theme that breaks video calls is worse than no theme at all. Understanding why it happened required digging into how Teams structures its calling UI — and discovering that the calling screen is fundamentally different from the chat screen.
In the chat view, Teams has a single .fui-FluentProvider wrapping
most of the UI. During calls, the DOM gains additional FluentProvider instances.
The observe-calling.js tool revealed at least five during an active
call:
// From observe-calling.js — finding all FluentProviders during a call
const providersResult = await Runtime.evaluate({
expression: `
(() => {
const providers = [...document.querySelectorAll('.fui-FluentProvider')];
return JSON.stringify(providers.map(p => ({
dataTid: p.getAttribute('data-tid') || '',
classes: p.className.substring(0, 100),
tokenCount: [...getComputedStyle(p)]
.filter(prop => prop.startsWith('--')).length,
bounds: (() => {
const r = p.getBoundingClientRect();
return { w: Math.round(r.width), h: Math.round(r.height) };
})(),
})));
})()
`,
returnByValue: true,
});
The key discovery: one FluentProvider carried data-tid="calling-screen-background".
This element was positioned at z-index: -1 with position: fixed,
covering the entire viewport. It served as the backdrop behind the video grid. By
setting its background to a dark color, the theme CSS was painting an opaque layer
that hid the video elements underneath.
Another provider with a meeting-branding identifier appeared for
meetings with custom branding enabled — separate from both the main chat
provider and the calling background provider.
The first attempt was to exclude calling-related FluentProviders from the token overrides:
/* ❌ Initial attempt — exclude calling providers */
.fui-FluentProvider:not([data-tid*="calling"]):not([data-tid*="meeting"]) {
--colorNeutralBackground1: #21252b !important;
/* ... 400 more tokens */
}
This stopped blocking the video feeds, but introduced a new problem: the calling screen UI (buttons, participant names, controls) remained in the default Teams light or dark theme. The call screen looked like a completely different app from the rest of Teams. Half-themed was arguably worse than the original bug.
Inspecting the <html> element during calls revealed something
interesting. In normal chat, the class might be theme-darkV2. During
calls, it changes to theme-defaultV2 — regardless of whether
Teams is in light or dark mode. The “V2” suffix indicates a Fluent UI
v9 rendering context.
This is significant because the html/body background rule
from Section 5 (html { background-color: #21252b; }) was forcing an
opaque background even during calls. The solution: make the rule conditional on
the html class:
/* Normal views — set opaque background */
html:not([class*="V2"]) {
background-color: #21252b !important;
}
html:not([class*="V2"]) body {
background-color: #21252b !important;
}
/* During calls — transparent for video feeds */
html[class*="V2"], html[class*="V2"] body {
background-color: transparent !important;
}
The [class*="V2"] selector catches all calling-related class names
(theme-darkV2, theme-defaultV2, etc.) and forces
transparency. In normal chat views where the html class doesn’t contain
“V2”, the opaque background prevents the WebView2 window color
from bleeding through.
The observe-calling.js script compared tokens from the main
FluentProvider against the calling screen FluentProvider. The result: 388 tokens
had different values. But the token names were identical —
--colorNeutralBackground1 existed in both providers, just with
different hex values.
The calling provider’s tokens were light-mode values. Even when Teams is in dark mode, the calling screen provider uses the light palette. This explains why naively excluding it from overrides produced a light-themed call screen.
Teams uses light-mode tokens for the calling screen because the UI overlays on top of video content (which can be any brightness). Light-colored controls with shadows provide better visibility against arbitrary video backgrounds than dark controls. This is a deliberate design decision, not a bug in Teams — but it causes problems for third-party theming because now we have to override the same token names in two different contexts.
The solution that emerged from this analysis:
.fui-FluentProvider
instances — including the calling screen provider. No exclusions.html/body transparent during calls so video
feeds show through the gaps between UI elements.[data-tid="calling-screen-background"] element’s
background explicitly to the theme’s deepest color./* Apply tokens to ALL FluentProviders — including calling */
.fui-FluentProvider {
--colorNeutralBackground1: #21252b !important;
/* ... all 400+ tokens */
}
/* Calling screen backdrop — explicit bg */
[data-tid="calling-screen-background"] {
background-color: #1b1f23 !important;
}
/* html/body: opaque normally, transparent during calls */
html:not([class*="V2"]) {
background-color: #21252b !important;
}
html[class*="V2"], html[class*="V2"] body {
background-color: transparent !important;
}
Video feeds work because <video> and <canvas>
elements render their content independent of CSS background tokens. They don’t
use --colorNeutralBackground1 — they draw pixels directly. The
issue was never the tokens blocking video; it was the opaque
html/body background painting over the transparent regions
where video feeds are visible.
Teams opens new windows for pop-out chats, calls, and meeting stages. Each window is a new WebView2 instance with its own CDP target. If the injector only connects to the initial target, new windows won’t be themed.
The injector solves this with a polling loop that scans for new CDP targets every 3 seconds:
// From injector.js — continuous target scanning
const TARGET_SCAN_INTERVAL_MS = 3000;
const scanForNewTargets = async () => {
const targets = await findTargets("Teams");
for (const target of targets) {
if (connectedTargetIds.has(target.id)) continue;
const client = await CDP({ port: target._port, target });
const { Runtime, Page } = client;
await Page.enable();
// Inject theme immediately into new target
await Runtime.evaluate({ expression });
console.log(` New target injected: ${target.title}`);
connectedTargetIds.add(target.id);
clients.push({ client, Runtime, Page, target });
// Re-inject on navigation
Page.on("frameNavigated", async (params) => {
if (!params.frame.parentId) {
await Runtime.evaluate({ expression });
}
});
}
};
setInterval(scanForNewTargets, TARGET_SCAN_INTERVAL_MS);
The scan uses a Set of connected target IDs to avoid duplicate connections. When a new target appears, it immediately injects the current theme CSS, registers a navigation handler for re-injection after page transitions, and adds a disconnect listener to clean up if the window closes.
Some themes require Teams to be in a specific native mode. Dimmed themes (Section 3) need light mode as input. Ported VS Code themes (Section 4) work best when Teams is in dark mode, to avoid a white flash on new windows before the theme injects.
The obvious detection approach — reading
localStorage.getItem(’tmp.desktopTheme’) — is unreliable.
The value can be stale, missing, or not reflect the actual visual state after
a theme change. The reliable method: temporarily remove the injected style and
read the actual computed background color:
// From injector.js — reliable mode detection
async function checkMode(Runtime) {
const result = await Runtime.evaluate({
expression: `
(() => {
// Temporarily remove injected theme
const injected = document.getElementById('custom-teams-theme');
if (injected) injected.remove();
// Force style recalc
document.body.offsetHeight;
// Read the actual background
const p = document.querySelector('.fui-FluentProvider');
const bg = getComputedStyle(p)
.getPropertyValue('--colorNeutralBackground1').trim();
// Parse and check brightness
const d = document.createElement('div');
d.style.color = bg;
document.body.appendChild(d);
const rgb = getComputedStyle(d).color.match(/\\d+/g);
document.body.removeChild(d);
const brightness = (0.299*rgb[0] + 0.587*rgb[1] + 0.114*rgb[2]) / 255;
return brightness > 0.5 ? 'light' : 'dark';
})()
`,
returnByValue: true,
});
return result.result.value;
}
This is destructive (it removes the theme briefly) but accurate. The theme is re-injected immediately afterward, so the visual flicker is imperceptible in practice.
Detecting the native mode is useful, but the real goal is to switch it programmatically. If a user selects a dimmed theme but Teams is in dark mode, the injector should switch Teams to light mode automatically. This turned out to be far harder than expected.
Here’s every approach that was tried and why each failed:
| Approach | Result | Why It Failed |
|---|---|---|
localStorage.setItem(’tmp.desktopTheme’, ’dark’) |
No effect | Teams reads but doesn’t watch this key at runtime |
Emulation.setEmulatedMedia |
Changes matchMedia |
Teams doesn’t listen to prefers-color-scheme changes |
Emulation.setAutoDarkModeOverride |
No visible effect | Teams doesn’t use the browser’s auto-dark feature |
Edit app_settings.json |
Reverted on restart | Cloud sync overwrites local settings on startup |
Edit cloud_settings.json |
Reverted on restart | File is downloaded fresh from Microsoft’s servers |
chrome.webview.postMessage interception |
Zero messages | Theme changes don’t use the WebView2 message channel |
Teams’ theme setting lives in the native (Electron/WebView2) layer, not the web layer. CDP gives us full control of the web content, but the native shell is largely opaque. We can read the DOM, execute JavaScript, intercept network requests — but we can’t call native APIs that the Electron/WebView2 host exposes only to its own privileged code.
Since no API exists, the solution is to automate what a human would do: open Settings, navigate to Appearance, change the theme dropdown, and navigate back. CDP can click elements, wait for DOM mutations, and read values — everything needed to drive the UI programmatically.
// From injector.js — automating the Settings UI
async function switchTeamsMode(Runtime, targetMode) {
const label = { dark: "Dark", light: "Light" }[targetMode];
// Helper: wait for element to appear, then click
async function waitAndClick(selector, timeout = 3000) {
const result = await Runtime.evaluate({
expression: `new Promise((resolve) => {
const el = document.querySelector('${selector}');
if (el && el.getBoundingClientRect().width > 0) {
el.click(); resolve('ok'); return;
}
const obs = new MutationObserver(() => {
const el = document.querySelector('${selector}');
if (el && el.getBoundingClientRect().width > 0) {
obs.disconnect(); el.click(); resolve('ok');
}
});
obs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { obs.disconnect(); resolve('timeout'); }, ${timeout});
})`,
returnByValue: true,
awaitPromise: true,
});
return result.result.value === "ok";
}
// Step 1: Open Settings menu
await waitAndClick('[data-tid="more-options-header"]');
await waitAndClick('[data-tid="settings-button-menu"]');
// Step 2: Click Appearance tab
await waitAndClick('[data-tid="appearance"]');
// Step 3: Open theme dropdown and select target
await waitAndClick('[data-tid="appearance-settings-theme-selector-dropdown"]');
// Step 4: Click the target option
await Runtime.evaluate({
expression: `new Promise((resolve) => {
const check = () => {
const opts = [...document.querySelectorAll('[role="option"]')];
const target = opts.find(o => o.textContent?.trim() === '${label}');
if (target) { target.click(); resolve('ok'); return true; }
return false;
};
if (check()) return;
const obs = new MutationObserver(() => { if (check()) obs.disconnect(); });
obs.observe(document.body, { childList: true, subtree: true });
setTimeout(() => { obs.disconnect(); resolve('timeout'); }, 3000);
})`,
returnByValue: true,
awaitPromise: true,
});
await new Promise(r => setTimeout(r, 500));
// Step 5: Navigate back twice (Appearance → General → main view)
for (let i = 0; i < 2; i++) {
await Runtime.evaluate({
expression: `(() => {
const btn = document.querySelector('[data-tid="nav-back"]');
if (btn) { btn.click(); return 'back'; }
history.back(); return 'history-back';
})()`,
returnByValue: true,
});
await new Promise(r => setTimeout(r, 300));
}
}
The waitAndClick() helper uses a MutationObserver instead of polling
with setTimeout. When a Teams UI panel opens asynchronously, the
observer fires as soon as the target element appears in the DOM, then clicks it
and disconnects. This minimizes the delay between each step — the entire
Settings → Appearance → Dropdown → Select → Back → Back
sequence takes about 2–3 seconds.
The MutationObserver approach also handles the case where the element already exists: it checks first, and only sets up the observer if the element isn’t found. A timeout (3 seconds) prevents hanging indefinitely if the UI structure changes.
Settings in Teams is a hierarchical view: General → Appearance is one level
deep. After changing the theme, you need to go back twice to return to
the original view (Appearance → General → chat/channel). The injector
clicks [data-tid="nav-back"] twice with a 300ms delay between clicks
to let the transition animation complete.
If the back button isn’t found (possible if the Settings UI structure
changes), the fallback calls history.back() — the browser
navigation stack may or may not work depending on how Teams manages its
single-page app routing.
[class*="V2"] pattern on <html> distinguishes calling windows from normal viewsThe injector now handles chat, channels, and calls correctly — with automatic mode detection and switching. The next section looks ahead: what’s still fragile, how to maintain themes across Teams updates, and where this project could go.