Building tools. Learning to build tools. Learning to build learning tools.
Why design tokens alone can’t theme everything, and how stable DOM attributes let us target the elements that Griffel’s atomic CSS makes unreachable.
Design CDP
After applying 400+ token overrides to the .fui-FluentProvider element,
you’d expect every surface in Teams to reflect the new palette. Open Teams
with an injected theme, though, and you’ll see white gaps bleeding through:
the title bar, the compose box background, the chat list sidebar. These elements
stubbornly ignore your tokens.
The culprit is Griffel — Microsoft’s CSS-in-JS library
used by Fluent UI v9. Griffel generates atomic CSS classes: each class
sets exactly one property. Instead of a semantic class like
.sidebar { background-color: var(--colorNeutralBackground2); }, Griffel
generates something like:
/* Griffel atomic class — sets background-color directly */
.___1qrapf3 {
background-color: #ffffff;
}
The hardcoded #ffffff wins over any CSS custom property override on
a parent element. Griffel extracts the resolved value of the design token
at build time and bakes it into the atomic class. At runtime, changing the token
doesn’t affect elements styled by these pre-resolved classes.
CSS custom properties (like --colorNeutralBackground1) on a parent
element are inherited, not directly applied. When a Griffel atomic class
sets background-color: #ffffff on an element, that declaration has
higher specificity than an inherited custom property. The only way to override it
is with a rule that targets the same element with equal or greater specificity,
or uses !important.
The most visible problem is the <html> and
<body> elements themselves. Teams runs inside a WebView2 control,
and the native window has its own background color. If the web content’s
<body> has a white background, it shows through every gap between
Fluent UI components — margins, padding, scroll gutters. Token overrides
don’t help because <html> and <body>
aren’t inside .fui-FluentProvider.
The fix is straightforward: set the html/body background directly. But as Section 6 will explain, this has to be conditional — during calls, the body must be transparent to allow video feeds through.
If Griffel generates atomic classes like .___1qrapf3 for the sidebar
background, could we just target that class in our CSS override?
/* ❌ Don't do this — hash changes on every Teams update */
.___1qrapf3 {
background-color: #21252b !important;
}
No. Griffel class names are content hashes of the style definition. Any
change to the component — even a minor prop adjustment or Fluent UI version
bump — produces a different hash. After the next Teams update,
.___1qrapf3 becomes .___17x8g2a and your override silently
breaks. The theme looks fine for two weeks, then spontaneously fails.
We need selectors that are stable across updates.
The DOM exploration phase (explore-dom.js) revealed that Teams marks
its major UI regions with data-tid and data-testid
attributes. These exist for testing purposes — they let Teams’ own
automated tests find elements regardless of class name changes. They’re
exactly what we need: stable identifiers that survive updates.
// From explore-dom.js — discovering all data-tid elements
const tidResult = await Runtime.evaluate({
expression: `
(() => {
const els = document.querySelectorAll('[data-tid]');
const results = [];
for (const el of els) {
const rect = el.getBoundingClientRect();
if (rect.width === 0 && rect.height === 0) continue;
results.push({
tid: el.getAttribute('data-tid'),
tag: el.tagName.toLowerCase(),
bounds: {
x: Math.round(rect.x),
y: Math.round(rect.y),
w: Math.round(rect.width),
h: Math.round(rect.height)
},
});
}
return JSON.stringify(results);
})()
`,
returnByValue: true,
});
The explore-dom.js tool connects to Teams via CDP and does a
comprehensive inventory of the live DOM. It maps the document tree to four levels
deep, captures every data-tid and data-testid attribute,
records bounding rectangles, ARIA labels, and roles. The output is a JSON file
that serves as a blueprint for writing stable selectors.
Running explore-dom.js on a typical Teams chat view produces results
like this:
Each of these provides a stable CSS selector. Instead of targeting the unpredictable Griffel hash classes, we use:
[data-tid="title-bar"] { /* stable */ }
[data-tid="app-bar-wrapper"] { /* stable */ }
[data-testid="simple-collab-rail"] { /* stable */ }
[data-tid="entity-header"] { /* stable */ }
[data-tid="message-pane-body"] { /* stable */ }
[data-tid="ckeditor"] { /* stable */ }
| UI Region | Selector | Palette Role |
|---|---|---|
| Title bar | [data-tid="title-bar"] |
titleBar (special) |
| App bar (left icon rail) | [data-tid="app-bar-wrapper"] |
bg.base |
| Chat/channel list | [data-testid="simple-collab-rail"] |
bg.base |
| Content header | [data-tid="entity-header"] |
bg.elevated |
| Message area | [data-tid="message-pane-body"] |
bg.elevated |
| Compose box | [data-tid="ckeditor"] |
bg.elevated |
The buildCSS() function in port-theme.js generates the
complete theme CSS file. It produces two sections: the token map (applied to
.fui-FluentProvider) and the hardcoded overrides (targeting specific
elements by data-tid).
// From port-theme.js — the CSS generator
function buildCSS(palette, tokenMap, observedTokens) {
const P = palette;
const lines = [];
// ── Section 1: Token overrides on FluentProvider ──
lines.push(".fui-FluentProvider {");
for (const [token, value] of Object.entries(tokenMap)) {
lines.push(` ${token}: ${value} !important;`);
}
lines.push("}");
// ── Section 2: Hardcoded overrides ──
// html/body background (conditional for calls)
lines.push(`html:not([class*="V2"]) {`);
lines.push(` background-color: ${P.bg.base} !important;`);
lines.push(`}`);
// Title bar
lines.push(`.fui-FluentProvider [data-tid="title-bar"] {`);
lines.push(` background-color: var(--titleBar) !important;`);
lines.push(`}`);
// App bar
lines.push(`.fui-FluentProvider [data-tid="app-bar-wrapper"] {`);
lines.push(` background-color: ${P.bg.base} !important;`);
lines.push(`}`);
// Chat list
lines.push(`.fui-FluentProvider [data-testid="simple-collab-rail"] {`);
lines.push(` background-color: ${P.bg.base} !important;`);
lines.push(`}`);
// ... more targeted overrides
}
Even with targeted selectors, some Griffel-styled elements sit deep in the component tree and resist overrides. The nuclear option is a descendant wildcard that re-propagates key token values to every child element:
/* Token propagation for Griffel atomic overrides */
.fui-FluentProvider * {
--colorNeutralBackground1: #21252b;
--colorNeutralBackground2: #1b1f23;
--colorNeutralBackground3: #282c34;
}
This is a blunt instrument but effective. By setting the custom property directly
on every element (not just the provider), any Griffel class that uses
var(--colorNeutralBackground1) will pick up the override. The
performance impact is negligible — CSS custom properties are inheritable by
default, so the browser already tracks them through the tree. Setting them
explicitly on * just ensures that inline-style-like Griffel
declarations can’t escape.
The wildcard approach works today because Griffel uses
var(--tokenName) in most of its atomic classes. If a future Griffel
version resolves tokens at build time (replacing
var(--colorNeutralBackground1) with #ffffff directly),
the wildcard trick would stop working. We’d need even more targeted
overrides. This is the fundamental fragility of overriding a CSS-in-JS system
from the outside.
Chat messages are one of the most visually prominent elements. Fluent UI uses
two distinct component classes: .fui-ChatMessage__body for messages
from other people, and .fui-ChatMyMessage__body for your own messages.
These aren’t data-tid elements — they’re Fluent UI
component class names, which are more stable than Griffel hashes because they’re
public API rather than generated internals.
/* Chat messages — other people */
.fui-FluentProvider .fui-ChatMessage__body {
background-color: #2c313a !important;
}
/* Chat messages — my messages (tinted with brand color) */
.fui-FluentProvider .fui-ChatMyMessage__body {
background-color: #2b3140 !important;
}
The distinction matters: “my message” backgrounds typically get a subtle
brand tint to visually distinguish them. The palette’s myMessageBg
role captures this — a dark blue-tinted variant of the base background in
Atom One Dark, for example.
Teams shows unread message counts as small colored badges on channel and chat
entries. These use the .fui-CounterBadge component class:
/* Notification badges — custom brand color */
.fui-FluentProvider .fui-CounterBadge {
background-color: #e5c07b !important; /* warning.fg */
color: #282c34 !important; /* bg.elevated */
}
Using the warning color (gold/yellow) for badges makes them stand out against dark backgrounds without the aggressive red that Teams uses by default. This is an opinionated design choice — each theme can customize the badge palette role to whatever feels right.
The compose box ([data-tid="ckeditor"]) uses a CKEditor instance.
Its placeholder text (“Type a new message”) is set via a
::before pseudo-element on a [data-placeholder] attribute
or a .ck-placeholder class. Both need to be targeted:
/* Compose box placeholder text */
.fui-FluentProvider [data-tid="ckeditor"] [data-placeholder]::before,
.fui-FluentProvider [data-tid="ckeditor"] .ck-placeholder::before {
color: #636d83 !important; /* fg.subtle */
}
Without this override, the placeholder remains an unreadable near-white text on a light background — or worse, inherits a dark color from the token override that doesn’t contrast with the compose box’s background.
Not all selectors are equally fragile. Here’s the observed stability ranking:
| Selector Type | Stability | Example |
|---|---|---|
| CSS custom property names | ★★★★★ | --colorNeutralBackground1 |
| Fluent component classes | ★★★★ | .fui-ChatMessage__body |
data-tid attributes |
★★★★ | [data-tid="title-bar"] |
data-testid attributes |
★★★ | [data-testid="simple-collab-rail"] |
| ARIA roles/labels | ★★ | [role="navigation"] |
| Griffel hash classes | ★ | .___1qrapf3 |
The theme generator uses the highest-stability selectors available for each target element. Token overrides handle the vast majority of the UI; hardcoded selectors are the fallback for elements where Griffel bakes in resolved values.
.___1qrapf3) change on every Teams update — unusable as selectorsexplore-dom.js discovers stable data-tid and data-testid attributesbuildCSS() generates both token overrides and targeted hardcoded rules.fui-FluentProvider * wildcard re-propagates tokens to all children for Griffel resilience.fui-ChatMessage__body) which are more stable than hashesTokens handle the system; hardcoded overrides handle the exceptions. Together they produce a fully themed Teams UI — until you join a call. The next section covers the calling screen bug and the discovery that Teams has multiple FluentProviders with different token sets.