Repository navigation
Theming: Runtime Color Utilities #4268
Description
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 itscolor,color.hover, andcolor.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
InteractiveColorOverrideshas 59 members
(flex.types.ts),
and x3'smappings.interactionhas 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:getInteractionColorsin
colorStyles.ts
resolvescolors.hoverorcolors.pressedand 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 itssemantic-light.css/
semantic-dark.csssplit 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 andcalc(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.03and0.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 asinteractionCurveMultin
dev/web/flex-themes/scripts/build.mjs. It multiplies the lightness delta by 1
atL >= 0.4and 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_VALUEStable in
the same file: hover is-0.03lightness with+0.04alpha in light and
+0.03with+0.03in dark; pressed is-0.06with+0.08in light and
+0.06with+0.06in dark. - Observed. Direction selection is a single predicate in
build.mjs:
isInverseInteraction = (name) => /heavy|onloud|loud/.test(name). Tokens whose
name containsheavy,loud, oronlouduse the inverse fragments; all
others use standard. - Observed. The OKLCH conversions are implemented in
dev/web/flex-themes/oklch.mjsasrgbToOklchandoklchToRgb, using the
standard sRGB to linear to LMS to Oklab pipeline with cube-root compression.
The module also exportsgenerateBrandRamp,autoStepFromColor,
CHROMA_PRESETS,taperFactor, andBRAND_RAMP_STEPSfor brand ramp
generation, which is a separate concern from interaction states. - Observed. For environments without OKLCH support, x3 ships a precomputed
opt-in fallback atdev/web/flex-themes/css/interaction-fallback.css(130
lines) -- the exact file FURN's
flex.types.ts
andflex-token-map.yaml
already name as the source of truth. It emitslight-dark(<lightHex>, <darkHex>)per token, sourced frommappings.interactionin the
@x3-design/flex-tokenspackage rather than computed at build time. - Observed. The "Bebop Warm" theme uses an extended variant, implemented in
build.mjsaswarmInteractionRgbandwarmInteractionColor: it scales the
lightness delta by an opacity factorclamp(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-pressvariables 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:WCAGthresholds (aaText4.5,
aaLargeText3.0,aaaText7.0,visibleStroke3.0), an alphacomposite()
helper, aPAIRINGStable of foreground and background token pairs with
minimum ratios, andresolvedPairs(theme, mode). - Observed. Packaging precedent:
@x3-design/flex-themes@0.8.0exposes
./oklchand./contrastas 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'sheavy|loud|onloudpredicate produces. - Implement the derivation in a design submodule with its own
exportsentry,
not re-exported from the design root barrel, following the./oklchand
./contrastprecedent upstream. - Support deriving a full
InteractiveColorOverridesmap from a
SemanticColorTokenValuesset, and deriving a single state value from a single
rest value. - Define and implement the behavior for
ColorValueinputs 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
PAIRINGSandresolvedPairs. - 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
SemanticColorsorInteractiveColorOverridesshape. - Selecting light versus dark behavior; that resolution comes from
System Appearance Handling.
Deliverables
- A design submodule implementing OKLCH conversion and the default interaction
algorithm, with its ownexportsentry. - The direction classification for all 59 interactive tokens, expressed so it
stays verifiable against upstream. - Defined handling for non-literal
ColorValueinputs. - An opt-in path that populates
hoverandpressedfor a theme that supplies
only rest values. - A conformance test using the vectors above, plus a broader fixture derived
from x3's 118-entry interaction table. - Evidence that the submodule is excluded from a bundle that does not import
it. - A recorded note of the upstream commit the port was taken from, so drift can
be re-checked. - 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.interactiontable from the correspondingsemantic-*.cssbase
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
exportsentry 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
ColorValueinputs 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, andyarn lage lintpass at the
repository root, and changesets are present.
Dependencies and ordering
- Depends on Package Consolidation for the
optional-submodule pattern andexportsconventions. - 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 thatuseThemeStaterelies 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-tokensrather than computed during the CSS build. - Risk.
ColorValuecan 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-themesREADME 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
declaresMODE_SURFACE.darkas#242424whilecontrast.mjsdefines 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
packages/agentic/design/src/tokens/flex.types.ts:InteractiveColorOverrides(59 members) andSemanticColors, with the x3 interaction-fallback source path recorded in comments.packages/agentic/design/src/styling/colorStyles.ts: state override resolution and rest fallback.packages/agentic/design/src/tokens/defaultTokens.ts: hand-authored hover and pressed maps.packages/agentic/design/src/tokens/mappings/flex-token-map.yaml: interaction fallback source path and unmapped-state policy.packages/agentic/design/package.json: submoduleexportspattern and dependency set.
Upstream (x3-design/fluent-design @ d334acf5cbad813f2b7cd554da942b09a7ff8f10)
dev/web/flex-themes/css/semantic.css: the four interaction fragments per mode and the deprecated delta variables.dev/web/flex-themes/css/interaction-fallback.css: precomputedlight-dark()values; the file FURN's mapping comments already cite.dev/web/flex-themes/scripts/build.mjs:interactionCurveMult,INTERACTION_VALUES,isInverseInteraction, and the warm-variant functions.dev/web/flex-themes/oklch.mjs: OKLCH conversions and brand ramp helpers.dev/web/flex-themes/contrast.mjs: WCAG thresholds,composite,PAIRINGS,resolvedPairs.dev/web/flex-themes/scripts/__tests__/interaction-fragments.test.mjs: upstream assertions on the fragment constants and override precedence.dev/web/flex-tokens/mappings.json:interaction(118 entries, 59 base tokens),deltas,interactionFragments.dev/web/flex-themes/README.md: runtime OKLCH resolution, the opt-in fallback, and the tree-shaking claim for theoklchandcontrastsubpaths.
Activity
[-]Runtime Color Utilities[/-][+]Theming: Runtime Color Utilities[/+]