Building tools. Learning to build tools. Learning to build learning tools.
Parsing 498 tokens into a structural map: background layers, interaction states, foreground ramps, brand colors, status semantics, and contrast requirements.
Design Arch
The observation step gave us 498 token names and their values. That’s a flat list — useful for injection but opaque as a design system. To generate well-behaved themes, we need to understand the relationships between tokens: which backgrounds are meant to be layered, which foregrounds go on which surfaces, which colors share an underlying value for design system compression.
The analyze.js script reads the observation data and produces a
structural analysis. Let’s walk through each major finding.
Teams’ background system uses numbered layers to create visual depth.
--colorNeutralBackground1 is the brightest (the “paper” surface),
and each subsequent number is slightly darker, pushing the layer visually behind
the one above it.
| Layer | Token | Light Value | Brightness | Used For |
|---|---|---|---|---|
| 1 | --colorNeutralBackground1 |
#ffffff |
1.000 | Main content area, message pane |
| 2 | --colorNeutralBackground2 |
#fafafa |
0.980 | Sidebars, secondary panels |
| 3 | --colorNeutralBackground3 |
#f5f5f5 |
0.961 | Cards, elevated containers |
| 4 | --colorNeutralBackground4 |
#f0f0f0 |
0.941 | Nested panels, deeper insets |
| 5 | --colorNeutralBackground5 |
#ebebeb |
0.922 | Table headers, footers |
| 6 | --colorNeutralBackground6 |
#e6e6e6 |
0.902 | Input fields, wells |
| Inverted | --colorNeutralBackgroundInverted |
#292929 |
0.161 | Tooltips, badges (dark on light) |
| Static | --colorNeutralBackgroundStatic |
#333333 |
0.200 | Elements that don’t change with theme |
// From analyze.js — detecting the surface hierarchy
function analyzeSurfaceHierarchy(tokens) {
const layers = [];
for (let i = 1; i <= 8; i++) {
const key = `--colorNeutralBackground${i}`;
const val = tokens[key];
if (val) {
const c = parseColor(val);
layers.push({
layer: i,
token: key,
value: val,
hex: c ? colorToHex(c) : val,
luminance: c ? luminance(c).toFixed(4) : "N/A",
brightness: c ? perceivedBrightness(c).toFixed(3) : "N/A",
});
}
}
return { layers };
}
The layer numbering creates a z-axis of brightness. Imagine stacking sheets of paper: Layer 1 is the top sheet (brightest/closest), and each layer beneath is slightly dimmer. This is how Fluent UI communicates depth without shadows — the brightness difference is the depth cue. When we dim the theme, we must preserve these relative brightness steps, or the UI loses its sense of layering.
Each background token can have up to four interaction variants. These follow a consistent naming pattern:
// From analyze.js — finding interaction state families
function analyzeInteractionStates(tokens) {
const families = {};
const statePattern = /(.*?)(Hover|Pressed|Selected|Disabled)$/;
for (const key of Object.keys(tokens)) {
const base = key.replace(/^--/, "");
const match = base.match(statePattern);
if (match) {
const family = `--${match[1]}`;
if (!families[family]) families[family] = { base: null, states: {} };
families[family].states[match[2]] = {
token: key, value: tokens[key]
};
}
}
// Match bases — only include families with a base AND states
for (const family of Object.keys(families)) {
if (tokens[family]) {
families[family].base = { token: family, value: tokens[family] };
}
}
}
The analysis reveals ~30 token families with complete state sets. The brightness shift between states is consistent: Hover is 2–4% darker than Base, Pressed is 6–8% darker, and Selected falls between. This pattern is critical for theme generation — we can’t just dim the Base and leave its states untouched, or the hover feedback would disappear (all states would appear the same brightness).
Foreground tokens control text and icon colors. They form a brightness ramp from high-contrast primary text to nearly-invisible disabled text:
| Level | Token | Light Value | Brightness | Used For |
|---|---|---|---|---|
| 1 (primary) | --colorNeutralForeground1 |
#242424 |
0.141 | Primary text, headings |
| 2 (secondary) | --colorNeutralForeground2 |
#424242 |
0.259 | Secondary text, labels |
| 3 (tertiary) | --colorNeutralForeground3 |
#616161 |
0.380 | Muted text, captions |
| 4 (quaternary) | --colorNeutralForeground4 |
#707070 |
0.439 | Placeholder text |
| Disabled | --colorNeutralForegroundDisabled |
#bdbdbd |
0.741 | Disabled controls |
In light mode, foregrounds are dark on light — low brightness values
on high brightness backgrounds. The contrast between Foreground1 (#242424,
brightness 0.14) and Background1 (#ffffff, brightness 1.0) gives a ratio
well above WCAG AA’s 4.5:1 requirement. This polarity relationship becomes
the central challenge when dimming — as we’ll see in Section 3.
Teams uses a single brand color (a muted indigo, #5b5fc7) that appears
in multiple tints and shades for different contexts:
// From analyze.js — extracting the brand system
function analyzeBrandSystem(tokens) {
const brand = {};
for (const [key, val] of Object.entries(tokens)) {
if (/^--colorBrand/i.test(key) || /^--colorCompound/i.test(key)) {
const c = parseColor(val);
brand[key] = {
value: val,
hex: c ? colorToHex(c) : val,
brightness: c ? perceivedBrightness(c).toFixed(3) : "N/A",
};
}
}
return brand;
}
Brand tokens divide into three categories:
--colorBrandBackground,
--colorBrandBackground2): used for filled buttons, selected tabs,
and accent surfaces--colorBrandForeground1,
--colorBrandForeground2): used for links, icon accents, and brand text--colorBrandStroke1,
--colorBrandStroke2): used for focused outlines and brand borders
The Compound tokens (--colorCompoundBrand*) are aliases that reference
the same colors but are used by composite components like checkboxes and toggles.
Beyond neutral and brand colors, the design system defines semantic status colors. Each status category (Danger, Success, Warning, Severe) has a full set of variants for different surface contexts:
| Status | Background Tokens | Border Tokens | Foreground Tokens |
|---|---|---|---|
| Danger (Red) | Background1, Background3 |
Border1, Border2 |
Foreground1, Foreground3 |
| Success (Green) | Background1, Background3 |
Border1, Border2 |
Foreground1, Foreground3 |
| Warning (Yellow) | Background1, Background3 |
Border1, Border2 |
Foreground1, Foreground3 |
| Severe (Orange) | Background1, Background3 |
Border1 |
Foreground1 |
Each variant is carefully chosen for its context: Background1 is a very light tint
(e.g., pale red #fdf3f4) for inline alerts, Background3 is the saturated
base (e.g., #d13438) for filled danger buttons, and Foreground1 is
a darkened variant (e.g., #b10e1c) for readable text on white backgrounds.
Status colors must remain recognizable after dimming. If danger-red becomes indistinguishable from the dimmed background, users won’t notice error states. The dimming engine (Section 3) applies less-aggressive transforms to status colors, preserving their hue and relative saturation while adjusting only the lightness to match the new surface brightness.
The largest token group is the palette: approximately 30 named color families
(Red, DarkOrange, Marigold, Gold,
Brass, Forest, Teal, Steel,
Cornflower, Navy, Lilac, Berry,
Grape, Anchor, Charcoal, …). Each
family has Background1, Background2, Background3, Foreground1, Foreground2, Foreground3,
and BorderActive tokens.
These feed the avatar color system — each user gets a deterministic palette assignment for their avatar background. They’re also used for tags, category labels, and decorative elements. There are roughly 200 palette tokens in total.
The analysis script verifies WCAG 2.0 AA compliance for common foreground/background pairings. The standard requires a contrast ratio of at least 4.5:1 for normal text and 3:1 for large text (18px or 14px bold):
// From analyze.js — contrast ratio calculation
function contrastRatio(c1, c2) {
const l1 = luminance(c1);
const l2 = luminance(c2);
const lighter = Math.max(l1, l2);
const darker = Math.min(l1, l2);
return (lighter + 0.05) / (darker + 0.05);
}
// Relative luminance per WCAG 2.0
function luminance(c) {
if (!c) return 0;
const srgb = [c.r / 255, c.g / 255, c.b / 255];
const linear = srgb.map(v =>
v <= 0.03928 ? v / 12.92 : Math.pow((v + 0.055) / 1.055, 2.4)
);
return 0.2126 * linear[0] + 0.7152 * linear[1] + 0.0722 * linear[2];
}
The analysis checks 10 critical pairings:
| Pairing | BG Token | FG Token | Ratio | AA |
|---|---|---|---|---|
| Primary text on main bg | NeutralBackground1 |
NeutralForeground1 |
14.68 | PASS |
| Secondary text on main bg | NeutralBackground1 |
NeutralForeground2 |
9.68 | PASS |
| Tertiary text on main bg | NeutralBackground1 |
NeutralForeground3 |
5.74 | PASS |
| Disabled text on main bg | NeutralBackground1 |
ForegroundDisabled |
1.87 | FAIL* |
| Brand accent on main bg | NeutralBackground1 |
BrandForeground1 |
4.85 | PASS |
| Text on brand button | BrandBackground |
ForegroundOnBrand |
7.20 | PASS |
* Disabled text intentionally fails AA — reduced contrast is the visual signal that a control is non-interactive.
One surprising discovery: many tokens with different names share the same underlying color value. The analysis groups tokens by their value and reports clusters of 3+ tokens pointing to the same hex code:
// From analyze.js — finding shared token values
function analyzeTokenGroups(tokens) {
const byValue = {};
for (const [key, val] of Object.entries(tokens)) {
if (!key.startsWith("--color")) continue;
if (!byValue[val]) byValue[val] = [];
byValue[val].push(key);
}
// Only report values shared by 3+ tokens
const shared = {};
for (const [val, keys] of Object.entries(byValue)) {
if (keys.length >= 3) {
shared[val] = keys;
}
}
return shared;
}
In the light theme, #ffffff is shared by 8+ tokens (Background1,
CardBackground, SubtleBackgroundLightAlphaSelected, etc.). Similarly,
#242424 serves as both NeutralForeground1 and several other foreground
tokens. This “compression” exists because the design system defines
semantic meaning through token names while allowing the values to
converge when the visual intent is the same.
We override tokens individually even when they share values. While it would be tempting to collapse shared values into fewer CSS declarations, maintaining per-token overrides means each token can diverge independently in future themes. If a dimmed theme needs CardBackground to differ from Background1 (to add depth), we can do that without restructuring.
The 498 tokens aren’t all colors. The observation also captures:
--spacingHorizontalS (8px),
--spacingVerticalM (12px), etc.--fontSizeBase300 (14px),
--fontWeightSemibold (600), --lineHeightBase300 (20px)--borderRadiusMedium (4px),
--borderRadiusCircular (10000px)--shadow2, --shadow4 through
--shadow64 (multi-layer box-shadow values)--durationFast (150ms),
--durationNormal (300ms), --curveEasyEaseFor theming, we focus primarily on colors and shadows. Spacing and typography tokens are left unchanged — modifying them breaks layout assumptions that Fluent components rely on. Shadow tokens are adjusted: dimmer surfaces need less shadow opacity since the contrast between the shadow and the surface decreases.
The complete analysis is written to two files:
output/analysis.json — machine-readable data: surface hierarchy,
interaction state families, brand system, color ramps, contrast pairs, token groups,
and status colorsoutput/analysis-report.txt — a human-readable report summarizing
all findings, formatted for easy review before theme generationWith this structural understanding, we can now build a theme generator that transforms every token correctly — preserving layer depth, maintaining contrast, respecting brand vibrancy, and handling the polarity flip that occurs when backgrounds darken past a threshold. That’s the dimming engine, next.