Building tools. Learning to build tools. Learning to build learning tools.
Don’t guess what Teams uses — measure it. Extracting every design token, computed color, and DOM landmark from a running instance.
CDP Design
It’s tempting to open the Fluent UI documentation, look up token names, and start guessing values. Don’t. Documentation describes intent; the running app reveals reality. Token names get renamed between Fluent UI versions. Some documented tokens aren’t used. Some undocumented ones are critical. The only reliable source of truth is the live DOM of a running Teams instance.
The observation pipeline connects to Teams via CDP and programmatically extracts everything we need: every CSS custom property, every computed color, and the actual DOM selectors used by the UI. This data becomes the foundation for analysis (Section 2) and theme generation (Section 3).
All of Teams’ design tokens live as CSS custom properties on the
.fui-FluentProvider element. The observation script reads every one of
them by iterating the element’s computed style:
// From observe.js — extracting all CSS custom properties
const tokenResult = await Runtime.evaluate({
expression: `
(() => {
const provider = document.querySelector('.fui-FluentProvider');
if (!provider) return JSON.stringify({
error: 'No .fui-FluentProvider element found'
});
const cs = getComputedStyle(provider);
const tokens = {};
for (let i = 0; i < cs.length; i++) {
const prop = cs[i];
if (prop.startsWith('--')) {
tokens[prop] = cs.getPropertyValue(prop).trim();
}
}
return JSON.stringify(tokens);
})()
`,
returnByValue: true,
});
const tokens = JSON.parse(tokenResult.result.value);
Runtime.evaluate() executes a JavaScript expression in the page’s
main context and returns the result. The returnByValue: true option
serializes the return value so we get the actual data rather than a remote object
reference. Since we’re returning a JSON string, we parse it on the Node.js side.
The result: 498 CSS custom properties. These aren’t just colors — the token set includes spacing, typography, borders, shadows, corner radii, and motion timing. Here’s a sampling of what comes back:
{
"--colorNeutralBackground1": "#ffffff",
"--colorNeutralBackground2": "#fafafa",
"--colorNeutralBackground3": "#f5f5f5",
"--colorNeutralForeground1": "#242424",
"--colorNeutralForeground2": "#424242",
"--colorBrandBackground": "#5b5fc7",
"--colorBrandForeground1": "#5b5fc7",
"--colorPaletteRedBackground3": "#d13438",
"--colorStatusDangerForeground1": "#b10e1c",
"--borderRadiusMedium": "4px",
"--fontSizeBase300": "14px",
"--fontWeightSemibold": "600",
"--durationFast": "150ms",
"--shadow4": "0 0 2px rgba(0,0,0,0.12), 0 2px 4px rgba(0,0,0,0.14)"
}
The FluentProvider isn’t the only source of custom properties. Some older Teams
components use :root or document.body properties. The
observation script captures these separately to ensure complete coverage:
// From observe.js — reading :root and body properties
const rootResult = await Runtime.evaluate({
expression: `
(() => {
const result = { root: {}, body: {} };
const rootStyles = getComputedStyle(document.documentElement);
const bodyStyles = getComputedStyle(document.body);
for (let i = 0; i < rootStyles.length; i++) {
const prop = rootStyles[i];
if (prop.startsWith('--')) {
result.root[prop] = rootStyles.getPropertyValue(prop).trim();
}
}
for (let i = 0; i < bodyStyles.length; i++) {
const prop = bodyStyles[i];
if (prop.startsWith('--')) {
result.body[prop] = bodyStyles.getPropertyValue(prop).trim();
}
}
return JSON.stringify(result);
})()
`,
returnByValue: true,
});
In practice, the FluentProvider tokens dominate. The :root and body
properties provide a handful of additional values, mostly legacy or Teams-specific
tokens that predate the Fluent v9 migration.
To sample computed colors from specific UI regions, we need CSS selectors that actually match elements in Teams. This turned out to be harder than expected.
Don’t guess selector names. The original version of the observation script
used selectors like [data-tid="left-rail"] and
[data-tid="chat-list"] — names that seemed logical but
didn’t exist in the actual DOM. The explore-dom.js tool was
built specifically to discover what selectors Teams really uses.
The explore-dom.js tool walks the DOM tree from <body>
down four levels, recording every element’s tag name, classes, data-tid,
data-testid, role, and aria-label attributes:
// From explore-dom.js — recursive DOM tree walker
function describeEl(el, depth, maxDepth) {
if (depth > maxDepth) return null;
const tag = el.tagName?.toLowerCase() || '?';
const id = el.id ? '#' + el.id : '';
const classes = el.className && typeof el.className === 'string'
? '.' + el.className.trim().split(/\s+/).slice(0, 5).join('.')
: '';
const role = el.getAttribute?.('role') || '';
const dataTid = el.getAttribute?.('data-tid') || '';
const dataTestid = el.getAttribute?.('data-testid') || '';
const ariaLabel = el.getAttribute?.('aria-label') || '';
// Collect all data-* attributes
const dataAttrs = {};
if (el.attributes) {
for (const attr of el.attributes) {
if (attr.name.startsWith('data-')) {
dataAttrs[attr.name] = attr.value?.substring(0, 80) || '';
}
}
}
const info = {
tag, selector: tag + id + classes,
role, dataTid, dataTestid, ariaLabel,
dataAttrs, childCount: el.children?.length || 0,
};
// Recurse into children
if (depth < maxDepth && el.children) {
const children = [];
for (const child of el.children) {
const desc = describeEl(child, depth + 1, maxDepth);
if (desc) children.push(desc);
}
if (children.length > 0) info.children = children;
}
return info;
}
It also searches specifically for all elements with data-tid attributes
and records their bounding rectangles. This reveals the actual naming scheme Teams uses:
| Guessed Name | Actual data-tid |
UI Element |
|---|---|---|
left-rail |
app-layout-area--mid-nav |
The conversation list panel |
chat-list |
simple-collab-dnd-rail |
The draggable chat tree |
nav-bar |
app-bar-wrapper |
The icon rail on the far left |
header |
entity-header |
The channel/chat header bar |
compose |
ckeditor |
The message compose box |
settings-btn |
more-options-header |
The “…” settings menu |
Some elements use data-testid instead of data-tid
(notably the collab rail components), and some use ARIA roles. The explorer captures
all of these patterns, letting us build a comprehensive selector list.
With correct selectors in hand, the observation script samples computed style properties from ~35 UI regions. For each region, it reads color-related CSS properties and records the actual rendered values:
// From observe.js — sampling computed colors from UI regions
const UI_REGIONS = [
{ selector: ".fui-FluentProvider", name: "FluentProvider (root)" },
{ selector: '[data-tid="app-layout-area--title-bar"]', name: "Title Bar Area" },
{ selector: '[data-tid="app-layout-area--nav"]', name: "Nav Area (App Bar)" },
{ selector: '[data-tid="app-layout-area--mid-nav"]', name: "Mid-Nav (Left Rail)" },
{ selector: '[data-testid="simple-collab-rail"]', name: "Collab Rail (Chat List)" },
{ selector: '[data-tid="app-layout-area--main"]', name: "Main Content Area" },
{ selector: '[data-tid="message-pane-body"]', name: "Message Pane Body" },
{ selector: '[data-tid="ckeditor"]', name: "Message Editor" },
{ selector: ".fui-Button", name: "Button (first)" },
{ selector: ".fui-TreeItem", name: "Tree Item (first)" },
// ... 25 more regions ...
];
const COLOR_PROPERTIES = [
"color", "background-color", "border-color",
"border-top-color", "border-bottom-color",
"box-shadow", "text-shadow", "caret-color",
"fill", "stroke",
// ... and more ...
];
For each region, the script queries the element, reads its computed style for every color-related property, and also captures any inline style declarations that might override the computed values. This gives us a ground truth map of what color appears where in the UI.
Beyond the FluentProvider tokens and computed colors, the observation script scans
all accessible stylesheets to build a map of which CSS properties reference which
var() tokens:
// From observe.js — scanning stylesheets for var() references
for (const sheet of document.styleSheets) {
try {
const rules = sheet.cssRules || sheet.rules;
for (const rule of rules) {
if (rule.style) {
for (let i = 0; i < rule.style.length; i++) {
const prop = rule.style[i];
if (/color|background|border|shadow/i.test(prop)) {
const val = rule.style.getPropertyValue(prop).trim();
// Track var() references
const varMatch = val.match(/var\(--([^)]+)\)/g);
if (varMatch) {
for (const v of varMatch) {
const name = v.match(/var\(--(.*?)(?:,|\))/)?.[1];
if (name) {
varReferences.get(name)?.add(prop)
|| varReferences.set(name, new Set([prop]));
}
}
}
}
}
}
}
} catch {
// Cross-origin stylesheet — skip silently
}
}
This reveals which tokens are actually used by the stylesheets versus which tokens merely exist as properties. Some tokens are defined but never referenced in any CSS rule — useful to know when deciding what to override.
Before generating a theme, we need to know whether Teams is currently in Light or Dark mode. The observation script determines this by analyzing the luminance of the primary background token:
// From observe.js — mode detection via token luminance
const bg1 = cs.getPropertyValue('--colorNeutralBackground1').trim();
const fg1 = cs.getPropertyValue('--colorNeutralForeground1').trim();
function parseLuminance(color) {
const temp = document.createElement('div');
temp.style.color = color;
document.body.appendChild(temp);
const computed = getComputedStyle(temp).color;
document.body.removeChild(temp);
const match = computed.match(/\d+/g);
if (!match) return 0.5;
const [r, g, b] = match.map(Number);
return (0.299 * r + 0.587 * g + 0.114 * b) / 255;
}
const bgLum = parseLuminance(bg1);
const mode = bgLum > 0.5 ? 'light' : 'dark';
Why not just read localStorage.getItem('tmp.desktopTheme')?
Teams stores its native theme preference there, and it isn’t
affected by injected CSS. The problem: this value can become stale. If the
user changes themes via Settings and Teams doesn’t flush, the localStorage
value may not reflect reality. The luminance approach is more reliable —
but it has its own issue: if a theme is already injected, the luminance reflects
the injected theme, not the native one. The injector solves this by
temporarily removing the injected style before sampling, then re-injecting it.
The observation pipeline includes a screenshot step. Using CDP’s
Page.captureScreenshot, it captures the full viewport and measures the
bounding boxes of each UI region:
// From screenshot.js — capturing the viewport
const { data: fullData } = await Page.captureScreenshot({ format: "png" });
fs.writeFileSync(
path.join(OUTPUT_DIR, "screenshot-full.png"),
Buffer.from(fullData, "base64")
);
The screenshot also records each region’s bounding rectangle by querying
getBoundingClientRect() on the discovered selectors. This data feeds
into the analysis step, where we can verify that our token-to-region mapping is correct
by comparing the token values against what’s actually rendered.
The observation step produces four output files:
| File | Format | Contents |
|---|---|---|
observed-tokens.json |
JSON | All 498 CSS custom properties, detected mode, token metadata |
observed-tokens.css |
CSS | A reference stylesheet with all tokens as a .fui-FluentProvider rule |
computed-colors.json |
JSON | Computed colors from ~35 UI regions with element metadata |
observation-report.txt |
Text | Human-readable summary: token counts, mode detection, region status |
We now have a complete snapshot of Teams’ color system. Next, we’ll analyze this data to understand the structural relationships between tokens — the surface hierarchy, interaction states, brand system, and contrast requirements that define the design system.