Raxol.UI.Harness.Prominence (Raxol v2.6.1)

View Source

Maps a continuous prominence (0.0..1.0) attribute onto a resolved hex color through the Raxol.UI.Theming.Salience H-K solver.

Ground-aware fade

Fading a foreground color toward a hardcoded reference ground (rather than the terminal's actual background) gets the direction wrong on a light theme: fading toward a dark reference moves a dark foreground away from a light background, so contrast rises as prominence drops instead of falling. resolve/3 (and fade/3) take the real ground as an argument, threaded to Salience.solve_lightness/3 the same way Salience.solve/4 already accepts :ground -- the formula faded_apparent = ground + (apparent - ground) * prominence interpolates APPARENT lightness toward the ground point, which is direction-correct on either side (dark ground: fades down toward it; light ground: fades up toward it) as long as ground is the real one, not a hardcoded reference.

Two modes -- pure fade (default) vs floored (opt-in)

This is the load-bearing design decision of this module. The salience gradient is the point: context text is meant to recede as its prominence drops, and "faded but not lost" means recoverable when a caller later raises the block back to prominence 1.0 (e.g. on focus), NOT readable-at-a-glance. A universal legibility floor would fight that gradient -- flattening the very tier separation the gradient exists to create. So:

  • Pure fade (default). resolve(hex, prominence) and resolve(hex, prominence, ground: g) apply only the ground-aware fade. No floor. The output recedes monotonically toward the ground as prominence drops -- the gradient callers depend on, with no tier collapse. Only guaranteed legible on promotion (raising the block back to prominence 1.0 = identity = the seed), not at rest. At prominence: 0.0 with the floor off, the color fades all the way to ground -- effectively invisible against the background. This is an intentional boundary of the pure-fade contract, not a bug.

  • Floored (opt-in, legibility_floor: true). Additionally clamps the output to a WCAG-style contrast floor. Callers engage this only for acting / interactive content -- the tiers where blind-reading risk actually lives -- while context/history content fades free. A floored output is guaranteed legible at rest.

The legibility clamp (opt-in), and why it is opt-in

A prominence scalar floors the INPUT multiplier, not the OUTPUT contrast: |faded_apparent - ground| = |apparent - ground| * prominence, which depends on how much contrast the source color started with. A low-apparent-contrast source (a receding neutral, or any near-ground seed) can drop below any legibility threshold. When legibility_floor: true, resolve/3 clamps the WCAG-style relative-luminance contrast ratio (Salience.relative_luminance/1, Rec. 709 -- an external check the solver's own apparent-lightness model cannot self-certify) against FLOOR_RATIO: if the raw fade falls short, the clamp walks back up the fade line toward higher prominence until the floor is met, via bisection (the fade-to-clamped mapping isn't analytically invertible once gamut shrink is in play). "The fade line" is the one-parameter family fade_color(t) = build_hex(ground + (apparent - ground) * t, c * t, h) for t running from the requested prominence up to 1.0 -- it moves BOTH apparent lightness and chroma together, so its endpoint at t = 1.0 is the true full-chroma prominence:1.0 color (≈ the input hex itself, up to gamut round-trip), NOT a reduced-chroma proxy. That makes the ceiling exact: the clamp never manufactures more contrast than the color's own full-strength self, and the floor-reachability check is run against that true ceiling. When even the full-strength color misses the floor (a genuinely low-contrast seed against this ground -- a palette-design gap the clamp cannot paper over without exceeding its own ceiling), resolve/3 returns that true full-chroma ceiling as documented best effort.

FLOOR_RATIO (3.0, 3.0:1) is a placeholder, pending ratification by a human-eye review pass -- the first playground matrix run picks the highest ratio at which every cell is legible and pins it here permanently.

The needs-input starvation guard (policy floor)

Content that is waiting on the user (an approval prompt, a composer asking for input) must never be starved of visibility below the ordinary context it interrupts, no matter what prominence value a demotion sweep hands it -- a projection bug must not be able to fade the one thing the user has to see. needs_input: true floors the effective prominence at needs_input_floor/0 (0.6, the ordinary-context tier of the shipped salience ladder) before the fade runs. The floor raises only: prominences at or above it pass through byte-identical, and prominence >= 1.0 remains the identity. It composes with (and applies before) the opt-in legibility clamp.

Truecolor-only floor; 256-color deferred

The clamp guarantees the floor on the 24-bit hex it emits -- it is truecolor-only. 256-color survival is out of scope for this module, and callers must not assume the clamp survives quantization: downsampling a floored hex to the xterm-256 cube can drop it back under the floor (and can collapse adjacent fade tiers). The eventual answer is a redistributed per-ground tier ladder for 256-color (fewer, wider-spaced tiers chosen in-palette), not a post-hoc clamp on a quantized value -- deferred here because it needs a quantization-pipeline decision this module does not make. One minimum IS pinned by regression test: the 1.0/0.6 prominence pair must survive Raxol.UI.Theming.Colors.find_closest_256_color/1 as distinct palette indices (the coarsest tier separation the ladder needs on a 256-color terminal).

Scope

This module resolves a single foreground color; it does not decide which prominence a block gets nor whether the floor is engaged for it (that's the caller's policy -- it owns the acting-vs-context call and passes legibility_floor:/needs_input: accordingly), nor whether a sealed block may be restyled at all (that's separate fold/authorization territory).

Summary

Functions

The raw ground-aware fade -- identical to resolve/3's default (pure fade) mode, and exposed as its own name so callers/tests can name the intent explicitly, with no chance of the opt-in floor engaging. A non-numeric ground falls back to the same lazy default as resolve/3.

The placeholder WCAG-style legibility floor ratio (see moduledoc).

The prominence floor applied under needs_input: true -- the ordinary-context tier of the shipped salience ladder, so content awaiting user input never resolves below ordinary context content.

Resolves hex at prominence (0.0..1.0) against a ground.

WCAG-style contrast ratio between two hex colors: sRGB relative luminance (Salience.relative_luminance/1, Rec. 709), (max(Y) + 0.05) / (min(Y) + 0.05). This is the external, standards-based legibility check, distinct from the solver's own internal apparent-lightness model. Raises ArgumentError if either hex string is malformed (see Salience.relative_luminance/1).

Types

opts()

@type opts() :: [
  ground: float(),
  legibility_floor: boolean(),
  floor_ratio: float(),
  needs_input: boolean()
]

Functions

fade(hex, prominence, ground)

@spec fade(String.t(), number(), term()) :: String.t()

The raw ground-aware fade -- identical to resolve/3's default (pure fade) mode, and exposed as its own name so callers/tests can name the intent explicitly, with no chance of the opt-in floor engaging. A non-numeric ground falls back to the same lazy default as resolve/3.

floor_ratio()

@spec floor_ratio() :: float()

The placeholder WCAG-style legibility floor ratio (see moduledoc).

needs_input_floor()

@spec needs_input_floor() :: float()

The prominence floor applied under needs_input: true -- the ordinary-context tier of the shipped salience ladder, so content awaiting user input never resolves below ordinary context content.

resolve(hex, prominence, opts \\ [])

@spec resolve(String.t(), number(), opts()) :: String.t()

Resolves hex at prominence (0.0..1.0) against a ground.

Default is the pure ground-aware fade (the salience gradient) -- no legibility floor. Pass legibility_floor: true to additionally clamp the output to a WCAG contrast floor; callers engage this only for acting / interactive tiers. See the moduledoc's "Two modes" section.

Options

  • :ground - ground (background) OKLCH lightness. Default: the OSC-11-detected terminal background (Raxol.UI.Theming.SalienceTheme.detect_ground/0), falling back to Salience.reference_ground/0 when detection is unavailable. Pass explicitly to test against a specific ground without touching :persistent_term. A non-numeric :ground (a color string, an atom, ...) can't feed the fade math -- it is treated as absent and falls back to the same lazy default instead of reaching the fade math and raising.
  • :legibility_floor - true engages the opt-in legibility clamp (default false = pure fade).
  • :floor_ratio - override the legibility floor ratio (default 3.0); only consulted when legibility_floor: true.
  • :needs_input - true floors the effective prominence at needs_input_floor/0 (0.6) before the fade -- the starvation guard for content awaiting user input (see moduledoc). Default false. Raises the effective prominence only; inert at or above the floor.

prominence >= 1.0 is the identity (byte-identical hex back, no ground lookup, no clamp, regardless of :legibility_floor) -- a neutrality guarantee: a caller that never sets prominence below 1.0 sees zero change. prominence < 0.0 is clamped to 0.0 -- a negative prominence would extrapolate past the ground rather than fade toward it, which is gamut-undefined.

wcag_ratio(hex_a, hex_b)

@spec wcag_ratio(String.t(), String.t()) :: float()

WCAG-style contrast ratio between two hex colors: sRGB relative luminance (Salience.relative_luminance/1, Rec. 709), (max(Y) + 0.05) / (min(Y) + 0.05). This is the external, standards-based legibility check, distinct from the solver's own internal apparent-lightness model. Raises ArgumentError if either hex string is malformed (see Salience.relative_luminance/1).