Repository navigation
Theming: Better System Appearance Handling #4270
Copy link
Copy link
Open
Description
Summary
Replace the current mix of requested appearance, resolved light or dark mode,
and high-contrast state with a structured model exposed through ThemeState.
Unify platform resolution so components can reliably ask which scheme is active
without re-reading theme context or depending on mutable module state.
The existing AppearanceOptions union combines several concepts, platform
helpers disagree on resolution, and ThemeState exposes only a
highContrast boolean.
Goal
Replace the ad hoc appearance and high-contrast handling with a structured
model: separate the requested color scheme from the resolved one, expose whether
the active theme is light or dark, and surface those values on ThemeState.
Stage
Stage 2 - Beta delivery.
Why it matters
- Observed. One union carries three different concepts.
AppearanceOptionsis'light' | 'dark' | 'darkElevated' | 'highContrast',
andTheme.host.appearanceisAppearanceOptions | 'dynamic'
(Theme.types.ts).
A theme in'highContrast'therefore has no representable light or dark
value, and a theme in'dynamic'has no resolved value at all. - Observed.
ThemeStatecannot answer "is this theme dark?".
useThemeState.ts
computeshighContrast: theme.host.appearance === 'highContrast' || isHighContrast(theme)and exposes no scheme value, so a component that needs
scheme-dependent behavior has to read the rawThemefrom context. - Observed. High contrast is determined differently on every platform:
constantfalsein
platformUtils.defaults.ts,
a module-level mutable flag set bysetIsHighContrastin
platformUtils.macos.ts,
atheme?.name === 'HighContrast'string check in
platformUtils.win32.ts,
andAppTheme.isHighContrastin
platformUtils.windows.ts. - Observed.
getCurrentAppearancealso diverges. The default implementation
returnsAppearance.getColorScheme() || fallbackfor'dynamic'; the Windows
implementation additionally returns'highContrast'when
AppTheme.isHighContrast, collapsing scheme and accessibility mode into one
return value. The default version treatsnullandundefinedinputs as the
fallback while the Windows version checks onlyundefined. - Inferred. Because the scheme is not resolved into
ThemeState, every
consumer that needs it re-derives it, and the macOS module-level mutable flag
means the value depends on call ordering rather than on the theme.
Observed current state
- Observed.
ThemeOptionshas bothappearance(including'dynamic') and
defaultAppearance, pluspaletteName
(Theme.types.ts). - Observed. The appearance helpers are exported publicly from the design
theming submodule
(theming/index.ts)
and re-exported by
theming-utils; they
were consolidated into design by
PR #4155. - Observed. Theme invalidation on appearance change is implemented per
platform theme rather than centrally:
createAppleTheme.macos.ts
registersAppearance.addChangeListenerand the macOShighContrastChanged
accessibility listener and callsThemeReference.invalidate(). - Observed.
useThemeStatecaches oneThemeStateperThemeobject
identity, so any appearance-derived value placed onThemeStateis only
recomputed when the theme object changes. - Observed. High-contrast token selection consumes the same union:
getAliasTokens(mode: AppearanceOptions)in
theme-tokens/src/getTokens.ts
branches on'light','dark' | 'darkElevated', and'highContrast'with
assertNever. - Observed. The Storybook app exposes exactly four fixed choices --
none, light, dark, high contrast -- built fromcreateDefaultTheme
(StorybookTheme.tsx); there
is no'dynamic'option.
Upstream appearance model (x3-design/fluent-design at d334acf)
- Observed. Upstream separates requested from resolved by construction. A
theme built bycreateThemealways carries both resolved modes
(ThemeResult.lightandThemeResult.dark), and selection happens at
consumption time through CSSlight-dark()withcolor-scheme, an explicit
[data-theme="light"|"dark"]selector, or aprefers-color-schememedia
query. Thedev/web/flex-themesREADME shows scoping a subtree by setting
color-schemeon an element. - Observed. The scheme is a two-value axis only: light and dark. There is no
darkElevatedequivalent. - Observed. Mode also parameterizes derived behavior rather than only token
values: the interaction algorithm's lightness direction and alpha constants are
per mode, andcontrast.mjstakes an explicitmodeargument with a
MODE_SURFACEconstant used to composite translucent colors. - Inferred. This supports modeling FURN's resolved scheme as a required,
always-concrete light or dark value onThemeState, with high contrast and any
elevated treatment as separate axes rather than as scheme members.
Scope
- Define the structured model: a requested color scheme (including a dynamic or
system option), a resolved scheme that is always concrete, and accessibility
modes such as high contrast and elevated dark treated as separate axes rather
than as scheme values. - Add the resolved values to
ThemeStatealongside the existinghighContrast
flag, keeping the identity and caching guarantees intact. - Unify the platform implementations behind one API, keeping platform detection
in the existing.macos.ts,.win32.ts, and.windows.tsfiles and removing
divergent behavior such as the'HighContrast'name check and the
null/undefinedinconsistency. - Define how a change in system appearance or high contrast invalidates the
theme and produces a newThemeState, and where that subscription belongs. - Provide a migration path for
AppearanceOptionsconsumers, including
getAliasTokensand the platform theme packages.
Out of scope
- Changing which token values each appearance resolves to; see
Default Values Codegen. - Adding a Flex-value context; see
Dynamic Theme Building. - Deriving interaction colors per scheme; see
Runtime Color Utilities. - Adding a dynamic appearance option to Storybook, which is listed as a
suggested addition.
Deliverables
- The structured appearance types, exported from the design theming submodule.
- Resolved scheme and accessibility values on
ThemeState. - One unified appearance API with consistent behavior across the default,
macOS, win32, and windows implementations. - A defined and implemented invalidation path for system appearance and
high-contrast changes. - A migration note plus updates for existing
AppearanceOptionsconsumers. - Tests covering resolution for each requested scheme on each platform
implementation, plusThemeStaterecomputation on change. - Changesets.
Acceptance criteria
- The requested scheme and the resolved scheme are separately representable,
and the resolved scheme is always a concrete light or dark value. - High contrast and any elevated dark treatment are represented
independently of the light/dark scheme. -
ThemeStateexposes the resolved scheme and the accessibility values, and
a component can determine whether the active theme is dark without reading
the rawTheme. - The default, macOS, win32, and windows implementations expose the same API
with documented per-platform sources, and no implementation infers high
contrast from a theme name string. -
getCurrentAppearancehandlesnullandundefinedidentically across
implementations. - A system appearance change or high-contrast change produces a new
ThemeState, covered by a test. - Existing consumers of
AppearanceOptions, including
getAliasTokens
and the platform theme packages, still compile and behave the same, or are
updated with a recorded migration. -
yarn build,yarn lage test, andyarn lage lintpass at the
repository root, and changesets are present.
Dependencies and ordering
- Depends on Dynamic Theme Building, which
determines howThemeStateis constructed and what a Flex-authored theme
supplies. - Pairs with Default Values Codegen: the
structured scheme model determines which generated appearance sets exist and
how one is selected. - Feeds Apple Theme, which relies on macOS appearance and
high-contrast resolution. - Feeds Runtime Color Utilities, whose
derivation direction depends on the resolved scheme.
Risks and open decisions
- Open decision. Whether
AppearanceOptionsis redefined in place or a new
type is added with the old one deprecated. Observed: it is exported
publicly from design theming and re-exported by thetheme-typesshim, and
getAliasTokensusesassertNeverover its members, so adding or removing a
member is a compile-time break there. - Open decision. Whether
'darkElevated'is a scheme, a modifier on dark, or
a platform-specific concept. Observed: it is currently a scheme value that
getAliasTokensfolds into the dark branch. - Open decision. Where appearance subscriptions live. Observed: they are
currently registered by individual platform themes such as
createAppleTheme.macos.ts, so a theme that does not register them never
updates. - Risk. The macOS module-level mutable
isHighContrastEnabledflag makes the
value global rather than per-theme; replacing it changes ordering behavior for
existing consumers that callsetIsHighContrastdirectly. - Risk.
ThemeStateis cached perThemeobject identity. If an appearance
change does not produce a new theme object, addedThemeStatevalues will go
stale; the invalidation path must be part of the change, not an assumption. - Risk. Platform detection must stay inside platform-suffixed files so the
React Native forks are not pulled into one type graph, per
AGENTS.md. - Risk, and a hard evidence gap. Observed: there is no high-contrast or
forced-colors theme anywhere in x3'sdev/web/flex-themes-- no such CSS file,
token set, or media query. FURN's high-contrast behavior therefore has no
upstream definition to align to and must be sourced from the platform APIs and
the Fluent token packages, which do ship high-contrast variants
(@fluentui-react-native/design-tokens-macoshclight/hcdarkand
@fluentui-react-native/design-tokens-win32hc). The owner should confirm
that split before the structured model is finalized.
Evidence and references
packages/agentic/design/src/theming/types/Theme.types.ts:AppearanceOptions,Theme.host.appearance,ThemeOptions.packages/agentic/design/src/theming/platformUtils.defaults.ts,platformUtils.macos.ts,platformUtils.win32.ts,platformUtils.windows.ts: the four divergent implementations.packages/agentic/design/src/useThemeState.ts: currentThemeStatefields and caching.packages/agentic/design/src/theming/themeReference.ts: invalidation mechanism.packages/theming/apple-theme/src/createAppleTheme.macos.ts: per-theme appearance subscriptions.packages/theming/theme-tokens/src/getTokens.ts:AppearanceOptionsconsumer withassertNever.apps/storybook/src/StorybookTheme.tsx: fixed appearance choices with no dynamic option.- microsoft/fluentui-react-native@
80bf14d: PR Consolidate getCurrentAppearance, isHighContrast and setHighContrast in design package #4155, consolidated the appearance helpers into design. - microsoft/fluentui-react-native@
ea738f0: PR Ensure that useThemeState will work with existing furn themes #4186,ThemeStateover existing FURN themes. - x3
dev/web/flex-themes/README.md:color-schemescoping, adaptive versus split files, and mode selectors. - x3
dev/web/flex-themes/createTheme.d.ts:ThemeResultcarrying both resolved modes andtoCssmode selection. - x3
dev/web/flex-themes/contrast.d.ts: explicitmodeparameter andMODE_SURFACE.
Reactions are currently unavailable
Metadata
Metadata
Assignees
Labels
No labels
Activity
[-]Better System Appearance Handling[/-][+]Theming: Better System Appearance Handling[/+]