Skip to content

Theming: Runtime Color Utilities #4268

Description

@JasonVMo

Summary

Add an opt-in design submodule that derives hover and pressed interaction
colors from a base color using the x3 OKLCH algorithm. The implementation must
preserve the algorithm's lightness curve, alpha shifts, mode constants, and
standard-versus-inverse token direction without entering bundles that do not
import it.

FURN's 59 interaction-token names match the upstream set exactly, and the
published x3 fallback values provide conformance vectors for the port.

Goal

Provide a runtime library that generates hover, pressed, and related interaction
color values from a base color using the x3 theme algorithms, in a submodule
that is only bundled when a consumer imports it.

Stage

Stage 3 - Production fidelity.

Why it matters

  • Observed. No color math exists in this repository. A search across
    packages/ for color derivation helpers (lighten, darken, HSL conversion,
    RGBA construction, color utility modules) returned no implementation.
  • Observed. Interaction values are enumerated by hand.
    defaultTokens.ts
    restates the full token list in its color, color.hover, and color.pressed
    maps, producing 249 color literals with only 80 distinct values; many hover and
    pressed entries repeat the rest value verbatim.
  • Observed. The contract already matches upstream exactly. FURN's
    InteractiveColorOverrides has 59 members
    (flex.types.ts),
    and x3's mappings.interaction has 118 entries covering 59 base tokens; the
    two name sets are identical after kebab-case to camel-case conversion, with no
    member on either side unmatched.
  • Observed. The consumption path already prefers a state override and falls
    back to the rest value: getInteractionColors in
    colorStyles.ts
    resolves colors.hover or colors.pressed and uses
    interactionColors?.[colorKey] ?? colors[colorKey]. Inferred: a derivation
    library can populate those maps without changing component code.

Observed current state

Upstream algorithm (x3-design/fluent-design at d334acf)

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

  • Observed. Interaction states are computed in OKLCH, at runtime on the
    web, through four CSS custom properties defined in
    dev/web/flex-themes/css/semantic.css (and its semantic-light.css /
    semantic-dark.css split files): --interaction-standard-hover,
    --interaction-standard-pressed, --interaction-inverse-hover, and
    --interaction-inverse-pressed. Each is a relative-color fragment applied as
    oklch(from <base> <fragment>).
  • Observed. The light-mode fragments are, in x3's own notation,
    calc(l - 0.03 * clamp(1,(.5 - l)/.1,3)) c h / calc(alpha + 0.04) for standard
    hover and calc(l - 0.06 * ...) c h / calc(alpha + 0.08) for standard pressed.
    The inverse fragments use the opposite lightness sign and leave alpha
    unchanged. In dark mode every lightness sign flips and the alpha additions are
    0.03 and 0.06.
  • Observed. Chroma (c) and hue (h) are passed through unchanged in the
    default theme. Only lightness and alpha move.
  • Observed. The amplification curve is clamp((0.50 - L) / 0.10, 1, 3),
    defined once as interactionCurveMult in
    dev/web/flex-themes/scripts/build.mjs. It multiplies the lightness delta by 1
    at L >= 0.4 and rises to a hard cap of 3 as lightness approaches 0, so dark
    colors move further than light ones.
  • Observed. The per-mode constants live in the INTERACTION_VALUES table in
    the same file: hover is -0.03 lightness with +0.04 alpha in light and
    +0.03 with +0.03 in dark; pressed is -0.06 with +0.08 in light and
    +0.06 with +0.06 in dark.
  • Observed. Direction selection is a single predicate in build.mjs:
    isInverseInteraction = (name) => /heavy|onloud|loud/.test(name). Tokens whose
    name contains heavy, loud, or onloud use the inverse fragments; all
    others use standard.
  • Observed. The OKLCH conversions are implemented in
    dev/web/flex-themes/oklch.mjs as rgbToOklch and oklchToRgb, using the
    standard sRGB to linear to LMS to Oklab pipeline with cube-root compression.
    The module also exports generateBrandRamp, autoStepFromColor,
    CHROMA_PRESETS, taperFactor, and BRAND_RAMP_STEPS for brand ramp
    generation, which is a separate concern from interaction states.
  • Observed. For environments without OKLCH support, x3 ships a precomputed
    opt-in fallback at dev/web/flex-themes/css/interaction-fallback.css (130
    lines) -- the exact file FURN's
    flex.types.ts
    and flex-token-map.yaml
    already name as the source of truth. It emits light-dark(<lightHex>, <darkHex>) per token, sourced from mappings.interaction in the
    @x3-design/flex-tokens package rather than computed at build time.
  • Observed. The "Bebop Warm" theme uses an extended variant, implemented in
    build.mjs as warmInteractionRgb and warmInteractionColor: it scales the
    lightness delta by an opacity factor clamp(alpha + 0.35, 0.35, 1), adds a
    small chroma boost, clamps lightness at mode-specific bounds, and for
    translucent background and stroke layers composites against a reference
    backdrop and solves back for the source color. The default theme does not use
    this path.
  • Observed. The legacy --lightness-hover, --lightness-press,
    --alpha-hover, and --alpha-press variables are retained in the CSS as
    deprecated compatibility output, are no longer consumed by the fragments, and
    are documented for removal in 1.0.0.
  • Observed. x3 publishes contrast tooling alongside the algorithm in
    dev/web/flex-themes/contrast.mjs: WCAG thresholds (aaText 4.5,
    aaLargeText 3.0, aaaText 7.0, visibleStroke 3.0), an alpha composite()
    helper, a PAIRINGS table of foreground and background token pairs with
    minimum ratios, and resolvedPairs(theme, mode).
  • Observed. Packaging precedent: @x3-design/flex-themes@0.8.0 exposes
    ./oklch and ./contrast as separate export subpaths, and its README states
    both "tree-shake independently (<=4 KB minified each)". The package is alpha and
    its README instructs consumers to pin exact versions.

Verified test vectors

Observed. Reimplementing only the default-theme rule -- lightness shifted by
delta * clamp((0.5 - L)/0.1, 1, 3), chroma and hue unchanged, alpha increased
for standard tokens and left alone for inverse tokens -- on top of x3's own
rgbToOklch and oklchToRgb reproduces the published fallback hexes exactly for
every case checked, including alpha and translucent tokens:

Token Direction Mode Base Hover Pressed
surface-neutral-farther standard light #ebebeb #e1e1e1 #d7d7d7
background-neutral-heavy inverse light #242424 #363636 #494949
background-brand-loud inverse light #6f6f6f #787878 #818181
background-danger-loud inverse light #c02e56 #cb395e #d54467
background-neutral-subtle standard light #0000000d #00000017 #00000021
foreground-neutral-primary standard light #000000db #000000e5 #000000ef
surface-neutral-farther standard dark #1b1b1b #2f2f2f --
background-neutral-heavy inverse dark #ffffff #f5f5f5 --
background-neutral-subtle standard dark #ffffff14 #ffffff1c --
surface-neutral-translucent standard dark #3c3c3c80 #47474788 --

Base values are from x3's semantic-light.css and semantic-dark.css; expected
hover and pressed values are from interaction-fallback-light.css and
interaction-fallback-dark.css, both at d334acf. Inferred: these are
usable directly as the port's test vectors, and the full 118-entry
mappings.interaction table can serve as an exhaustive conformance fixture.

Scope

  • Port the default-theme interaction algorithm to React Native: OKLCH conversion,
    the amplification curve, the per-mode delta table, and the standard versus
    inverse direction rule.
  • Implement the direction rule against FURN's camel-case token names, preserving
    the same 59-token classification x3's heavy|loud|onloud predicate produces.
  • Implement the derivation in a design submodule with its own exports entry,
    not re-exported from the design root barrel, following the ./oklch and
    ./contrast precedent upstream.
  • Support deriving a full InteractiveColorOverrides map from a
    SemanticColorTokenValues set, and deriving a single state value from a single
    rest value.
  • Define and implement the behavior for ColorValue inputs that are not literal
    colors (platform color objects, dynamic colors) rather than silently producing
    a wrong value.
  • Provide an opt-in integration point so a theme that supplies only rest values
    gets derived interaction values, without changing components.
  • Verify tree-shaking: a bundle that does not import the submodule must not
    contain it.

Out of scope

  • The Bebop Warm variant (opacity factor, chroma boost, lightness clamps,
    translucent backdrop compositing). It is a separate theme's algorithm and
    should only be ported if a FURN theme adopts that theme.
  • Brand ramp generation (generateBrandRamp, autoStepFromColor, chroma
    presets). It is a theme-construction concern, not an interaction concern.
  • Contrast auditing; see the
    suggested addition for adopting x3's
    PAIRINGS and resolvedPairs.
  • Replacing authored interaction values in the generated defaults. Derivation is
    a fallback for themes that lack them; generated defaults are owned by
    Default Values Codegen.
  • Changing SemanticColors or InteractiveColorOverrides shape.
  • Selecting light versus dark behavior; that resolution comes from
    System Appearance Handling.

Deliverables

  1. A design submodule implementing OKLCH conversion and the default interaction
    algorithm, with its own exports entry.
  2. The direction classification for all 59 interactive tokens, expressed so it
    stays verifiable against upstream.
  3. Defined handling for non-literal ColorValue inputs.
  4. An opt-in path that populates hover and pressed for a theme that supplies
    only rest values.
  5. A conformance test using the vectors above, plus a broader fixture derived
    from x3's 118-entry interaction table.
  6. Evidence that the submodule is excluded from a bundle that does not import
    it.
  7. A recorded note of the upstream commit the port was taken from, so drift can
    be re-checked.
  8. Changesets.

Acceptance criteria

  • The port reproduces every vector in the table above exactly, for both
    modes and both states.
  • The port reproduces the light and dark values of x3's 118-entry
    mappings.interaction table from the corresponding semantic-*.css base
    values, or every deviation is listed with a recorded reason.
  • Direction classification matches upstream for all 59 interactive tokens.
  • Alpha handling matches upstream: standard tokens add the per-mode alpha
    delta clamped to 1, inverse tokens leave alpha unchanged.
  • The submodule has its own exports entry in the design package and is not
    reachable from the design root barrel.
  • A bundle produced without importing the submodule does not contain its
    code, demonstrated with a cited repeatable command.
  • Non-literal ColorValue inputs follow the defined behavior and surface a
    diagnostic rather than returning a silently incorrect color.
  • Components rendering with a theme that has authored interaction values are
    unchanged; derivation applies only where a value is absent.
  • The upstream commit used as the source is recorded in the repository.
  • yarn build, yarn lage test, and yarn lage lint pass at the
    repository root, and changesets are present.

Dependencies and ordering

  • Depends on Package Consolidation for the
    optional-submodule pattern and exports conventions.
  • Depends on Default Values Codegen to identify
    which interaction states have no authored source.
  • Should follow System Appearance Handling so the
    derivation can read the resolved scheme rather than re-deriving it, since the
    algorithm's constants and lightness direction are mode-dependent.
  • Feeds Apple Theme, which may supply rest values from
    system colors without authored interaction states.

Risks and open decisions

  • Open decision. Whether derivation runs eagerly when a theme is built or
    lazily per token on first read. Observed: the algorithm is a handful of
    floating-point operations plus two color-space conversions per token, and there
    are 59 tokens per state. Inferred: eager derivation is bounded and
    predictable; lazy derivation complicates the frozen-object and identity
    guarantees that useThemeState relies on.
  • Open decision. Whether to implement the OKLCH math locally or take a
    dependency. Observed: the design package currently has exactly one runtime
    dependency, and x3's own implementation is a self-contained module with no
    dependencies, so a local implementation has upstream precedent.
  • Open decision. Whether FURN should consume x3's precomputed fallback table
    as generated defaults, compute at runtime, or both. Observed: x3 itself
    ships both, and its default theme's fallback values are authored upstream in
    @x3-design/flex-tokens rather than computed during the CSS build.
  • Risk. ColorValue can be an opaque platform color (PlatformColor,
    DynamicColorMacOS). Deriving from it is not possible without resolving it
    natively. This must be an explicit, surfaced limitation rather than a silent
    fallback, and it directly affects Apple Theme, whose
    palette is built almost entirely from platform colors.
  • Risk. Upstream is alpha and explicitly versions-in-flux: the
    @x3-design/flex-themes README tells consumers to pin exact versions, and the
    legacy delta variables are scheduled for removal in 1.0.0. The port needs a
    recorded source commit and a re-check step.
  • Risk. Upstream has at least one internal inconsistency: contrast.d.ts
    declares MODE_SURFACE.dark as #242424 while contrast.mjs defines it as
    #000000. Any FURN work that depends on a specific upstream constant should
    verify it against the implementation, not the declaration.
  • Risk. The x3 algorithm assumes a single continuous color space and no
    forced-colors mode. It has no high-contrast theme (see
    System Appearance Handling), so derived values must
    not be applied on top of high-contrast platform colors without a decision.

Evidence and references

Local

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

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