feat(ui): dim the content behind a dialog

The panel has no alpha and there is no framebuffer to blend against -- 307KB against
276KB free -- so a scrim cannot be composited. It can be computed instead: the palette
is re-derived from seeds mixed toward black, and the content behind a dialog is
repainted in those colors, which is the result a black scrim would have produced. The
tree was already the source of truth, so dimming is a palette swap rather than a
readback. A node marked dimmed carries it to everything it contains, and ui.confirm
opts its card back out.

Doing that exposed a latent bug in bordered boxes. They filled a square rect and then
drew a rounded border over it, so the corners the border does not cover showed the
fill. That was invisible while everything behind a card was the same color as the card,
and obvious the moment the background dimmed. Separating the two roles fixes it: bg is
what a node fills, surface is what sits underneath, anti-aliased edges blend into
surface, and a box that paints its own rounded background suppresses the square fill.
This commit is contained in:
2026-08-01 18:54:19 -04:00
parent f7c86ded80
commit e530331613
4 changed files with 82 additions and 16 deletions
+20
View File
@@ -107,6 +107,26 @@ assert(card.rect.w == 272, "card width " .. card.rect.w) -- 0.85 of 320
assert(card.rect.x == 24, "card x " .. card.rect.x) -- centered horizontally
assert(card.rect.y == math.floor((480 - card.rect.h) / 2), "card y " .. card.rect.y)
-- Dimming repaints what is behind in a darkened palette, and the dialog opts out, so a
-- card sits lit over dimmed content without any pixel ever being blended.
local litText = ui.text("behind")
ui.screen(ui.box{litText})
local dimText = ui.text("behind")
local lit = ui.text("in the dialog")
ui.screen(ui.box{dimmed = true, ui.box{dimText}, ui.box{dimmed = false, lit}})
-- A black scrim leaves black text black; the surface under it is what darkens.
assert(dimText.bg ~= litText.bg, "the dimmed surface kept its background")
assert(lit.bg == litText.bg, "the dialog was dimmed along with the content behind it")
assert(ui.theme.dim.bg ~= ui.theme.bg, "the dim palette matches the lit one")
assert(ui.theme.dim.muted ~= ui.theme.muted, "dimming did not reach derived roles")
-- A bordered card blends its rounded edge into what is behind it, not into its own
-- fill, or the corners it does not cover show the wrong color.
local bordered = ui.box{border = 1, bg = 2, w = 40, h = 20}
ui.screen(ui.box{dimmed = true, bordered})
assert(bordered.surface == ui.theme.dim.bg, "card blends against its own fill, not the surface")
assert(bordered.paintsBackground, "a bordered box must suppress the square fill")
-- A tap on the button that the dialog covers must not reach it.
dialogScreen:down(60, 24)
dialogScreen:up(60, 24)