Raxol. UI. Theming. Ansi16Salience
(Raxol v2.6.1)
View Source
Polarity-preserving 16-color (ANSI16) degradation table for semantic salience roles.
Why this module exists
On a 16-color terminal, naive nearest-RGB quantization of the solved
salience palette (Raxol.UI.Theming.Colors.find_closest_basic_color/1)
collapses most semantic fields onto the gray ramp: measured on this
codebase, 8 of the 11 harness-painted fields (success, accent, emphasis,
diff add/del, chrome, ...) gray out on the dark reference ground, and some
survivors lie about their hue -- on a light ground, solved emphasis
nearest-RGB-quantizes to ANSI 1 (red) and foreground to ANSI 6 (cyan),
neither a neutral/warm slot. Averaging RGB channels down to 16 flat
swatches erases the hue information that carries semantic meaning.
This module replaces that lossy path for semantic colors. Instead of
measuring distance in RGB space, each semantic role is PINNED to a
hue-preserving ANSI slot chosen so the role's category (red/green/yellow/
blue/magenta/neutral) is never lost and never confused with another
role's category. (:running is reserved for activity state -- no RGB
seed exists in the harness yet, which is why measured naive-collapse
counts say "11 fields" while roles/0 returns 12.)
Legibility floor
Every slot assignment meets a WCAG-style 3:1 contrast ratio against its
polarity's canonical ground, measured on the module's reference palette
(Raxol.UI.Theming.Colors.ansi_to_rgb/1), with one small, named
exemption set on light polarity only:
- Green/yellow state roles on light ground: no green or yellow slot in the reference palette is legible on light ground (best available: normal green 1.98:1, normal yellow 1.56:1). The best-effort normal slots (2 and 3) are pinned anyway, because swapping hue family would be a category lie worse than low contrast for a state signal, and gray would erase the signal entirely. Real light-mode terminal themes darken these slots; the pin keeps the category-true handle for them to interpret.
- Muted/border recede chrome on light ground: silver is a subtle light-UI border by design; receding below the floor is these roles' function.
Dark polarity has NO exemptions: every dark-canvas assignment meets the floor outright. The ranked priority throughout the table is explicit: legibility > category preservation > tier separation.
Polarity-awareness
ANSI16 offers a normal-intensity slot (1-7) and a bright-intensity slot
(9-15) per hue. Which one reads as "the loud one" depends on the canvas:
bright variants pop on a dark background, normal variants pop on a light
background. polarity/1 derives the canvas from ground OKLCH lightness
using the same threshold as Raxol.UI.Theming.Salience's :auto
polarity resolution (ground < 0.5 is dark). Because a 16-color path is
a fallback, polarity/1 must not crash when the OSC 11 background probe
has no reading: nil falls back to the reference ground's polarity;
other non-numbers still raise.
Tier degradation
The shipped prominence ladder has four tiers (1.0 / 0.8 / 0.6 / 0.4),
but 16-color can express at most two distinguishable steps per hue. The
ladder folds pairwise at loud_threshold/0 (0.8): prominence >= 0.8
resolves to the loud tier, everything below resolves to the soft tier.
Which roles keep distinct 1.0 vs 0.6 slots is per polarity. On dark, all
roles stay distinct except :muted and :border, which already sit at
the dimmest legible slot (the recede floor -- intentional fold). On
light, the reference palette leaves single legible slots for several
families, so the fold list grows: :muted, :border, :warning,
:success, :diff_add, :running, :foreground, and :chrome all
trade tier separation for the legibility floor.
Accepted losses
This is a lossy degradation and it accepts specific losses in exchange for never lying about hue and never dropping below legibility where the palette allows:
- Sub-hue detail is dropped -- an orange warning and a yellow warning both land on the yellow slot.
:diff_addfolds onto:success's green family and:diff_delfolds onto:error's red family; these are in-family folds, not category lies.:accentrenders on the cyan slots on a dark canvas: the palette's normal blue (slot 4, #0000EE) is illegible on black -- the classic blue-on-black problem -- and the bright blue slot alone cannot express two tiers. Cyan is the adjacent cool slot and no other role occupies it; the role's category stays:blue.:emphasistrades its warm (yellow-adjacent) seed hue for the max-contrast neutral slot at its loudest tier, so it can never be mistaken for the:warningstate signal -- the yellow slot is reserved for warning.:emphasiskeeps its anchor function (it is still the loudest role in the ramp) without competing for a hue.- The neutral ramp merges. On dark at the soft tier,
:foreground,:chrome,:muted, and:borderall share slot 8 -- receded body text merges into recede chrome, because slot 8 is the only legible sub-body neutral on dark. On light the whole mid-ramp compresses onto slot 8: the palette has exactly two legible neutrals on light ground (black and dark gray), so foreground and chrome fold onto dark gray at both tiers. :runningis reserved for activity/in-progress state and given its own hue family (magenta), separate from the alarm/success/warning triad.
Scope
This table is the only supported 16-color path for semantic role colors.
Raxol.UI.Theming.Colors.find_closest_basic_color/1 (nearest-RGB) must
not be used for semantic roles -- see its @doc for the pointer back
here. Truecolor and 256-color rendering are unaffected; this module is
additive. Note the table is not yet consumed by the render path (wiring
the capability gate is a follow-up); until then
find_closest_basic_color/1 remains the live -- lossy -- 16-color
fallback.
Unknown roles
roles/0 is the closed, compile-time set of roles this table knows
about. slot/2, slot/3, and category/1 intentionally have no
fallback clause for atoms outside that set -- they raise
FunctionClauseError rather than silently guessing a slot.
Summary
Functions
The hue family a role belongs to.
Prominence at or above this value resolves to the loud tier; below it resolves to the soft tier.
Derives canvas polarity from ground OKLCH lightness.
The semantic salience roles this table covers.
Same as slot/3 at full (1.0) prominence.
Resolves a semantic role to its ANSI16 slot for the given canvas polarity and prominence.
The full role -> slot map for a given polarity and prominence.
Types
@type category() :: :red | :green | :yellow | :blue | :magenta | :neutral
@type polarity() :: :dark | :light
@type role() ::
:foreground
| :accent
| :error
| :warning
| :success
| :emphasis
| :muted
| :border
| :chrome
| :diff_add
| :diff_del
| :running
@type slot() :: 0..15
@type tier() :: :loud | :soft
Functions
The hue family a role belongs to.
@spec loud_threshold() :: float()
Prominence at or above this value resolves to the loud tier; below it resolves to the soft tier.
Derives canvas polarity from ground OKLCH lightness.
Mirrors Raxol.UI.Theming.Salience's :auto polarity threshold: a
ground lightness below 0.5 is a dark canvas, 0.5 and above is light.
A 16-color path is a fallback and must not crash when ground detection
(the OSC 11 background probe) has no reading: nil falls back to the
reference ground's polarity. Other non-numbers raise
FunctionClauseError -- fail-loud on garbage, graceful on the
documented unknown.
@spec roles() :: [role()]
The semantic salience roles this table covers.
Same as slot/3 at full (1.0) prominence.
Resolves a semantic role to its ANSI16 slot for the given canvas polarity and prominence.
The full role -> slot map for a given polarity and prominence.