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.