Raxol.UI.ColorResolver (Raxol v2.6.1)

View Source

The single whole-list resolution pass that turns Raxol.UI.ColorIntent structs (and the {:fixed, color} wrapper) surviving in a cell list's fg/bg slots into concrete literal colors -- hex strings, {r, g, b} tuples, ANSI atoms, or 256-palette integers -- exactly once, as close to the terminal writer as this codebase gets.

See docs/core/RENDERING.md's "Region prominence" section for the model this implements.

Test-ID legend

Comments and tests below cite short IDs (RP-N-02, RP-P-01, F2, A6, ...) rather than re-deriving each contract inline: RP- is the region- prominence spec docs/core/RENDERING.md numbers negative (N, a falsifier -- something that must NOT happen) and positive (P, a guarantee) assertions under; bare letter+digit IDs (F2, A6) are that same document's numbered functional/architectural requirements. Each ID maps to one test/raxol/ui/*.exs describe/test name carrying it, which is the authoritative expansion of what the ID claims.

Raxol.UI.Layout.Engine's stamp_region_prominence/2 stamps a per-element region_prominence float -- computed by Raxol.UI.RegionPolicy.region_prominence/4 from the region paths present this frame, the focused path, and any mounted dimming overlays -- into a transient {:region_prominence, p} marker in each cell's attrs list (see Raxol.UI.Renderer's per-element render step). This module reads that marker, composes it into effective_p (own_p * region_p), and fades both fg and bg, literal or intent-resolved, toward the frame's terminal ground -- mirroring Raxol.UI.CellDim.dim_fg/2 / dim_bg/2 exactly (including the :black fg-vs-bg sentinel asymmetry documented there) but parametrized by the composed p and a module chroma exponent @region_gamma instead of CellDim's fixed (contrast_keep 0.45, chroma_keep 0.65) pair. @region_gamma is the closed-form solve ln(0.65) / ln(0.45) (~0.5395), chosen to reproduce CellDim's chroma_keep to floating-point precision at the one p (Raxol.UI.Layout.Engine's @overlay_keep, 0.45) the modal dim composes. See the @region_gamma module attribute comment for the full derivation and test/raxol/ui/region_prominence_test.exs for the golden (RP-N-02: 0 of 964 painted cells differ on the modal_demo fixture).

The {:region_prominence, p} marker is a resolver-internal implementation detail, never a real cell attribute: resolve_cells/2 always strips it before a cell leaves this pass (see resolve_cell/3), so no downstream consumer (buffer diff, terminal writer, MCP structured screenshot, ...) ever sees it. p == 1.0 (the default, and every cell's value absent a mounted dialog or other de-prominent region) short-circuits every dimming clause to identity, so the pass is neutral (RP-P-01) whenever no region drops below full prominence.

Local-ground bookkeeping

Cell lists arrive flattened -- tree containment is already erased by the time render_to_cells/2 concatenates every element's cells, and overlapping absolute layers make "enclosing element" ambiguous. So the ground a bg intent resolves against, and the ground a fg intent (or the legibility floor) resolves against, cannot come from containment -- it comes from paint order: a sparse %{{x, y} => resolved_bg} grid, folded once over the cell list exactly as Raxol.UI.CellManager.put_cell/2 and Backends.inherit_background/2 already fold overlapping cells when merging into the buffer. A cell reads the grid (its own under-layer, or the terminal ground if nothing painted there yet) before resolving, and writes its own resolved bg into the grid after -- but only if that bg is non-nil (opaque). A nil (transparent) bg never writes, so a stack of transparent cells all fall through to the deepest painted bg, identical to how the buffer itself inherits background today.

What actually resolves

  • nil -- the unpainted/transparent sentinel, passes through unchanged (and never writes the grid).
  • {:fixed, color} -- unwraps to color, exempt from everything (see Raxol.UI.ColorIntent moduledoc).
  • %Raxol.UI.ColorIntent{} in a bg slot -- resolved via Raxol.UI.Theming.Salience.solve/4 against the enclosing ground (the grid's under-layer at this coordinate, else the terminal ground), then region-dimmed (below) -- the RESULT becomes the local ground for this cell's fg and for every later cell painted at the same coordinate. bg intent resolution itself has no prominence term -- it always resolves at the intent's own tier's full-strength target (tier defaults to :baseline when unset); the region term is layered on after, identically to a literal bg (below).
  • %Raxol.UI.ColorIntent{} in a fg slot -- resolved at effective_p = clamp(intent.prominence || 1.0) * clamp(region_p) (own_p composed with the per-cell region prominence) against the local ground (this cell's own region-dimmed bg, else the grid's under-layer, else terminal ground), then clamped to the intent's :floor class against the local resolved bg's actual color (not a synthesized gray of its lightness) via bisection along the same fade line. The floor clamp runs after the full effective_p composition, so a region dim can never push an already-floored color back under its floor.
  • Any other term (atom, hex string, {r, g, b}, integer, ...) -- a literal. Passes through completely unchanged (RP-P-01, the byte-identity contract) when this cell's region prominence is 1.0; region-dimmed otherwise, exactly like Raxol.UI.CellDim dims literals for the modal case -- literals participate in region dimming, not just intents.

Region prominence

Each cell's attrs list may carry a transient {:region_prominence, p} marker (p :: float() in 0.0..1.0), stamped per-element by Raxol.UI.Renderer from Raxol.UI.Layout.Engine's stamp_region_prominence/2 output before cells reach this module. region_prominence_of/1 reads it (default 1.0 when absent); the marker is always stripped from the emitted cell's attrs before it leaves resolve_cell/3 -- it is never a real terminal/buffer attribute like :bold or :dim, only a resolver-internal composition input.

At p == 1.0 every dimming clause below is the identity (an explicit guard, not just an arithmetic no-op) -- byte-identical output, matching the neutrality contract that holds whenever no region drops below full prominence. At p < 1.0:

  • fg -- an intent's effective_p folds in p before the existing fade+clamp pipeline runs (so the floor clamp sees the fully composed prominence -- no upstream stage can push an already-floored class back under the floor). A literal fg fades via region_dim_fg/3 -- the fg-flavored half of the CellDim mirror: a painted fg: :black is dimmed as a real color (CellDim.dim_fg/2's documented asymmetry).
  • bg -- both an intent-resolved bg and a literal bg pass through region_dim_bg/3 -- nil (the unpainted sentinel) and the literal atom :black (the unpainted-bg sentinel, see CellDim moduledoc) both pass through completely untouched, matching CellDim.dim_bg/2 exactly.
  • Both dimming paths fade apparent lightness toward the terminal ground (ground + (apparent - ground) * p) and chroma by `p @region_gamma** -- the same two-channel OKLCH interpolationCellDim.dim_oklch/4performs, parametrized instead of hardcoded.@region_gammais chosen so the shipped modal look survives through the unified formula exactly (see moduledoc intro and the@region_gamma` attribute comment).

Reuse, not reimplementation

The fade/clamp math is not duplicated here. Raxol.UI.Theming.Salience's solve/4, hex_to_oklch/1, and apparent_lightness/3 do all OKLCH/H-K work; Raxol.UI.Harness.Prominence's fade/3 and wcag_ratio/2 do the ground-aware fade and the external WCAG contrast check. The only new code here is the bisection driver -- adapted from Prominence.clamp_to_floor/7 but walking the fade line against the LOCAL bg's real hex rather than a gray reconstruction of the ground's lightness.

The writer guard (RP-N-03)

resolve_cells/2 always emits literal fg/bg for every cell -- every clause below terminates in a literal. enforce_resolved!/1 is the defense-in-depth postcondition check run as the pass's last step: if any cell still carries a %ColorIntent{} or {:fixed, _} (a producer bug, or a code path that skipped render_to_cells/2 entirely), it raises in dev/test and maps the offending slot to :default plus a [:raxol, :ui, :color_resolver, :unresolved_intent] telemetry event in prod -- fail visible-but-safe, never a crashed render in production.

Summary

Functions

The RP-N-03 writer guard: any cell whose fg/bg is still a %ColorIntent{} or {:fixed, _} after resolution raises (dev/test) or is mapped to :default with a telemetry event (prod). Called automatically as the last step of resolve_cells/2; exposed publicly so it can be exercised directly (e.g. injecting an unresolved intent to prove the guard fires -- RP-N-03's falsifier).

The chroma exponent region-prominence dimming uses -- see the @region_gamma module attribute comment for the RP-N-02 rationale. Exposed so tests/callers can compute the exact expected region-dimmed chroma for a given p without duplicating the constant.

Resolves every ColorIntent/{:fixed, _} in cells' fg/bg slots to literal colors, folding a paint-order local-ground grid across the whole list. Literal-only input passes through unchanged.

Types

cell()

@type cell() :: {integer(), integer(), term(), term(), term(), list()}

Functions

enforce_resolved!(cells)

@spec enforce_resolved!([cell()]) :: [cell()]

The RP-N-03 writer guard: any cell whose fg/bg is still a %ColorIntent{} or {:fixed, _} after resolution raises (dev/test) or is mapped to :default with a telemetry event (prod). Called automatically as the last step of resolve_cells/2; exposed publicly so it can be exercised directly (e.g. injecting an unresolved intent to prove the guard fires -- RP-N-03's falsifier).

region_gamma()

@spec region_gamma() :: float()

The chroma exponent region-prominence dimming uses -- see the @region_gamma module attribute comment for the RP-N-02 rationale. Exposed so tests/callers can compute the exact expected region-dimmed chroma for a given p without duplicating the constant.

resolve_cells(cells, opts \\ [])

@spec resolve_cells(
  [cell()],
  keyword()
) :: [cell()]

Resolves every ColorIntent/{:fixed, _} in cells' fg/bg slots to literal colors, folding a paint-order local-ground grid across the whole list. Literal-only input passes through unchanged.

Options

  • :ground - ground (background) OKLCH lightness. Default: Raxol.UI.Theming.SalienceTheme.detect_ground/0 -- never a hardcoded constant; the ground is always live-detected, never assumed. A caller that needs a fixed ground for a test passes it explicitly here rather than this module defaulting to one.
  • :color_depth - the capability-tier downgrade rung (:truecolor | :ansi256 | :ansi16 | :none). Default: a guarded lazy read of Raxol.Terminal.Capabilities.color_depth/0 that falls back to :truecolor (NOT that function's own :ansi16 no-record default) whenever no capability record has been cached for this session -- see default_color_depth/0 for the full rationale. A caller that needs to force a rung (tests; an app that already knows its terminal) passes it explicitly here.