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
- A design submodule implementing OKLCH conversion and the default interaction
algorithm, with its own exports entry.
- The direction classification for all 59 interactive tokens, expressed so it
stays verifiable against upstream.
- Defined handling for non-literal
ColorValue inputs.
- An opt-in path that populates
hover and pressed for 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
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)
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: precomputed light-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 the oklch and contrast subpaths.
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
packages/for color derivation helpers (lighten, darken, HSL conversion,RGBA construction, color utility modules) returned no implementation.
defaultTokens.tsrestates the full token list in its
color,color.hover, andcolor.pressedmaps, producing 249 color literals with only 80 distinct values; many hover and
pressed entries repeat the rest value verbatim.
InteractiveColorOverrideshas 59 members(
flex.types.ts),and x3's
mappings.interactionhas 118 entries covering 59 base tokens; thetwo name sets are identical after kebab-case to camel-case conversion, with no
member on either side unmatched.
back to the rest value:
getInteractionColorsincolorStyles.tsresolves
colors.hoverorcolors.pressedand usesinteractionColors?.[colorKey] ?? colors[colorKey]. Inferred: a derivationlibrary 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.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 asoklch(from <base> <fragment>).calc(l - 0.03 * clamp(1,(.5 - l)/.1,3)) c h / calc(alpha + 0.04)for standardhover 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.03and0.06.c) and hue (h) are passed through unchanged in thedefault theme. Only lightness and alpha move.
clamp((0.50 - L) / 0.10, 1, 3),defined once as
interactionCurveMultindev/web/flex-themes/scripts/build.mjs. It multiplies the lightness delta by 1at
L >= 0.4and rises to a hard cap of 3 as lightness approaches 0, so darkcolors move further than light ones.
INTERACTION_VALUEStable inthe 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.build.mjs:isInverseInteraction = (name) => /heavy|onloud|loud/.test(name). Tokens whosename contains
heavy,loud, oronlouduse the inverse fragments; allothers use standard.
dev/web/flex-themes/oklch.mjsasrgbToOklchandoklchToRgb, using thestandard sRGB to linear to LMS to Oklab pipeline with cube-root compression.
The module also exports
generateBrandRamp,autoStepFromColor,CHROMA_PRESETS,taperFactor, andBRAND_RAMP_STEPSfor brand rampgeneration, which is a separate concern from interaction states.
opt-in fallback at
dev/web/flex-themes/css/interaction-fallback.css(130lines) -- the exact file FURN's
flex.types.tsand
flex-token-map.yamlalready name as the source of truth. It emits
light-dark(<lightHex>, <darkHex>)per token, sourced frommappings.interactionin the@x3-design/flex-tokenspackage rather than computed at build time.build.mjsaswarmInteractionRgbandwarmInteractionColor: it scales thelightness delta by an opacity factor
clamp(alpha + 0.35, 0.35, 1), adds asmall 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.
--lightness-hover,--lightness-press,--alpha-hover, and--alpha-pressvariables are retained in the CSS asdeprecated compatibility output, are no longer consumed by the fragments, and
are documented for removal in 1.0.0.
dev/web/flex-themes/contrast.mjs:WCAGthresholds (aaText4.5,aaLargeText3.0,aaaText7.0,visibleStroke3.0), an alphacomposite()helper, a
PAIRINGStable of foreground and background token pairs withminimum ratios, and
resolvedPairs(theme, mode).@x3-design/flex-themes@0.8.0exposes./oklchand./contrastas separate export subpaths, and its README statesboth "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 increasedfor standard tokens and left alone for inverse tokens -- on top of x3's own
rgbToOklchandoklchToRgbreproduces the published fallback hexes exactly forevery case checked, including alpha and translucent tokens:
surface-neutral-farther#ebebeb#e1e1e1#d7d7d7background-neutral-heavy#242424#363636#494949background-brand-loud#6f6f6f#787878#818181background-danger-loud#c02e56#cb395e#d54467background-neutral-subtle#0000000d#00000017#00000021foreground-neutral-primary#000000db#000000e5#000000efsurface-neutral-farther#1b1b1b#2f2f2fbackground-neutral-heavy#ffffff#f5f5f5background-neutral-subtle#ffffff14#ffffff1csurface-neutral-translucent#3c3c3c80#47474788Base values are from x3's
semantic-light.cssandsemantic-dark.css; expectedhover and pressed values are from
interaction-fallback-light.cssandinteraction-fallback-dark.css, both atd334acf. Inferred: these areusable directly as the port's test vectors, and the full 118-entry
mappings.interactiontable can serve as an exhaustive conformance fixture.Scope
the amplification curve, the per-mode delta table, and the standard versus
inverse direction rule.
the same 59-token classification x3's
heavy|loud|onloudpredicate produces.exportsentry,not re-exported from the design root barrel, following the
./oklchand./contrastprecedent upstream.InteractiveColorOverridesmap from aSemanticColorTokenValuesset, and deriving a single state value from a singlerest value.
ColorValueinputs that are not literalcolors (platform color objects, dynamic colors) rather than silently producing
a wrong value.
gets derived interaction values, without changing components.
contain it.
Out of scope
translucent backdrop compositing). It is a separate theme's algorithm and
should only be ported if a FURN theme adopts that theme.
generateBrandRamp,autoStepFromColor, chromapresets). It is a theme-construction concern, not an interaction concern.
suggested addition for adopting x3's
PAIRINGSandresolvedPairs.a fallback for themes that lack them; generated defaults are owned by
Default Values Codegen.
SemanticColorsorInteractiveColorOverridesshape.System Appearance Handling.
Deliverables
algorithm, with its own
exportsentry.stays verifiable against upstream.
ColorValueinputs.hoverandpressedfor a theme that suppliesonly rest values.
from x3's 118-entry interaction table.
it.
be re-checked.
Acceptance criteria
modes and both states.
mappings.interactiontable from the correspondingsemantic-*.cssbasevalues, or every deviation is listed with a recorded reason.
delta clamped to 1, inverse tokens leave alpha unchanged.
exportsentry in the design package and is notreachable from the design root barrel.
code, demonstrated with a cited repeatable command.
ColorValueinputs follow the defined behavior and surface adiagnostic rather than returning a silently incorrect color.
unchanged; derivation applies only where a value is absent.
yarn build,yarn lage test, andyarn lage lintpass at therepository root, and changesets are present.
Dependencies and ordering
optional-submodule pattern and
exportsconventions.which interaction states have no authored source.
derivation can read the resolved scheme rather than re-deriving it, since the
algorithm's constants and lightness direction are mode-dependent.
system colors without authored interaction states.
Risks and open decisions
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
useThemeStaterelies on.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.
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.ColorValuecan be an opaque platform color (PlatformColor,DynamicColorMacOS). Deriving from it is not possible without resolving itnatively. 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.
@x3-design/flex-themesREADME tells consumers to pin exact versions, and thelegacy delta variables are scheduled for removal in 1.0.0. The port needs a
recorded source commit and a re-check step.
contrast.d.tsdeclares
MODE_SURFACE.darkas#242424whilecontrast.mjsdefines it as#000000. Any FURN work that depends on a specific upstream constant shouldverify it against the implementation, not the declaration.
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.