Skip to content

Theming: Contrast Audit Submodule #4262

Closed

Description

@JasonVMo

Summary

Port x3's contrast module -- WCAG ratio thresholds, an alpha compositing helper,
a PAIRINGS table of token pairs with minimum ratios, and resolvedPairs --
into a tree-shakeable ./contrast submodule in @fluentui-react-native/design.
Extend the model to emit a structured diagnostic for pairs that include opaque
platform color objects, so a FURN theme that relies on PlatformColor or
DynamicColorMacOS still produces a useful audit rather than a silent skip.
Add a conformance test driven by the generated defaults and a CI step so
contrast regressions are caught before they ship.

This task does not derive new colors; derivation is owned by
Runtime Color Utilities. It audits authored or
generated values against WCAG thresholds.

Goal

Provide a ./contrast submodule with WCAG thresholds, alpha compositing, a
PAIRINGS table mapping FURN token pairs to minimum required ratios, a
resolvedPairs function that returns a structured result per pair including a
'unresolvable' status for native color values, and a conformance test that
gates regressions in CI.

Stage

Stage 3 - Production fidelity.

Why it matters

  • Observed. x3 ships a contrast module alongside its interaction algorithm
    in
    dev/web/flex-themes/contrast.mjs,
    and its README states this module is the same interface its theme tool's
    accessibility audit uses.
  • Observed. FURN has no equivalent. A search across packages/ for
    contrast-ratio or relative-luminance helpers returned no implementation;
    there are no WCAG assertions in any test file.
  • Inferred. A FURN theme that authors or generates new color values can
    regress WCAG contrast silently, with no automated gate to catch it before
    components ship.
  • Observed. Many FURN tokens carry alpha channels.
    SemanticColorTokenValues
    includes backgroundNeutralTranslucent, backgroundNeutralTransparent,
    backgroundBrandTransparent, and strokeNeutralTransparent, and the
    verified test vectors in Runtime Color Utilities
    confirm that several interaction-state tokens are semi-transparent (for
    example background-neutral-subtle is #0000000d in light mode and
    #ffffff14 in dark mode). A contrast check on raw alpha-channel values
    without compositing them over a backdrop produces wrong ratios.
  • Observed. x3 packages ./contrast as a separate subpath entry and its
    README states both ./oklch and ./contrast "tree-shake independently
    (<=4 KB minified each)".

Observed current state

FURN baseline

  • Observed. No contrast-ratio or luminance code exists anywhere in
    packages/. SemanticColors carries every authored and generated color value
    but the repository has no API to evaluate pairs of them against a threshold.
  • Observed. ColorValue in React Native can be a literal hex or rgb()
    string, a PlatformColor object, or a DynamicColorMacOS / DynamicColorIOS
    record. The last two are opaque at runtime in JavaScript; their resolved
    tint depends on the native color system and cannot be read back in JS.
  • Observed. The design package already follows the optional-submodule
    pattern: ./theming, ./styling, and ./testing are each separate
    exports entries in
    packages/agentic/design/package.json
    with no cross-entry re-exports.
  • Observed. Package Consolidation defines the
    conventions for adding a new optional submodule entry to the design package.
    That pattern must be established before ./contrast is added.

Upstream contrast module (x3-design/fluent-design at d334acf)

All paths below are relative to the x3 repository at commit
d334acf5cbad813f2b7cd554da942b09a7ff8f10.

  • Observed. contrast.mjs defines four WCAG threshold constants: aaText
    4.5, aaLargeText 3.0, aaaText 7.0, and visibleStroke 3.0.
  • Observed. The composite(fg, bg) helper composites a foreground hex
    color over a background hex color using straight-alpha compositing, returning
    the opaque result as a hex string. It is used to resolve alpha-channel
    foreground tokens before computing relative luminance.
  • Observed. MODE_SURFACE provides the backdrop reference used when
    compositing a foreground against the ambient page surface rather than a
    specific background token: { light: '#ffffff', dark: '#000000' } in
    contrast.mjs. The declaration file contrast.d.ts states
    MODE_SURFACE.dark as #242424, which does not match the implementation.
    Inferred: the declaration file is stale; the implementation value should
    be used.
  • Observed. PAIRINGS is a table of objects, each with a foreground token
    name, a background token name, and a minimum ratio drawn from the WCAG
    thresholds. Representative entries include normal body text pairs
    (foreground-neutral-primary over background-neutral-soft,
    foreground-neutral-primary over surface-neutral-farther), loud-variant
    text pairs (foreground-neutral-onloud over background-neutral-heavy,
    foreground-brand-onloud over background-brand-loud), and visible-stroke
    pairs (stroke-neutral-heavy over background-neutral-soft). Ratios range
    from aaLargeText (3.0) for strokes to aaText (4.5) for body text.
  • Observed. resolvedPairs(theme, mode) accepts an x3 theme object and a
    mode string, resolves each pair's foreground and background values from the
    theme, composites alpha-channel values against MODE_SURFACE[mode], computes
    the relative luminance ratio, and returns an array of result objects that
    include the pair definition, computed ratio, and whether it passes.
  • Observed. @x3-design/flex-themes declares ./contrast as a separate
    subpath exports entry alongside ./oklch.

Scope

  • Define WCAG threshold constants matching upstream (aaText, aaLargeText,
    aaaText, visibleStroke).
  • Implement the composite(fg, bg) helper using straight-alpha compositing
    against a hex backdrop, supporting the six-digit and eight-digit hex formats
    present in FURN's generated defaults.
  • Define a MODE_SURFACE record for light and dark modes, derived from the
    upstream implementation (not the declaration file).
  • Build a PAIRINGS table expressed with FURN's camel-case token names from
    SemanticColorTokenValues, covering the same token families as the upstream
    table. Record upstream token names alongside FURN names so parity is
    verifiable.
  • Define a PairResult type with fields for the pairing definition, the
    computed contrast ratio, the WCAG pass/fail result, and a 'unresolvable'
    status for pairs that include a token whose ColorValue is not a resolvable
    hex or rgb() literal.
  • Implement resolvedPairs(colors, mode) that takes a SemanticColorTokenValues
    and a resolved mode ('light' | 'dark'), composites alpha values, computes
    ratios, and returns a PairResult[]. Do not accept a FlexTokens directly;
    operate on the flat color values so the function is usable without a full
    theme.
  • Publish the submodule as ./contrast in the design exports map with its
    own react-native, import, and types conditions, following the pattern
    in Package Consolidation. Do not re-export any
    of its symbols from the root barrel.
  • Add a Jest conformance test that calls resolvedPairs against the generated
    light and dark defaults (from Default Values Codegen),
    asserts all literal-color pairs pass their minimum ratio, and asserts all
    platform-color pairs emit an 'unresolvable' status rather than throwing or
    silently returning a ratio.
  • Verify tree-shaking: demonstrate with a cited repeatable command that a
    bundle produced without importing ./contrast does not contain it.
  • Document a CI step that runs the conformance test and fails on any new
    fail result.

Out of scope

  • Deriving hover, pressed, or any other interaction color from a rest value;
    that is owned by Runtime Color Utilities.
  • Resolving PlatformColor, DynamicColorMacOS, or DynamicColorIOS values
    at runtime. The diagnostic for unresolvable values is the defined behavior;
    native resolution is not a JS-layer concern.
  • Changing any token value to improve its contrast ratio; this task only
    measures and reports.
  • High-contrast token sourcing or forced-colors mode behavior; see
    System Appearance Handling and the suggested
    high-contrast token source consolidation.
  • Auditing typography tokens (font size, weight, line height) for conformance;
    those map to different WCAG criteria and are a separate accessibility concern.
  • The Bebop Warm translucent-backdrop compositing path from x3; it applies to a
    specific theme variant not present in FURN.

Deliverables

  1. WCAG threshold constants (aaText, aaLargeText, aaaText,
    visibleStroke) matching the upstream values.
  2. A composite(fg, bg) helper with straight-alpha compositing over a hex
    backdrop, covering six-digit and eight-digit hex inputs.
  3. A MODE_SURFACE record for light and dark, taken from the upstream
    contrast.mjs implementation.
  4. A PAIRINGS constant mapping FURN camel-case token names to minimum ratios,
    with upstream kebab-case names recorded inline as provenance.
  5. A PairResult type with pairing definition, computed ratio, pass/fail
    result, and 'unresolvable' status.
  6. A resolvedPairs(colors, mode) implementation operating on
    SemanticColorTokenValues.
  7. A ./contrast exports entry in the design package package.json.
  8. A Jest conformance test asserting the generated light and dark defaults pass
    all literal-color pairings and return 'unresolvable' (not an error) for
    platform-color pairings.
  9. A CI step that runs the conformance test and fails on any new fail result,
    with the step command recorded in the task.
  10. Evidence that a bundle not importing ./contrast does not contain it, with
    a cited repeatable command.
  11. Changesets for every changed package.

Acceptance criteria

  • WCAG threshold constants match the upstream contrast.mjs values
    exactly.
  • composite(fg, bg) reproduces straight-alpha compositing results for
    representative semi-transparent FURN values (for example
    #0000000d over #ffffff for light mode, #ffffff14 over #000000
    for dark mode), verified by unit test.
  • PAIRINGS covers at least the token families present in the upstream
    table (neutral body text, loud-variant text, brand text on brand
    background, visible strokes) and records the upstream kebab-case name for
    each entry.
  • resolvedPairs returns 'unresolvable' for every pairing that includes
    a PlatformColor or DynamicColorMacOS token value, without throwing
    or returning a numeric ratio.
  • resolvedPairs called against the generated light defaults returns a
    passing result for every literal-color pair.
  • resolvedPairs called against the generated dark defaults returns a
    passing result for every literal-color pair.
  • The ./contrast submodule has its own exports entry and no symbol from
    it is reachable through the root barrel or any other submodule.
  • A bundle produced without importing ./contrast does not contain it,
    demonstrated with a cited repeatable command.
  • The CI step is documented and fails reproducibly when a pair is added to
    PAIRINGS whose generated value does not meet its minimum ratio.
  • yarn build, yarn lage test, and yarn lage lint pass at the
    repository root, and changesets are present for every changed package.

Dependencies and ordering

  • Depends on Package Consolidation for the
    optional-submodule pattern, exports entry conventions, and the correct
    submodule boundary inside @fluentui-react-native/design.
  • Depends on Default Values Codegen for
    generated light and dark color literals usable as conformance fixtures. The
    conformance test cannot exercise literal-color assertions against
    defaultTokens.ts because that file has 249 literals with only 80 distinct
    values and contains known duplicated hover and pressed entries.
  • Should follow System Appearance Handling so
    resolvedPairs can be called with the resolved scheme from ThemeState
    rather than requiring callers to re-derive it from the theme.
  • Independent of Runtime Color Utilities;
    the two tasks may land in either order since they share no code paths.
    However, the conformance test should also cover the interaction-state token
    values produced by that task once it lands, since hover and pressed values
    must also meet their applicable ratio thresholds.

Risks and open decisions

  • Open decision. Whether PAIRINGS covers only a curated subset of pairs
    or all theoretically checkable foreground-on-background combinations. A full
    cross-product of foreground and background tokens would produce hundreds of
    pairs, most of which are not used together by any component. Inferred:
    a curated table modeled on the upstream one is preferable; it can be extended
    per component as component tasks land.
  • Open decision. Whether to expose resolvedPairs as a test-only utility
    or as a publicly exported API. Observed: x3 exports it from a named
    subpath, treating it as a public tool. Inferred: marking it as
    experimental and shipping it in the submodule is consistent with the
    production-fidelity stage; consumers building custom themes can use it for
    their own audits.
  • Open decision. Whether the CI step runs the conformance test as part of
    yarn lage test (which already runs all Jest suites) or as a separate named
    step. Inferred: placing the assertion inside Jest keeps it consistent with
    the rest of the test pipeline; a separate step is only warranted if the test
    requires output that cannot be expressed as a Jest assertion.
  • Risk. contrast.d.ts in the upstream package declares MODE_SURFACE.dark
    as #242424 while contrast.mjs defines it as #000000. Observed in the
    Runtime Color Utilities task. FURN's port
    should use the implementation value and record this discrepancy; any future
    upstream sync must re-verify this constant rather than copying the declaration.
  • Risk. The Apple theme is built predominantly from PlatformColor and
    DynamicColorMacOS values (see Apple Theme). The
    contrast audit will therefore emit 'unresolvable' for most Apple-theme
    pairs. The conformance test should assert the 'unresolvable' status as
    the expected outcome for those pairs, not treat them as failures, and a
    follow-up decision is needed for whether platform-color contrast is audited
    through a separate native mechanism.
  • Risk. FURN's token set includes semi-transparent tokens that must be
    composited against a background before measurement. The compositing backdrop
    for 'unresolvable' background tokens (platform colors) is itself
    unresolvable, so a semi-transparent foreground over a platform-color
    background must emit 'unresolvable' for both the background and the
    composite result.

Evidence and references

Local

Upstream (x3-design/fluent-design @ d334acf5cbad813f2b7cd554da942b09a7ff8f10)

Activity

  1. changed the title [-]Contrast Audit Submodule[/-] [+]Theming: Contrast Audit Submodule[/+] on Aug 21, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

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