Building tools. Learning to build tools. Learning to build learning tools.
Understanding what can break, building self-healing mechanisms, and exploring where this reverse-engineering approach leads.
Arch Design
Theme injection into a closed-source application is inherently fragile. We’re overriding a system designed to not be overridden. Every Teams update is a potential breaking change. Let’s be honest about what can break and how likely each failure mode is.
| Fragility Point | Likelihood | Impact | Detection |
|---|---|---|---|
data-tid selectors renamed or removed |
Low–Medium | Specific UI regions lose theming (e.g., title bar reverts to default) | Visual — affected elements are obvious |
| FluentProvider token names changed | Low | Entire theme stops working (wrong colors everywhere) | Immediately visible |
| Calling screen structure rearchitected | Medium | Video feed bug returns or calling UI unthemed | Requires joining a call to verify |
| Settings UI navigation paths changed | Medium | Auto mode-switching fails; themes still work, just can’t auto-switch | Console warning from switchTeamsMode() |
| Griffel resolves tokens at build time | Low | Token overrides stop affecting Griffel-styled elements | Subtle — individual components lose theming |
| CDP debug port disabled by Microsoft | Low | Total failure — no connection possible | Immediate — injector can’t connect |
The most important insight: token names are the most stable part of this
system. Fluent UI v9’s design tokens are essentially a public API —
documented, versioned, and used across multiple Microsoft products. Renaming
--colorNeutralBackground1 would break every Fluent UI consumer, not
just our theme injector. This makes the core token override approach relatively
durable.
The fragile parts are the hardcoded overrides — the targeted selectors for specific elements (Section 5) and the calling screen workarounds (Section 6). These depend on implementation details that Teams can change without notice.
The observation and analysis tools from Sections 1 and 2 aren’t just for initial development — they’re the maintenance strategy. When a Teams update breaks something, the tools can diagnose what changed:
After a Teams update, run explore-dom.js to re-inventory all
data-tid and data-testid elements. Compare the output
against the previous run. Did data-tid="title-bar" disappear? Was it
renamed to data-tid="titlebar"? The JSON diff tells you exactly what
changed and which CSS selectors need updating.
// Quick diff between two explore-dom.js outputs
const fs = require("fs");
const prev = JSON.parse(fs.readFileSync("output/dom-exploration-old.json"));
const curr = JSON.parse(fs.readFileSync("output/dom-exploration.json"));
const prevTids = new Set(prev.tids.map(t => t.tid));
const currTids = new Set(curr.tids.map(t => t.tid));
const removed = [...prevTids].filter(t => !currTids.has(t));
const added = [...currTids].filter(t => !prevTids.has(t));
console.log("Removed:", removed); // selectors that broke
console.log("Added:", added); // potential replacements
Similarly, observe.js can re-extract all tokens from a new Teams
version. If token names changed, the diff between runs reveals it immediately.
If new tokens appeared, they might need to be added to the buildTokenMap()
function. If tokens disappeared, their mappings can be removed.
The calling observation script can be re-run after updates to verify that the
FluentProvider structure hasn’t changed. If the
data-tid="calling-screen-background" element moves, gains a new
identifier, or uses a different positioning strategy, the diff will show it.
The most effective maintenance strategy is simply running the observation pipeline after each major Teams update. It takes ~30 seconds and produces a complete picture of what changed. The alternative — waiting until something visually breaks and then debugging — is far more time-consuming.
The dimmed themes from Section 3 are the most resilient themes in the
system. They only override tokens on .fui-FluentProvider — no
hardcoded selectors, no data-tid targeting, no wildcard propagation.
If a token name changes, every dimmed theme breaks identically and the fix is a
single rename in the token map.
Ported VS Code themes (Section 4) are moderately resilient. They use tokens for
the vast majority of the UI but add hardcoded overrides for elements where Griffel
bypasses tokens. If a data-tid changes, the specific UI region loses
theming but the rest still works.
This creates a clear strategy for minimizing fragility:
data-tid over Fluent component classes, and never use Griffel hashes.The 25-role palette system means adding a new theme is a matter of defining ~30 colors. But even that could be simpler:
port-all.js currently reads VS Code theme JSON files. The same
approach could be extended to other theme formats:
bg.base, text → fg.primary,
accent → brand.primary, etc.
Currently, defining a manual palette requires writing a JavaScript object in
port-theme.js. A JSON-based palette format would let users define
themes without touching the codebase:
{
"label": "My Custom Theme",
"bg": {
"deep": "#0d1117",
"base": "#161b22",
"elevated": "#1c2128",
"subtle": "#21262d",
"wash": "#30363d"
},
"fg": {
"emphasis": "#f0f6fc",
"primary": "#c9d1d9",
"muted": "#8b949e",
"subtle": "#6e7681",
"faint": "#484f58"
},
"brand": {
"primary": "#1f6feb",
"fg": "#58a6ff"
}
}
The porter engine would read this JSON, fill in missing roles with sensible
defaults (e.g., generate brand.hover from brand.primary
+ lightening), and produce a complete theme CSS. Adding a theme becomes editing
a JSON file and running a command.
Modern CSS supports @layer declarations that control specificity
ordering. If Griffel’s atomic classes were in a lower cascade layer and our
overrides were in a higher one, we wouldn’t need !important
everywhere:
/* Hypothetical — if Teams supported cascade layers */
@layer griffel, theme-override;
@layer theme-override {
.fui-FluentProvider {
--colorNeutralBackground1: #21252b;
/* No !important needed — layer wins */
}
}
Unfortunately, we can’t control the layer that Griffel’s classes are injected into. But if Fluent UI v9 ever adds cascade layer support, it would dramatically simplify third-party theming.
CSS containment (contain: style) limits the scope of CSS custom
properties, preventing them from leaking out of a subtree. This is the opposite
of what we want — we need our token overrides to leak into every
subtree. But understanding containment helps diagnose cases where tokens
mysteriously don’t apply: if a component sets contain: style,
our inherited custom properties won’t reach its children.
A dedicated health check tool could automate post-update validation:
// Concept: theme health check
async function checkThemeHealth(Runtime) {
const checks = [
// Verify key selectors still exist
{ selector: '[data-tid="title-bar"]', name: 'Title bar' },
{ selector: '[data-tid="app-bar-wrapper"]', name: 'App bar' },
{ selector: '[data-tid="message-pane-body"]', name: 'Message pane' },
{ selector: '[data-tid="ckeditor"]', name: 'Compose box' },
];
for (const check of checks) {
const exists = await Runtime.evaluate({
expression: `!!document.querySelector('${check.selector}')`,
returnByValue: true,
});
console.log(` ${exists.result.value ? '✓' : '✗'} ${check.name}`);
}
// Verify token overrides are taking effect
const bg = await Runtime.evaluate({
expression: `getComputedStyle(
document.querySelector('.fui-FluentProvider')
).getPropertyValue('--colorNeutralBackground1').trim()`,
returnByValue: true,
});
console.log(` Token check: --colorNeutralBackground1 = ${bg.result.value}`);
}
A standard JSON palette format (as described above) would enable a theme marketplace — a repository of community-created palettes that anyone can download, preview, and inject. The porter engine converts palette JSON to CSS automatically; sharing themes becomes sharing a 30-line JSON file.
The injector already supports hot-reloading: edit the CSS file and it re-injects within 300ms. Extending this to a “preview mode” — inject temporarily, let the user evaluate, and revert if they decline — would make theme selection more interactive.
// Concept: temporary theme preview
async function previewTheme(themePath, durationMs = 10000) {
const css = fs.readFileSync(themePath, "utf-8");
const expr = buildInjectionExpression(css);
await injectIntoAll(expr);
console.log(`Previewing ${path.basename(themePath)}...`);
console.log(`Press Enter to keep, or wait ${durationMs/1000}s to revert.`);
const kept = await Promise.race([
new Promise(r => process.stdin.once("data", () => r(true))),
new Promise(r => setTimeout(() => r(false), durationMs)),
]);
if (!kept) {
// Revert to previous theme
await injectIntoAll(previousExpression);
console.log("Reverted.");
}
}
The v1 system already supported Outlook (new Outlook for Windows uses the same WebView2 + Fluent UI architecture as Teams). Porting v2’s improved engine to Outlook would extend the same approach to a second Microsoft 365 app. The token names are largely shared between Teams and Outlook, so the palette system would work with minimal adaptation.
Teams for web (teams.microsoft.com) runs in a regular browser tab. A browser
extension could inject the same CSS without needing CDP. The extension would use
the standard chrome.tabs.insertCSS API or a content script. The
same theme CSS files work unchanged — they don’t depend on the
injection mechanism.
With the palette format standardized and auto-extraction from VS Code working,
the barrier to contributing a theme is low: install a VS Code theme, run
port-all.js, review the output, commit. Community contributions
could rapidly expand the theme library beyond what a single developer can
manually curate.
The single most valuable principle in this project: understand the system before you override it. v1 guessed at token values and fought the design system. v2 observed every token, analyzed the patterns, and worked with the structure. The difference in results — and in long-term maintainability — is dramatic.
This applies beyond theming. Any time you’re extending, modifying, or working around a system you don’t control, instrument first. Read every computed style, log every event, map every element. The upfront investment in observation saves multiples in debugging and guessing.
The Chrome DevTools Protocol gives you everything the Chrome DevTools UI does and more: DOM inspection, JavaScript evaluation, network interception, screenshots, emulation, runtime profiling. Most developers interact with CDP indirectly through DevTools or Puppeteer. Working with the protocol directly revealed capabilities that aren’t exposed in the standard DevTools UI — like evaluating expressions in specific execution contexts or intercepting specific CDP domains.
Despite fighting it throughout this project, the Fluent UI token system deserves praise. The layered background hierarchy, the consistent foreground ramp, the interaction state pattern — these are thoughtfully designed. The 25-role palette system only works because Fluent UI’s token structure is coherent and systematic. A chaotic token system would require chaotic overrides.
Every challenge in this project that couldn’t be solved elegantly was at the boundary between the web layer (where CDP works) and the native layer (where it doesn’t). Theme switching required UI automation because the setting lives in native storage. Video feed transparency required understanding how the WebView2 compositor layers web content and native windows. The CDP debug port itself is a configuration that the native shell controls.
If you’re working with WebView2 or Electron apps, expect the native boundary to be the source of your most difficult bugs.
You’ve worked through the entire Theme Injector tutorial — from connecting to Teams via CDP, through observing and analyzing the Fluent UI token system, to generating themes from a 25-color palette and handling edge cases like video feeds and native theme switching.
Here’s what you’ve learned:
The techniques here extend far beyond Teams theming. The observation → analysis → systematic override pipeline applies to any application you want to customize from the outside. The palette → token mapping pattern applies to any design system with structured tokens. And CDP’s capabilities go far beyond what was covered here — network interception, performance profiling, accessibility auditing, and more.
The best way to internalize these concepts is to apply them. Pick an Electron or WebView2 application you use daily. Connect to it via CDP. Observe its design tokens. Analyze the patterns. Build something. The worst that can happen is you learn how the application works — and that knowledge is valuable regardless of whether your theme or extension ships.