Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/public-docsite-v9/src/Concepts/Theming.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,10 @@ export const customDarkTheme = createDarkTheme(customBrandRamp);

@github-actions github-actions Bot Sep 8, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🕵🏾‍♀️ visual changes to review in the Visual Change Report

vr-tests-react-components/Menu Converged - submenuIndicator slotted content 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default - RTL.submenus open.chromium.png 404 Changed
vr-tests-react-components/Menu Converged - submenuIndicator slotted content.default.submenus open.chromium.png 413 Changed
vr-tests-react-components/Positioning 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/Positioning.Positioning end.updated 2 times.chromium.png 617 Changed
vr-tests-react-components/Positioning.Positioning end.chromium.png 887 Changed
vr-tests-react-components/ProgressBar converged 2 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness.default.chromium.png 54 Changed
vr-tests-react-components/ProgressBar converged.Indeterminate + thickness - Dark Mode.default.chromium.png 2 Changed
vr-tests-react-components/TagPicker 3 screenshots
Image Name Diff(in Pixels) Image Type
vr-tests-react-components/TagPicker.disabled - Dark Mode.chromium.png 658 Changed
vr-tests-react-components/TagPicker.disabled - High Contrast.disabled input hover.chromium.png 1319 Changed
vr-tests-react-components/TagPicker.disabled - RTL.chromium.png 635 Changed

There were 3 duplicate changes discarded. Check the build logs for more information.

A theme is a flat object containing `{ [token name]: CSS value }` pairs. You can copy the object and overwrite any tokens you wish.

Theme names and values are developer-authored CSS. If theme customization is based on dynamic data, validate it against
an application-specific schema before constructing the theme. Fluent UI contains generated declarations structurally,
but does not determine whether a valid CSS value or URL is appropriate for your application.

```tsx
import { webLightTheme, Theme } from '@fluentui/react-components';

Expand Down
Original file line number Diff line number Diff line change
@@ -1 +1,5 @@
This API allows you to create CSS from a theme and apply this CSS, for example, to `<body>`.

The selector and theme are developer-authored CSS. Validate dynamic data against an application-specific schema before
using it to construct a theme. The generated declarations are structurally contained, but the API does not determine
whether a valid CSS value or URL is appropriate for your application.
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"type": "patch",
"comment": "fix: structurally validate serialized theme tokens",
"packageName": "@fluentui/react-provider",
"email": "paulmardling@microsoft.com",
"dependentChangeType": "patch"
}
3 changes: 3 additions & 0 deletions docs/react-v9/contributing/patterns/extending-tokens.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ It's often useful for an app to extend the base set of tokens from Fluent UI.

⚠ Warning that adding more tokens adds more CSS variables which can effect run time performance as each DOM Node carries all the tokens.

Theme names and values are developer-authored CSS. Validate dynamic data against an application-specific schema before
using it to construct or extend a theme.

```tsx
import { makeStyles, themeToTokensObject, webLightTheme, FluentProvider, Theme } from '@fluentui/react-components';

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -76,4 +76,28 @@ describe('FluentProvider (node)', () => {
</div>"
`);
});

it('contains theme entries in the server style element', () => {
const theme = {
customToken: 'url(resource/*);token/**/)',
validToken: 'green',
} as unknown as PartialTheme;

const html = renderToStaticMarkup(<FluentProvider theme={theme} />);

expect(parseHTMLString(html)).toMatchInlineSnapshot(`
"<div
dir="ltr"
class="fui-FluentProvider fui-FluentProvider1"
>
<style id="fui-FluentProvider1">
.fui-FluentProvider1 {
--customToken: url(resource)\\3B token);
--validToken: green;
}
</style>
</div>"
`);
expect(html.match(/<style/g)).toHaveLength(1);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,12 @@ export type FluentProviderProps = Omit<ComponentProps<FluentProviderSlots>, 'dir
/** Provides the document, can be undefined during SSR render. */
targetDocument?: Document;

/** Sets the theme used in a scope. */
/**
* Sets the theme used in a scope.
*
* Theme names and values are developer-authored CSS. Validate dynamic data against an application-specific schema
* before using it to construct a theme.
*/
theme?: PartialTheme;

// eslint-disable-next-line @typescript-eslint/naming-convention
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,26 @@
import {
teamsDarkTheme,
teamsHighContrastTheme,
teamsLightTheme,
themeToTokensObject,
webDarkTheme,
webLightTheme,
} from '@fluentui/react-theme';
import type { PartialTheme } from '@fluentui/react-theme';
import { createCSSRuleFromTheme } from './createCSSRuleFromTheme';

describe('createCSSRuleFromTheme', () => {
const noop = () => undefined;
let logWarnSpy: jest.Spied<typeof console.warn>;

beforeEach(() => {
logWarnSpy = jest.spyOn(console, 'warn').mockImplementation(noop);
});

afterEach(() => {
jest.restoreAllMocks();
});

it('handles undefined theme', () => {
expect(createCSSRuleFromTheme('.selector', undefined)).toMatchInlineSnapshot(`".selector {}"`);
});
Expand Down Expand Up @@ -37,4 +56,197 @@ describe('createCSSRuleFromTheme', () => {
expect(result).not.toContain('>');
expect(result).toContain('\\3C /style\\3E \\3C script\\3E alert("xss")\\3C /script\\3E ');
});

it.each([
{ value: String.raw`<`, expected: String.raw`\3C ` },
{ value: String.raw`\<`, expected: String.raw`\3C ` },
{ value: String.raw`\\<`, expected: String.raw`\\\3C ` },
{ value: String.raw`\\\<`, expected: String.raw`\\\3C ` },
])('preserves backslash parity when escaping angle brackets in %j', ({ value, expected }) => {
expect(createCSSRuleFromTheme(value, undefined)).toBe(`${expected} {}`);
});

it.each([5_000, 10_000, 20_000, 100_000])(
'serializes matching and nonmatching backslash runs of length %i',
runLength => {
const backslashes = '\\'.repeat(runLength);
const theme = {
fontFamilyBase: `"${backslashes}x"`,
fontFamilyMonospace: `"${backslashes}<"`,
} as PartialTheme;

expect(createCSSRuleFromTheme(`.selector${backslashes}<`, theme)).toBe(
`.selector${backslashes}\\3C { --fontFamilyBase: "${backslashes}x"; --fontFamilyMonospace: "${backslashes}\\3C "; }`,
);
},
);

it.each([
{ description: 'font family fallbacks', value: '"Segoe UI", system-ui, sans-serif' },
{ description: 'system and functional colors', value: 'color-mix(in srgb, CanvasText 40%, transparent)' },
{ description: 'nested functions and fallbacks', value: 'clamp(1rem, calc(var(--scale, 1) * 2vw), 3rem)' },
{ description: 'multiple shadows', value: '0 1px 2px rgb(0 0 0 / 20%), 0 4px 8px rgba(0, 0, 0, 0.1)' },
{ description: 'CSS-wide values', value: 'revert-layer' },
{
description: 'data URLs with nested delimiters',
value: 'url(data:image/svg+xml;charset=utf-8,%3Csvg%3E;%3C/svg%3E)',
},
{ description: 'quoted delimiters', value: '"value; with { delimiters }"' },
{ description: 'balanced blocks', value: 'custom({ value; [other] })' },
{ description: 'comments', value: 'calc(1px /* ; } */ + 2px)' },
{ description: 'escaped delimiters', value: String.raw`red\;blue` },
{ description: 'escaped quotes', value: String.raw`"escaped \"quote\""` },
{ description: 'escaped unquoted URL characters', value: String.raw`url(image\20 name.png)` },
{ description: 'uppercase hexadecimal escaped URL name', value: String.raw`\55rl(resource/*)` },
{ description: 'mixed hexadecimal escaped URL name', value: String.raw`u\52l(resource/*)` },
{ description: 'uppercase hexadecimal escaped URL suffix', value: String.raw`ur\4c(resource/*)` },
{ description: 'lowercase hexadecimal escaped URL name', value: String.raw`\75rl(resource/*)` },
{ description: 'mixed simple and hexadecimal escaped URL name', value: String.raw`\U\72L(resource/*)` },
{ description: 'hexadecimal escaped URL name with a space terminator', value: String.raw`\55 rl(resource/*)` },
{
description: 'hexadecimal escaped URL name with a CRLF terminator',
value: String.raw`u\52${'\r\n'}l(resource/*)`,
},
{ description: 'zero-padded hexadecimal escaped URL name', value: String.raw`\000055rl(resource/*)` },
{ description: 'escaped generic function names', value: String.raw`f\6f o((x); y)` },
{ description: 'quoted URL functions with nested blocks', value: String.raw`url("image" (x); fallback)` },
{ description: 'backslash and line feed', value: 'first\\\nsecond' },
{ description: 'backslash and carriage return', value: 'first\\\rsecond' },
{ description: 'backslash and form feed', value: 'first\\\fsecond' },
{ description: 'multiline whitespace', value: 'calc(\n 1px + 2px\n)' },
])('preserves $description', ({ value }) => {
const result = createCSSRuleFromTheme('.selector', { customToken: value } as unknown as PartialTheme);

expect(result).toBe(`.selector { --customToken: ${value}; }`);
expect(logWarnSpy).not.toHaveBeenCalled();
});

it('preserves supported custom token names and finite numeric values', () => {
const theme = {
'custom-token_1': 0,
customÜnicode: 'red',
'custom\\ token': 'blue',
'custom\\3A token': 'green',
'custom\\<token': 'purple',
} as unknown as PartialTheme;

expect(createCSSRuleFromTheme('.selector', theme)).toBe(
'.selector { --custom-token_1: 0; --customÜnicode: red; --custom\\ token: blue; --custom\\3A token: green; --custom\\3C token: purple; }',
);
expect(logWarnSpy).not.toHaveBeenCalled();
});

it('serializes escaped custom token names consistently with themeToTokensObject', () => {
const escapedColonTheme = { ...webLightTheme, 'custom\\3A token': 'red' };
const escapedAngleTheme = { ...webLightTheme, 'custom\\<token': 'red' };

expect(createCSSRuleFromTheme('.selector', escapedColonTheme)).toContain('--custom\\3A token: red;');
expect(createCSSRuleFromTheme('.selector', escapedAngleTheme)).toContain('--custom\\3C token: red;');
expect(themeToTokensObject(escapedColonTheme)['custom\\3A token']).toBe('var(--custom\\3A token)');
expect(themeToTokensObject(escapedAngleTheme)['custom\\<token']).toBe('var(--custom\\<token)');
});

it.each(['', 'token name', 'token:name', 'token;name', 'token\\', 'token\\\nname'])(
'omits unsupported custom token name %j',
tokenName => {
const result = createCSSRuleFromTheme('.selector', { [tokenName]: 'red' } as PartialTheme);

expect(result).toBe('.selector { }');
expect(logWarnSpy).toHaveBeenCalledWith(expect.stringContaining(JSON.stringify(tokenName)));
},
);

it.each([
{ description: 'top-level semicolon', value: 'red;blue', containedValue: 'red\\3B blue' },
{ description: 'unmatched closing block delimiter', value: 'red}', containedValue: 'red\\7D ' },
{
description: 'unquoted URL tokenization',
value: 'url(resource/*);token/**/)',
containedValue: 'url(resource/*)\\3B token/**/)',
},
{
description: 'escaped unquoted URL tokenization',
value: '\\000075\r\n\\000072\r\n\\00006c\r\n(resource/*);token/**/)',
containedValue: '\\000075\r\n\\000072\r\n\\00006c\r\n(resource/*)\\3B token/**/)',
},
])('contains a value with $description without affecting later tokens', ({ value, containedValue }) => {
const theme = {
customToken: value,
colorBrandBackground: 'blue',
} as unknown as PartialTheme;

expect(createCSSRuleFromTheme('.selector', theme)).toBe(
`.selector { --customToken: ${containedValue}; --colorBrandBackground: blue; }`,
);
expect(logWarnSpy).not.toHaveBeenCalled();
});

it.each([
{ description: 'mismatched delimiters', value: 'calc([1px)]' },
{ description: 'unterminated function', value: 'calc(1px' },
{ description: 'unterminated string', value: '"red' },
{ description: 'unescaped string newline', value: '"red\n; color: red' },
{ description: 'unterminated comment', value: 'red /* comment' },
{ description: 'unterminated escape', value: 'red\\' },
{ description: 'escaped whitespace in a generic function name', value: String.raw`ur\ l(resource{)` },
{ description: 'non-URL escaped function name', value: String.raw`\54rl(resource/*)` },
])('repairs a value with $description without affecting later tokens', ({ value }) => {
const ruleText = createCSSRuleFromTheme('.selector', {
customToken: value,
colorBrandBackground: 'blue',
} as unknown as PartialTheme);
const styleElement = document.createElement('style');
styleElement.textContent = ruleText;
document.head.appendChild(styleElement);

const rule = styleElement.sheet?.cssRules[0] as CSSStyleRule;
expect(rule.style.getPropertyValue('--colorBrandBackground')).toBe('blue');
expect(logWarnSpy).not.toHaveBeenCalled();

styleElement.remove();
});

it.each([
String.raw`\55rl(resource.png)`,
String.raw`u\52l(resource.png)`,
String.raw`ur\4c(resource.png)`,
String.raw`\55 rl(resource.png)`,
])('preserves escaped unquoted URL syntax through CSSOM for %j', value => {
const styleElement = document.createElement('style');
styleElement.textContent = createCSSRuleFromTheme('.selector', {
customToken: value,
} as unknown as PartialTheme);
document.head.appendChild(styleElement);

const rule = styleElement.sheet?.cssRules[0] as CSSStyleRule;
expect(rule.style.getPropertyValue('--customToken')).toBe(value);

styleElement.remove();
});

it.each([
{ description: 'non-finite number', value: Number.POSITIVE_INFINITY },
{ description: 'non-primitive value', value: { color: 'red' } },
])('omits a value with $description without affecting later tokens', ({ value }) => {
const theme = {
invalidToken: value,
colorBrandBackground: 'blue',
} as unknown as PartialTheme;

expect(createCSSRuleFromTheme('.selector', theme)).toBe('.selector { --colorBrandBackground: blue; }');
expect(logWarnSpy).toHaveBeenCalledWith(expect.stringContaining('"invalidToken"'));
expect(logWarnSpy.mock.calls[0][0]).not.toContain(String(value));
});

it('serializes all values from exported themes', () => {
for (const theme of [webLightTheme, webDarkTheme, teamsLightTheme, teamsDarkTheme, teamsHighContrastTheme]) {
const result = createCSSRuleFromTheme('.selector', theme);

for (const [tokenName, tokenValue] of Object.entries(theme)) {
expect(result).toContain(`--${tokenName}: ${tokenValue};`);
}
}

expect(logWarnSpy).not.toHaveBeenCalled();
});
});
Loading
Loading