Skip to content

An unpainted background emits no background, so transparency survives#520

Merged
DROOdotFOO merged 3 commits into
DROOdotFOO:masterfrom
merklebonsai:pr/unpainted-background-transparency
Jul 13, 2026
Merged

An unpainted background emits no background, so transparency survives#520
DROOdotFOO merged 3 commits into
DROOdotFOO:masterfrom
merklebonsai:pr/unpainted-background-transparency

Conversation

@merklebonsai

Copy link
Copy Markdown
Collaborator

Reported from iTerm with a transparent background: some elements are opaque and some are see-through, in the same UI.

Why

There is no alpha in the terminal protocol. A cell is transparent precisely when the application never set a background for it. Emitting any background — including one that matches the terminal's own — makes that cell opaque.

Raxol defaulted an unpainted background to the atom :black:

border_renderer.ex:318   Map.get(style, :background, :black)
element_renderer.ex:336  Map.get(style, :bg) || Map.get(style, :background, :black)

and the renderer maps :black@bg_color_codes[:black]\e[40m. So every "unpainted" cell was painted with an opaque black.

On an opaque black terminal that is invisible — black on black — which is exactly why it shipped. On a transparent terminal, each of those cells punches an opaque rectangle through the window, while cells whose background is genuinely nil stay see-through. Hence: one UI, two opacities.

Fix

Default an unpainted background to nil. The renderer already emits no SGR for a nil background, and every run is \e[0m-terminated (renderer.ex:199), so nothing bleeds into it.

This also un-overloads the sentinel. :black was forced to mean both "unpainted" and "actually black" — indistinguishable by the time it reached the renderer (CellDim's moduledoc says so explicitly: ":black is the de facto unpainted-bg sentinel"). Now nil means unpainted and :black is free to be a real, dimmable color.

CellDim.dim_bg(nil, _) passes through: there is nothing to dim, and dimming an unpainted background would paint it — silently re-introducing the bug on every dimmed cell (i.e. the whole modal backdrop).

Tests

New unpainted_background_test.exs locks the actual invariant end-to-end: a fg-only style resolves its bg to nil; a painted bg is preserved; a nil bg emits no 48;/\e[40m SGR; a painted bg does; dim_bg passes nil through but still dims a real color.

One existing test asserted an unpainted background rendered as :black — it was pinning the bug, and is updated.

Sweep (ui + cross_terminal + playground + renderer): 1798/1798.

@DROOdotFOO

Copy link
Copy Markdown
Owner

CI infra unblocked: your Setup & Cache failure wasn't your code -- it was upstream mix.lock drift (phoenix_live_view 1.2.6 -> 1.2.7, security advisory on 1.2.6) hitting the strict mix deps.get --check-locked. Master already fixed this class in #522 (adds a || mix deps.get fallback + refreshes the lock). I merged master into this branch, so Setup & Cache is green now.

The only failures left are golden/snapshot mismatches, which are the intended result of this PR -- it changes how backgrounds are emitted, so the committed render references no longer match:

  • Raxol.FATEGoldenTest -- box_1x1, box_border, ...
  • Raxol.Examples.ButtonTest -- visual + normal/disabled snapshots

To clear them, regenerate the references, eyeball the diff to confirm the new output is what you intend, then commit:

mix raxol.fate --gen        # FATE corpus
# then update the ButtonTest snapshots and commit both

Unrelated aside: the run logs a background CaseClauseError in lib/raxol/core/runtime/subscription.ex:304 (watch_file/3 hits an unmatched :ignore). It's a logged async-task crash, not a counted test failure, and looks pre-existing -- flagging in case it's relevant to the subscription work.

There is no alpha in the terminal protocol: a cell is transparent precisely
when the application never set a background for it. Emitting any background --
including one that matches the terminal's -- makes the cell opaque.

Unpainted backgrounds defaulted to the atom `:black`, which the renderer maps
to `\e[40m`: an opaque black cell. On an opaque black terminal that is
invisible, so it shipped. On a transparent terminal each of those cells punches
an opaque rectangle through the window, while cells whose background is
genuinely nil stay see-through -- hence the same UI rendering at two different
opacities.

Default an unpainted background to nil instead. The renderer already emits no
SGR for a nil background, and every run is `\e[0m`-terminated, so nothing bleeds
into it.

This also un-overloads the sentinel. `:black` had to mean both "unpainted" and
"actually black", indistinguishable by the time it reached the renderer; nil now
means unpainted and `:black` is free to be a real, dimmable color. CellDim.dim_bg
passes nil through: there is nothing to dim, and dimming it would paint it.

The one test that asserted an unpainted background rendered as `:black` was
pinning the bug.
Two more places turned an unpainted background into a painted one, so cells
stayed opaque even after defaulting the background to nil:

  ThemeResolver.resolve_bg_color/3 fell back to the theme's global background
  (and then to :black), so every element was handed an explicit background.

  Renderer.build_ansi_prefix/2 fell back to the theme's default background when
  a cell's background was nil, painting the very cells that had asked for none.

Both are the same mistake: the theme's "background" is an assumption about the
terminal's own, and painting it is a no-op on an opaque terminal and an opaque
rectangle on a transparent one.

This is also what CellDim already assumes. It detects the real ground with
OSC 11 and *solves* colours against it -- the terminal owns the background and
we adapt to it. Painting our assumed ground over the real one contradicts that.

So: themes colour content (foreground, accents, borders); the terminal owns the
canvas. An app that genuinely wants a different canvas still sets a background
explicitly, and that still paints -- foreground keeps its theme fallback, since
text does need a colour.

The theme tests asserted that an unpainted box inherits and paints the theme's
background. That was the behaviour being removed, so they now assert nil; the
test that overrides a background explicitly still expects it painted.
The FATE corpus hashes the cell grid, and a cell carries its background, so
every fixture that never asked for one moves from `bg: :white` to `bg: nil`.
The button snapshots move the same way at the ANSI level: the `\e[40m` they
used to carry is simply gone.

Both sets of references were recorded against the behaviour this branch
removes -- they encode "an element with no background gets one anyway". The
render diff is exactly that and nothing else (verified cell-by-cell against
master: only the bg token changes; glyphs, positions, foregrounds and attrs
are byte-identical).
@merklebonsai
merklebonsai force-pushed the pr/unpainted-background-transparency branch from c258077 to 89b28c2 Compare July 13, 2026 20:19
@merklebonsai

Copy link
Copy Markdown
Collaborator Author

Rebased onto current master, which brought the FATE golden corpus (#441) into range.

Every reference hash in priv/fate/golden.refs moves, and so do the three button
snapshots — because a cell's background is part of what gets hashed, and this branch
is precisely the change that stops inventing one. Regenerated in a separate commit.

The render diff is only that. Verified cell-by-cell against master:

- 0,0 "H" fg=:black bg=:white []      - 0,0 "┌" fg=:black bg=:white [:single]
+ 0,0 "H" fg=:black bg=nil   []       + 0,0 "┌" fg=:black bg=nil   [:single]

Glyphs, positions, foregrounds and attributes are byte-identical; the only token that
changes is bg, and only where nobody asked for one. The button snapshots lose their
\e[40m and nothing else.

Those references were recorded against the behaviour this branch removes — they were
encoding "an element with no background gets one anyway".

@DROOdotFOO
DROOdotFOO merged commit 77f3167 into DROOdotFOO:master Jul 13, 2026
27 checks passed
DROOdotFOO pushed a commit that referenced this pull request Jul 15, 2026
ADR-0029 rule 2: an unpainted background emits no background. This
clause defaulted bg to :black on its own, unlike every other element
in the paint path -- element_renderer and border_renderer both went
through this fix already (#520), the divider was missed.

Visible on a modal: the surface fills its interior with the theme's
:surface colour, but the divider punched a black cell through it
because :black overrides the fill instead of showing it.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants