130 Widgets

Building tools. Learning to build tools. Learning to build learning tools.

2. Analysis — Surface Hierarchy & Token Anatomy

Parsing 498 tokens into a structural map: background layers, interaction states, foreground ramps, brand colors, status semantics, and contrast requirements.

Design Arch

From Raw Data to Design Structure

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.

The Surface Hierarchy: 8 Background Layers

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 };
}
Design System Concept

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.

Interaction States: Hover, Pressed, Selected, Disabled

Each background token can have up to four interaction variants. These follow a consistent naming pattern:

Token Family: --colorNeutralBackground1 ┌────────────────────────────────────────────────────┐ │ │ │ Base ────── #ffffff (default state) │ │ Hover ────── #f5f5f5 (mouse over) │ │ Pressed ────── #e0e0e0 (mouse down) │ │ Selected ────── #ebebeb (active/checked) │ │ Disabled ────── #f0f0f0 (non-interactive) │ │ │ │ Brightness shift pattern (light mode): │ │ Base ← brightest │ │ Hover → slightly darker (−0.02 to −0.04) │ │ Pressed → darkest (−0.06 to −0.08) │ │ Selected → between hover and pressed │ │ Disabled → near base but desaturated │ └────────────────────────────────────────────────────┘
// 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).

The Foreground Ramp

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.

The Brand Color System

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:

The Compound tokens (--colorCompoundBrand*) are aliases that reference the same colors but are used by composite components like checkboxes and toggles.

Status Colors: Semantic Meaning

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.

Design System Concept

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.

Palette Colors: The Avatar Rainbow

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.

Contrast Ratio Verification

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.

Token Value Sharing: Design System Compression

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.

Architecture Decision

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.

Non-Color Tokens

The 498 tokens aren’t all colors. The observation also captures:

For 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 Analysis Output

The complete analysis is written to two files:

What We Now Know

With 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.