Repository navigation
Theming: Contrast Audit Submodule #4262
Description
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
includesbackgroundNeutralTranslucent,backgroundNeutralTransparent,
backgroundBrandTransparent, andstrokeNeutralTransparent, and the
verified test vectors in Runtime Color Utilities
confirm that several interaction-state tokens are semi-transparent (for
examplebackground-neutral-subtleis#0000000din light mode and
#ffffff14in dark mode). A contrast check on raw alpha-channel values
without compositing them over a backdrop produces wrong ratios. - Observed. x3 packages
./contrastas a separate subpath entry and its
README states both./oklchand./contrast"tree-shake independently
(<=4 KB minified each)".
Observed current state
FURN baseline
- Observed. No contrast-ratio or luminance code exists anywhere in
packages/.SemanticColorscarries every authored and generated color value
but the repository has no API to evaluate pairs of them against a threshold. - Observed.
ColorValuein React Native can be a literal hex orrgb()
string, aPlatformColorobject, or aDynamicColorMacOS/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./testingare each separate
exportsentries 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./contrastis added.
Upstream contrast module (x3-design/fluent-design at d334acf)
All paths below are relative to the x3 repository at commit
d334acf5cbad813f2b7cd554da942b09a7ff8f10.
- Observed.
contrast.mjsdefines four WCAG threshold constants:aaText
4.5,aaLargeText3.0,aaaText7.0, andvisibleStroke3.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_SURFACEprovides 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 filecontrast.d.tsstates
MODE_SURFACE.darkas#242424, which does not match the implementation.
Inferred: the declaration file is stale; the implementation value should
be used. - Observed.
PAIRINGSis 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-primaryoverbackground-neutral-soft,
foreground-neutral-primaryoversurface-neutral-farther), loud-variant
text pairs (foreground-neutral-onloudoverbackground-neutral-heavy,
foreground-brand-onloudoverbackground-brand-loud), and visible-stroke
pairs (stroke-neutral-heavyoverbackground-neutral-soft). Ratios range
fromaaLargeText(3.0) for strokes toaaText(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 againstMODE_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-themesdeclares./contrastas a separate
subpathexportsentry 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_SURFACErecord for light and dark modes, derived from the
upstream implementation (not the declaration file). - Build a
PAIRINGStable 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
PairResulttype 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 whoseColorValueis not a resolvable
hex orrgb()literal. - Implement
resolvedPairs(colors, mode)that takes aSemanticColorTokenValues
and a resolved mode ('light' | 'dark'), composites alpha values, computes
ratios, and returns aPairResult[]. Do not accept aFlexTokensdirectly;
operate on the flat color values so the function is usable without a full
theme. - Publish the submodule as
./contrastin the designexportsmap with its
ownreact-native,import, andtypesconditions, 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
resolvedPairsagainst 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./contrastdoes not contain it. - Document a CI step that runs the conformance test and fails on any new
failresult.
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, orDynamicColorIOSvalues
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
- WCAG threshold constants (
aaText,aaLargeText,aaaText,
visibleStroke) matching the upstream values. - A
composite(fg, bg)helper with straight-alpha compositing over a hex
backdrop, covering six-digit and eight-digit hex inputs. - A
MODE_SURFACErecord for light and dark, taken from the upstream
contrast.mjsimplementation. - A
PAIRINGSconstant mapping FURN camel-case token names to minimum ratios,
with upstream kebab-case names recorded inline as provenance. - A
PairResulttype with pairing definition, computed ratio, pass/fail
result, and'unresolvable'status. - A
resolvedPairs(colors, mode)implementation operating on
SemanticColorTokenValues. - A
./contrastexports entry in the design packagepackage.json. - 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. - A CI step that runs the conformance test and fails on any new
failresult,
with the step command recorded in the task. - Evidence that a bundle not importing
./contrastdoes not contain it, with
a cited repeatable command. - Changesets for every changed package.
Acceptance criteria
- WCAG threshold constants match the upstream
contrast.mjsvalues
exactly. -
composite(fg, bg)reproduces straight-alpha compositing results for
representative semi-transparent FURN values (for example
#0000000dover#fffffffor light mode,#ffffff14over#000000
for dark mode), verified by unit test. -
PAIRINGScovers 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. -
resolvedPairsreturns'unresolvable'for every pairing that includes
aPlatformColororDynamicColorMacOStoken value, without throwing
or returning a numeric ratio. -
resolvedPairscalled against the generated light defaults returns a
passing result for every literal-color pair. -
resolvedPairscalled against the generated dark defaults returns a
passing result for every literal-color pair. - The
./contrastsubmodule has its ownexportsentry and no symbol from
it is reachable through the root barrel or any other submodule. - A bundle produced without importing
./contrastdoes not contain it,
demonstrated with a cited repeatable command. - The CI step is documented and fails reproducibly when a pair is added to
PAIRINGSwhose generated value does not meet its minimum ratio. -
yarn build,yarn lage test, andyarn lage lintpass at the
repository root, and changesets are present for every changed package.
Dependencies and ordering
- Depends on Package Consolidation for the
optional-submodule pattern,exportsentry 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.tsbecause that file has 249 literals with only 80 distinct
values and contains known duplicated hover and pressed entries. - Should follow System Appearance Handling so
resolvedPairscan be called with the resolved scheme fromThemeState
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
PAIRINGScovers 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
resolvedPairsas 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.tsin the upstream package declaresMODE_SURFACE.dark
as#242424whilecontrast.mjsdefines 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
PlatformColorand
DynamicColorMacOSvalues (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
packages/agentic/design/src/tokens/flex.types.ts:
SemanticColorTokenValues(all rest token names),InteractiveColorOverrides
(59-member interactive subset), andSemanticColors(rest plus hover and
pressed maps).packages/agentic/design/src/tokens/defaultTokens.ts:
Current hand-authored color literals including alpha-channel values; will be
replaced by generated defaults before the conformance test uses them.packages/agentic/design/package.json:
Currentexportsmap and submoduleexportsconventions.packages/agentic/design/src/styling/colorStyles.ts:
ThegetInteractionColorsresolver showing how components access color state
values.AGENTS.md:
Export, build, and validation rules, including the prohibition on barrel
re-exports.
Upstream (x3-design/fluent-design @ d334acf5cbad813f2b7cd554da942b09a7ff8f10)
dev/web/flex-themes/contrast.mjs:
WCAG thresholds,composite,MODE_SURFACE,PAIRINGS, and
resolvedPairs. Canonical implementation for all ported values.dev/web/flex-themes/contrast.d.ts:
Declaration file;MODE_SURFACE.darkvalue here (#242424) does not match
the implementation (#000000). Do not use for constant values.dev/web/flex-themes/package.json:
./contrastsubpathexportsentry; tree-shaking claim in README.dev/web/flex-themes/README.md:
States./contrastis the interface used by x3's theme tool accessibility
audit; tree-shaking claim for./oklchand./contrastsubpaths.dev/web/flex-tokens/mappings.json:
Token name list used to cross-reference upstream kebab-case names against
FURN camel-case names inPAIRINGS.dev/web/flex-themes/css/semantic-light.css
andsemantic-dark.css:
Resolved token values per mode; source for any expected-ratio spot checks
against the upstream PAIRINGS table.
Activity
[-]Contrast Audit Submodule[/-][+]Theming: Contrast Audit Submodule[/+]