Skip to content

Theming: Better System Appearance Handling #4270

Description

@JasonVMo

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.
    AppearanceOptions is 'light' | 'dark' | 'darkElevated' | 'highContrast',
    and Theme.host.appearance is AppearanceOptions | '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. ThemeState cannot answer "is this theme dark?".
    useThemeState.ts
    computes highContrast: theme.host.appearance === 'highContrast' || isHighContrast(theme) and exposes no scheme value, so a component that needs
    scheme-dependent behavior has to read the raw Theme from context.
  • Observed. High contrast is determined differently on every platform:
    constant false in
    platformUtils.defaults.ts,
    a module-level mutable flag set by setIsHighContrast in
    platformUtils.macos.ts,
    a theme?.name === 'HighContrast' string check in
    platformUtils.win32.ts,
    and AppTheme.isHighContrast in
    platformUtils.windows.ts.
  • Observed. getCurrentAppearance also diverges. The default implementation
    returns Appearance.getColorScheme() || fallback for 'dynamic'; the Windows
    implementation additionally returns 'highContrast' when
    AppTheme.isHighContrast, collapsing scheme and accessibility mode into one
    return value. The default version treats null and undefined inputs as the
    fallback while the Windows version checks only undefined.
  • 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. ThemeOptions has both appearance (including 'dynamic') and
    defaultAppearance, plus paletteName
    (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
    registers Appearance.addChangeListener and the macOS highContrastChanged
    accessibility listener and calls ThemeReference.invalidate().
  • Observed. useThemeState caches one ThemeState per Theme object
    identity, so any appearance-derived value placed on ThemeState is 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 from createDefaultTheme
    (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 by createTheme always carries both resolved modes
    (ThemeResult.light and ThemeResult.dark), and selection happens at
    consumption time through CSS light-dark() with color-scheme, an explicit
    [data-theme="light"|"dark"] selector, or a prefers-color-scheme media
    query. The dev/web/flex-themes README shows scoping a subtree by setting
    color-scheme on an element.
  • Observed. The scheme is a two-value axis only: light and dark. There is no
    darkElevated equivalent.
  • Observed. Mode also parameterizes derived behavior rather than only token
    values: the interaction algorithm's lightness direction and alpha constants are
    per mode, and contrast.mjs takes an explicit mode argument with a
    MODE_SURFACE constant used to composite translucent colors.
  • Inferred. This supports modeling FURN's resolved scheme as a required,
    always-concrete light or dark value on ThemeState, 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 ThemeState alongside the existing highContrast
    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.ts files and removing
    divergent behavior such as the 'HighContrast' name check and the
    null/undefined inconsistency.
  • Define how a change in system appearance or high contrast invalidates the
    theme and produces a new ThemeState, and where that subscription belongs.
  • Provide a migration path for AppearanceOptions consumers, including
    getAliasTokens and the platform theme packages.

Out of scope

Deliverables

  1. The structured appearance types, exported from the design theming submodule.
  2. Resolved scheme and accessibility values on ThemeState.
  3. One unified appearance API with consistent behavior across the default,
    macOS, win32, and windows implementations.
  4. A defined and implemented invalidation path for system appearance and
    high-contrast changes.
  5. A migration note plus updates for existing AppearanceOptions consumers.
  6. Tests covering resolution for each requested scheme on each platform
    implementation, plus ThemeState recomputation on change.
  7. 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.
  • ThemeState exposes the resolved scheme and the accessibility values, and
    a component can determine whether the active theme is dark without reading
    the raw Theme.
  • 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.
  • getCurrentAppearance handles null and undefined identically 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, and yarn lage lint pass at the
    repository root, and changesets are present.

Dependencies and ordering

  • Depends on Dynamic Theme Building, which
    determines how ThemeState is 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 AppearanceOptions is 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 the theme-types shim, and
    getAliasTokens uses assertNever over 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
    getAliasTokens folds 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 isHighContrastEnabled flag makes the
    value global rather than per-theme; replacing it changes ordering behavior for
    existing consumers that call setIsHighContrast directly.
  • Risk. ThemeState is cached per Theme object identity. If an appearance
    change does not produce a new theme object, added ThemeState values 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's dev/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-macos hclight/hcdark and
    @fluentui-react-native/design-tokens-win32 hc). The owner should confirm
    that split before the structured model is finalized.

Evidence and references

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions