Four Ways We’d Extend the Fluent UI Contract (Real Widths, Teams Themes, and Automated A11y Checks)

Post 6 walked through react-design.md as it exists today: sixteen sections of MUST/SHOULD/MAY rules — the “Copilot UI Contract v2.1” — that tell an AI coding agent what it’s allowed to generate before it writes a single line of Fluent UI. It’s a genuinely strong contract, tighter and more opinionated than anything most SPFx teams have written down for themselves. But reading it closely, four places stood out where the rule is real and the enforcement isn’t there yet — a MUST with no runtime signal behind it, a host the contract never names, a validation step that checks the wrong thing, and a category of accessibility the file doesn’t mention at all.

As with Post 3’s scaffolding roadmap and Post 5’s upgrade-playbook roadmap, none of what follows is a criticism, and none of it is announced or endorsed by the maintainers of SharePoint/spfx-dev-skills. These are our own proposals — four of them, each with a runnable snippet — that we plan to test against real Viva Connections, Teams, and web part scenarios before we’d consider opening a pull request. Treat them as pre-PR working notes, not a changelog.


Proposal 1: A Runtime Signal for “Constrained Width,” Not an Assumed One

Section 7’s Embedded / Web Part row is unambiguous: a component in that context “MUST assume constrained width” and “MUST use compact density.” The problem is that the file never defines what width counts as constrained — and neither does any official SharePoint documentation. Microsoft’s own SharePoint grid and responsive design guidance confirms the page is a responsive 12-column grid whose columns reflow per breakpoint, but it publishes no single universal pixel number for “one-third column” versus “full-width section.” Those change with the viewport and the zone configuration. So an agent told to “assume constrained width” has to invent a breakpoint — exactly the kind of unverified guess this contract exists to prevent.

The cleaner rule is to stop assuming and start measuring. Wire the component to a ResizeObserver watching its own rendered box, and let it switch density at a threshold it defines for itself.

Proposed addition: a small useElementWidth hook, built on the standard, stable ResizeObserver browser API — no Fluent- or SPFx-specific dependency required.

import { useState, useEffect, useRef } from 'react';
// Measures the component's OWN rendered width instead of assuming a page breakpoint.
export function useElementWidth<T extends HTMLElement>() {
const ref = useRef<T>(null);
const [width, setWidth] = useState(0);
useEffect(() => {
const element = ref.current;
if (!element) return;
const observer = new ResizeObserver((entries) => {
setWidth(entries[0].contentRect.width);
});
observer.observe(element);
return () => observer.disconnect();
}, []);
return { ref, width };
}
// Usage — the component owns its threshold; there is no global "constrained" constant.
const { ref, width } = useElementWidth<HTMLDivElement>();
const density = width < 480 ? 'compact' : 'comfortable';

Why this helps an agent specifically: an agent told to “assume constrained width” has to guess a number and then hardcode it. An agent told to “measure your actual width and react to it” never guesses — and the same component renders correctly in a web part zone, a Teams tab, and a Viva Connections card without three separate implementations, because each host resizes the same box and the box reports its own truth.


Proposal 2: Explicit Teams and Viva Connections Host Theme Handling

Section 5 requires FluentProvider, theme-aware tokens instead of fixed colors, and a fallback to Fluent’s own defaults when nothing else is available. What it doesn’t mention is that a component compiled for SharePoint can also render inside a Microsoft Teams personal-app tab or a Viva Connections surface — and in those hosts the theme in force isn’t SharePoint’s at all. It’s the user’s Teams light, dark, or high-contrast setting, which is chosen independently of SharePoint and can change while the tab is still open. A component that resolves theme only through the SharePoint path silently ignores that.

The pieces to handle it correctly already exist. @fluentui/react-components ships webLightTheme, webDarkTheme, teamsLightTheme, teamsDarkTheme, and teamsHighContrastTheme as directly importable theme objects. And SPFx’s WebPartContext exposes a conditional sdks.microsoftTeams property — defined only when the component is actually hosted in Teams — wrapping the TeamsJS v2 API, including app.registerOnThemeChangeHandler, which fires with a theme string of default, dark, glass, or contrast on every live theme change.

Proposed addition: detect the Teams host, register for its theme changes, and map each returned string to the matching Fluent theme passed into FluentProvider.

import {
teamsLightTheme, teamsDarkTheme, teamsHighContrastTheme,
} from '@fluentui/react-components';
function mapTeamsTheme(theme: string) {
switch (theme) {
case 'dark': return teamsDarkTheme;
case 'contrast': return teamsHighContrastTheme;
default: return teamsLightTheme; // covers 'default' and 'glass'
}
}
// `context` is the SPFx WebPartContext; `sdks.microsoftTeams` is undefined outside Teams.
const teams = context.sdks.microsoftTeams;
if (teams) {
teams.teamsJs.app.registerOnThemeChangeHandler((theme) => {
setActiveTheme(mapTeamsTheme(theme)); // drives <FluentProvider theme={activeTheme}>
});
}

Why this helps an agent specifically: without this, “theme-aware tokens” only solves half the problem. The component can ship a SharePoint-only theme resolution path that compiles cleanly, passes review, and still ignores a live Teams theme change — which is a worse failure than no theming at all, because it looks handled right up until a user in dark mode opens the tab and gets a slab of web-light surface.


Proposal 3: Automated Accessibility Regression Checks in the Validation Step

Section 16, Validation, asks for two things: run npm run build and resolve every error, then smoke-test in the workbench. Section 10, Accessibility, lists six specific MUST-level requirements — semantic structure, full keyboard navigation, labels and accessible names, preserved tab order, managed focus for overlays, and exposed loading/empty/error states. Nothing in the Validation step actually checks any of the six. A clean build proves the TypeScript compiles; it proves nothing about whether the component an agent just generated is accessible.

That gap closes cheaply because the tooling is off-the-shelf. jest-axe is an actively maintained Jest matcher wrapping axe-core, and it drops straight into the React Testing Library setup most SPFx projects already have.

Proposed addition: add jest-axe and assert zero violations in the same step that already gates the build.

npm install --save-dev jest-axe
import { render } from '@testing-library/react';
import { axe, toHaveNoViolations } from 'jest-axe';
expect.extend(toHaveNoViolations);
test('StatusCard has no detectable accessibility violations', async () => {
const { container } = render(<StatusCard title="Quarterly report" />);
const results = await axe(container);
expect(results).toHaveNoViolations();
});

Why this helps an agent specifically: Section 10’s six rules are exactly the kind of thing a simple component can satisfy by accident and a complex one can silently break. An automated check living in the same Validation moment that already runs npm run build catches the regression with near-zero added process — it’s the “run this before the change is done” step the contract already has, doing one more useful thing, not a separate accessibility ceremony bolted on afterward.


Proposal 4: An Explicit prefers-reduced-motion Rule

None of the sixteen sections mention animation or motion — not Styling (Section 12), not Performance (Section 11), not even Accessibility (Section 10). Fluent UI v9 ships built-in motion primitives that animate by default, so a component built entirely to contract can still move in ways a user never asked for. For someone with a vestibular disorder who has set their OS-level “reduce motion” preference, the contract as written offers no protection at all.

The fix carries no dependency risk. prefers-reduced-motion is a standard, stable CSS media feature with a matching window.matchMedia JavaScript API — the same “respect what the host or OS already tells you” spirit as Section 4’s theme-precedence rule, applied to motion.

Proposed addition: read the preference once, react to changes, and let the component shorten or drop its Fluent motion accordingly.

import { useState, useEffect } from 'react';
// No library — a standard CSS media feature read from JS.
export function usePrefersReducedMotion(): boolean {
const query = '(prefers-reduced-motion: reduce)';
const [reduced, setReduced] = useState(() => window.matchMedia(query).matches);
useEffect(() => {
const mql = window.matchMedia(query);
const onChange = () => setReduced(mql.matches);
mql.addEventListener('change', onChange);
return () => mql.removeEventListener('change', onChange);
}, []);
return reduced;
}
// Usage — gate Fluent motion on the preference.
const reduced = usePrefersReducedMotion();
const motionDuration = reduced ? 0 : 200;

Why this helps an agent specifically: this is the cheapest of the four to implement and the one most likely to be silently missed — precisely because nothing in the source file prompts an agent to think about motion at all. With color or layout, an agent has an existing rule to pattern-match against and self-correct. With motion, there’s no rule in the contract to even trigger the thought, so the omission compounds: the agent doesn’t skip the rule, it never knows the rule should exist.


Testing Before Proposing

All four of these are proposals, not pull requests. Before any of them goes near SharePoint/spfx-dev-skills, we plan to run each against real scenarios — the useElementWidth hook across a genuine web part zone, a Teams personal tab, and a Viva Connections card; the Teams theme handler against a live light-to-dark switch; jest-axe against components that deliberately break each of Section 10’s six rules; and the reduced-motion hook against a Fluent v9 component that animates by default. That testing is separate from this blog series, and we’ll link it here once it exists.

Next in the series, we leave the Fluent UI contract behind and pick up the thread Section 15 opened. Post 8 is the pillar post for the fourth and final reference file, pnpjs.md: “Why PnPjs Should Be Non-Negotiable in Every AI-Generated SPFx Web Part.” Note that none of the four proposals above touch data access — that’s deliberate, so Section 15 stays fully intact for Post 8 to cover on its own.

Leave a Reply