The design system: away from Fluent, in every front end

"would like restyling away from fluent" · "and don't forget next and react native. check feature parity" — maintainer, 2026-11-01

There are four front ends. The Blazor portal (Fluent UI Blazor web components), the React web package clients/react (Fluent UI React v9), the Next.js portal clients/portal-next (renders through clients/react), and the React Native app app/react-native (its own leaf pack). Each took its look from its own component library, so the same page looked different in each, and only Blazor honoured the accent a person picks.

The plan has three steps, and every one of them reaches all four front ends.

1. One set of design tokens

Core's DesignTokens (Doc/GUI/DesignTokens) generates the --mw-* tokens from one accent colour and a mode: surfaces, text, borders, focus, accent, status, data colours, radii and a shadow. Every text-on-ground pair is contrast-solved for WCAG. One generator, three ways out:

Front end How it gets the tokens
Blazor /_mw/design-tokens.css: a light and a dark block per Office accent, selected by <html data-theme data-accent> before first paint. Fluent's own variables are aliased onto the tokens.
React web, Next.js The default tokens ship in the bundle as designTokens.generated.ts, with a drift test against the generator. The stored accent is fetched as JSON from /_mw/design-tokens.json. The Fluent React v9 theme's colours and radii read var(--mw-*).
React Native The same designTokens.generated.ts (plus the JSON for the stored accent) feeds its palette, so its controls stop using literal colours.

2. Our own components, one family at a time

Each Fluent component is replaced by a native element styled only from the tokens (src/MeshWeaver.Blazor/wwwroot/mw-controls.css, which has no literal colours, enforced by MwButtonTest). Native elements take ordinary CSS, so the ::part(control) workarounds that Fluent's shadow DOM forced on standard-page-layout.css disappear family by family.

MwButton's states: a button with no visible word is drawn icon-only and named by its Label. Disabled or loading, it cannot be activated. A button gets the native disabled attribute, and a link loses its href and gets aria-disabled. Loading also sets aria-busy. A disabled button still takes the pointer, so its tooltip and cursor show. A busy button's tooltip is the owner's status line ("Working…").

Usage measured on 2026-11-01: 106 Razor files rendered about 400 Fluent components. The order, most used first:

Family Uses State
Button (MwButton) 106 ButtonView moved, so every Controls.Button is an MwButton. The other 105 direct uses follow.
Icon 86 Next; MwButton still draws icons with FluentIcon.
Label, Stack, Card, Divider, Spacer 34, 32, 11, 5, 5 Pure layout, mechanical.
Anchor 17 MwButton with Href already covers the button-shaped ones.
Progress, ProgressRing 7, 25
TextField, Checkbox, Select 23, 19, 11 Needs care: forms, validation, focus.
MessageBar, Menu, Dialog, Toast 15, 5, 4, 5 Last: overlays, focus traps, keyboard handling.

🚨 The end-to-end tests select fluent-button. About 40 Playwright selectors in src/MeshWeaver.Portal.E2E.Test (chat composer, side-panel toggle, nav rail) target Fluent markup. They still match, because those buttons have not moved. The pull request that moves a family updates the selectors that family's markup answers to, with a selector that matches either form (button, fluent-button) while the move is in flight.

The MwAppearance names are Fluent's (Accent, Neutral, Outline, Stealth, Lightweight, Hypertext, Filled), because a control's Appearance travels as a name. Moving a view therefore changes nothing on the wire, and the React clients map the same names.

3. Feature parity (measured 2026-11-01)

Theme:

Blazor React web + Next.js React Native
Light / dark yes yes (same storage key) yes (separate key mw.theme)
The user's accent yes stored, never read: closed by step 1 fixed per deployment, leaf literals: closed by step 1
--mw-* tokens yes closed by step 1 closed by step 1 (its old literal --mw-accent in webStyles.ts is replaced)
Radii and shape tokens literal 4px / 2px: closed by step 1 literal 6 / 8 / 10

Controls. clients/react renders every control type the Blazor registry maps, pinned by src/render/parity.test.ts, which scrapes ViewsExtensions.cs. React Native is partial:

Control React Native today
TextArea falls back to a single-line field
Switch falls back to a checkbox
Select / Listbox an inline option list, no popover
NavMenu / NavGroup a plain stack
Toolbar a plain stack

Everything else, including Button, Label, Stack, LayoutGrid, Splitter, the Card skin, Badge, Icon, Progress, the text and number fields, CheckBox, Combobox, DataGrid, Menu, Dialog, Tabs, Chart and Expander, renders in all three. Closing the React Native gaps comes after the tokens, each with a parity test of the same kind as React's.