Files
zero/internal/tui/theme_select_test.go
euxaristia 665bd4b3d0 WIP: feat(tui): TUI theme presets (Dune and Neon) (#634)
* draft(tui): add theme interface and Claude/Codex presets proposal

Introduces the Theme interface alongside the initial specifications and styling models for the Claude (card-based, warm) and Codex (high-density, split-screen cyberpunk) layout presets. Detailed design plans are documented in docs/THEMES.md.

* fix(tui): implement Claude/Codex themes on the existing palette system

theme.go had replaced the tuiTheme/buildTheme/palette architecture every
renderer depends on with a standalone Theme interface using the wrong
lipgloss import, breaking the build and orphaning the existing theme
registry, /theme command, and picker.

Revert theme.go to the working implementation and add claude and codex
as real palette entries in theme_palettes.go, following the file's own
extension pattern. Both pass the WCAG AA contrast and gray-ramp
invariants asserted across the whole registry, and are picked up
automatically by --theme, ZERO_THEME, /theme, and the picker.

Rewrite docs/THEMES.md to describe the actual palette-registry
architecture instead of the removed interface.

* fix(tui): rename Claude/Codex to neutral Dune/Neon, ensure WCAG AA contrast compliance, and add tests

* style(tui): gofmt theme_select_test.go (drop trailing whitespace)

The ubuntu smoke run fails on gofmt -l; this clears the only unformatted
file in the draft so CI goes green.

* test(tui): cover the CLI/env resolution path for the dune and neon presets

TestNewThemePresetsWired only checked lookupTheme and the IsDark
grouping. Add coverage for the actual --theme/ZERO_THEME precedence
path (resolveThemeMode) and confirm applyTheme swaps zeroTheme to each
preset's own palette rather than a built-in fallback.

* fix(tui): raise Dune/Neon palette contrast and fix theme docs

Darken dune's accent, blue, and red tokens so the selected-row caret,
local-model dot, and diff deletion text clear WCAG AA against selBg and
delBg, including after ANSI-256 downsampling. Also correct THEMES.md's
description of bare /theme (it opens a live-preview picker, not a
list), and add the dune/neon names to the ZERO_THEME/--theme tables in
README.md and README_ZH.md.

* fix(tui): keep Dune and Neon readable after ANSI-256 downsampling

- Dune's accent becomes #724028: still AA on selBg and panel in truecolor
  (5.46:1 and 7.03:1), and its xterm-256 quantization (#444444) now clears
  AA on quantized selBg at 6.47:1 where the previous #7c4712 quantized to
  #875f00 at 3.81:1. Blue already survived quantization at 4.67:1.
- Neon's diff bands move to hexes that quantize to distinct green and red
  xterm entries (addBg #083c10 -> #005f00, delBg #3c0810 -> #5f0000,
  addBgWord #147828 -> #008700, delBgWord #74202e -> #870000) instead of
  collapsing to the same grays, while keeping every truecolor invariant.
- New TestExtendedThemeANSI256Contrast quantizes the palette tokens the way
  a 256-color terminal does and asserts the Dune selected-row pairs stay AA
  and the Neon bands keep their green/red identity and mutual separation.
- docs/THEMES.md is now linked from the Documentation sections of both
  READMEs, and the Unreleased changelog inventory says twelve themes and
  names dune and neon.

* fix(tui): keep Neon diff content AA after quantization, fix error border

- faintest becomes #74c468: line numbers now quantize to #87d75f, which
  clears AA on the xterm-green addBg (#005f00) where the old #58af69
  quantized to 2.95:1, while staying dimmer than faint so the gray ramp
  stays monotonic.
- addInk becomes #ecffdc, quantizing to #ffffd7 for 4.60:1 on the word
  band's #008700 (was 4.29:1). Both rendered diff-content pairs are now
  asserted in the ANSI-256 test instead of documented as omissions.
- cardErr becomes #9a4042: 3.01:1 against the panel (2.43:1 before) and
  3.65:1 quantized, meeting the 3:1 non-text component threshold, with the
  border surface asserted in both renderings.
- docs/THEMES.md no longer claims the extended invariants run against the
  whole registry: it now says new palettes must be added to the
  per-palette contrast tests or given equivalent coverage.
2026-07-17 12:38:51 +08:00

489 lines
17 KiB
Go

package tui
import (
"fmt"
"math"
"strconv"
"strings"
"testing"
"charm.land/lipgloss/v2"
)
func relLum(t *testing.T, hex string) float64 {
t.Helper()
h := strings.TrimPrefix(hex, "#")
v, err := strconv.ParseUint(h, 16, 32)
if err != nil || len(h) != 6 {
t.Fatalf("bad hex %q", hex)
}
r := float64((v>>16)&0xff) / 255
g := float64((v>>8)&0xff) / 255
b := float64(v&0xff) / 255
return 0.2126*r + 0.7152*g + 0.0722*b
}
// wcagRatio is the true WCAG 2.x contrast ratio (sRGB-linearized luminance), unlike
// relLum which is a cheap perceptual ordering. Used to assert AA (>=4.5) for the
// text-bearing theme tokens.
func wcagRatio(t *testing.T, fg, bg string) float64 {
t.Helper()
rel := func(hex string) float64 {
h := strings.TrimPrefix(hex, "#")
v, err := strconv.ParseUint(h, 16, 32)
if err != nil || len(h) != 6 {
t.Fatalf("bad hex %q", hex)
}
lin := func(c float64) float64 {
c /= 255
if c <= 0.03928 {
return c / 12.92
}
return math.Pow((c+0.055)/1.055, 2.4)
}
return 0.2126*lin(float64((v>>16)&0xff)) + 0.7152*lin(float64((v>>8)&0xff)) + 0.0722*lin(float64(v&0xff))
}
l1, l2 := rel(fg), rel(bg)
if l1 < l2 {
l1, l2 = l2, l1
}
return (l1 + 0.05) / (l2 + 0.05)
}
// The word-level diff's brighter changed-span band must keep its text AA-readable
// and stay clearly distinct from the base add/del band, on both themes.
func TestDiffWordSpanContrast(t *testing.T) {
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.addInk, pal.addBgWord); r < 4.5 {
t.Errorf("%s: addInk on addBgWord %.2f < 4.5 (AA)", name, r)
}
if r := wcagRatio(t, pal.delInk, pal.delBgWord); r < 4.5 {
t.Errorf("%s: delInk on delBgWord %.2f < 4.5 (AA)", name, r)
}
if sep := wcagRatio(t, pal.addBgWord, pal.addBg); sep < 1.2 {
t.Errorf("%s: addBgWord vs addBg separation %.2f < 1.2 (span not distinct)", name, sep)
}
if sep := wcagRatio(t, pal.delBgWord, pal.delBg); sep < 1.2 {
t.Errorf("%s: delBgWord vs delBg separation %.2f < 1.2 (span not distinct)", name, sep)
}
}
}
// The highlighted picker/autocomplete row must both stand out from the panel
// AND keep its label readable. Guards the regression this fixes: the light
// selBg (#e7f2cd) sat at 1.01 vs the panel (#ececed) — effectively invisible.
func TestSelectedRowBandIsVisibleAndReadable(t *testing.T) {
for _, entry := range themeRegistry {
name, pal := entry.Name, entry.Palette
if r := wcagRatio(t, pal.ink, pal.selBg); r < 4.5 {
t.Errorf("%s: ink on selBg contrast %.2f < 4.5 — selected-row label unreadable", name, r)
}
if sep := wcagRatio(t, pal.selBg, pal.panel); sep < 1.10 {
t.Errorf("%s: selBg vs panel separation %.2f < 1.10 — selected row does not stand out", name, sep)
}
}
}
// Every registered theme — not just the two built-ins — must clear WCAG AA on its
// text-bearing tokens against the panel and keep the muted>faint>faintest>panel
// gray ramp monotonic in its polarity (light-on-dark for dark themes, the inverse
// for light themes). Guards that a newly-added color palette can't ship illegible.
func TestAllThemesContrastAndHierarchy(t *testing.T) {
for _, entry := range themeRegistry {
pal := entry.Palette
for _, tok := range []struct {
name string
fg string
}{
{"ink", pal.ink}, {"muted", pal.muted}, {"faint", pal.faint},
{"faintest", pal.faintest}, {"accent", pal.accent},
} {
if r := wcagRatio(t, tok.fg, pal.panel); r < 4.5 {
t.Errorf("%s %s on panel %.2f < 4.5 (WCAG AA)", entry.Name, tok.name, r)
}
}
if r := wcagRatio(t, pal.onAccent, pal.accent); r < 4.5 {
t.Errorf("%s onAccent on accent %.2f < 4.5 (WCAG AA)", entry.Name, r)
}
// Gray ramp ordered ink -> muted -> faint -> faintest -> panel; luminance
// rises toward the surface on light themes, falls on dark themes.
chain := []float64{
relLum(t, pal.ink), relLum(t, pal.muted), relLum(t, pal.faint),
relLum(t, pal.faintest), relLum(t, pal.panel),
}
for i := 1; i < len(chain); i++ {
ok := chain[i] > chain[i-1]
if entry.IsDark {
ok = chain[i] < chain[i-1]
}
if !ok {
t.Errorf("%s hierarchy not monotonic toward surface at %d: %v", entry.Name, i, chain)
}
}
}
}
// resolveThemeMode precedence: explicit flag > ZERO_THEME env > auto.
func TestResolveThemeModePrecedence(t *testing.T) {
cases := []struct {
flag, env string
want themeMode
}{
{"light", "dark", themeLight}, // flag wins
{"dark", "light", themeDark}, // flag wins
{"", "light", themeLight}, // env
{"", "dark", themeDark}, // env
{"", "", themeAuto}, // default
{"garbage", "also-bad", themeAuto},
{"AUTO", "", themeAuto},
}
for _, c := range cases {
if got := resolveThemeMode(c.flag, c.env); got != c.want {
t.Errorf("resolveThemeMode(%q,%q) = %q, want %q", c.flag, c.env, got, c.want)
}
}
}
// applyTheme: auto resolves from background; explicit dark/light ignore it.
func TestApplyThemeResolution(t *testing.T) {
defer applyTheme(themeDark, true) // restore the global default
cases := []struct {
mode themeMode
darkBg bool
want themeMode
wantInk string
}{
{themeAuto, true, themeDark, darkPalette.ink},
{themeAuto, false, themeLight, lightPalette.ink},
{themeDark, false, themeDark, darkPalette.ink}, // explicit ignores bg
{themeLight, true, themeLight, lightPalette.ink}, // explicit ignores bg
}
for _, c := range cases {
got := applyTheme(c.mode, c.darkBg)
if got != c.want {
t.Errorf("applyTheme(%q, darkBg=%v) = %q, want %q", c.mode, c.darkBg, got, c.want)
}
wantR, wantG, wantB, _ := lipgloss.Color(c.wantInk).RGBA()
gotR, gotG, gotB, _ := zeroTheme.inkColor.RGBA()
if gotR != wantR || gotG != wantG || gotB != wantB {
t.Errorf("applyTheme(%q,%v): zeroTheme.inkColor not the %q ink", c.mode, c.darkBg, c.want)
}
}
}
// The light palette must be a real dark-on-light set: distinct from dark, ink
// well-contrasted against the panel, accent readable, and the gray hierarchy
// (ink→faintest) ordered toward the surface so it still reads on white.
func TestLightPaletteContrastAndHierarchy(t *testing.T) {
if lightPalette.ink == darkPalette.ink || lightPalette.panel == darkPalette.panel {
t.Fatal("light palette must differ from dark")
}
inkL, panelL := relLum(t, lightPalette.ink), relLum(t, lightPalette.panel)
if panelL-inkL < 0.5 {
t.Errorf("light ink/panel contrast too low: panel=%.2f ink=%.2f", panelL, inkL)
}
// AUDIT-H5/H6/M: text-bearing tokens (incl. faint/faintest, which carry line
// numbers, diff @@/+++/---, help text, placeholders, and the accent prompt glyph)
// must meet WCAG AA (>=4.5) against the worst-case background (the panel) — a real
// contrast ratio, not just a luminance ordering.
for _, tok := range []struct {
name string
fg, bg string
}{
{"dark muted", darkPalette.muted, darkPalette.panel},
{"dark faint", darkPalette.faint, darkPalette.panel},
{"dark faintest", darkPalette.faintest, darkPalette.panel},
{"dark accent", darkPalette.accent, darkPalette.panel},
{"light muted", lightPalette.muted, lightPalette.panel},
{"light faint", lightPalette.faint, lightPalette.panel},
{"light faintest", lightPalette.faintest, lightPalette.panel},
{"light accent", lightPalette.accent, lightPalette.panel},
} {
if r := wcagRatio(t, tok.fg, tok.bg); r < 4.5 {
t.Errorf("%s contrast %.2f < 4.5 (WCAG AA): %s on %s", tok.name, r, tok.fg, tok.bg)
}
}
// Dark-on-light: ink darkest, then progressively lighter toward the surface.
chain := []float64{
relLum(t, lightPalette.ink),
relLum(t, lightPalette.muted),
relLum(t, lightPalette.faint),
relLum(t, lightPalette.faintest),
relLum(t, lightPalette.panel),
}
for i := 1; i < len(chain); i++ {
if !(chain[i] > chain[i-1]) {
t.Errorf("light hierarchy not monotonic toward surface at %d: %v", i, chain)
}
}
// Dark theme keeps the inverse ordering (light-on-dark).
dchain := []float64{
relLum(t, darkPalette.ink),
relLum(t, darkPalette.muted),
relLum(t, darkPalette.faint),
relLum(t, darkPalette.faintest),
relLum(t, darkPalette.panel),
}
for i := 1; i < len(dchain); i++ {
if !(dchain[i] < dchain[i-1]) {
t.Errorf("dark hierarchy not monotonic toward surface at %d: %v", i, dchain)
}
}
}
// /theme switches the active theme live and shows state with no arg.
func TestHandleThemeCommand(t *testing.T) {
defer applyTheme(themeDark, true)
m := model{themeMode: themeAuto, hasDarkBg: true}
m, out := m.handleThemeCommand("light")
if m.themeMode != themeLight {
t.Fatalf("after /theme light, mode = %q", m.themeMode)
}
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, lightPalette.ink) {
t.Error("/theme light did not swap the active palette")
}
if !strings.Contains(out, "light") {
t.Errorf("output should confirm light: %q", out)
}
m, _ = m.handleThemeCommand("dark")
if m.themeMode != themeDark {
t.Fatalf("after /theme dark, mode = %q", m.themeMode)
}
_, state := m.handleThemeCommand("")
if !strings.Contains(state, "active theme") {
t.Errorf("no-arg /theme should show state: %q", state)
}
if _, bad := m.handleThemeCommand("solarized"); !strings.Contains(bad, "Unknown theme") {
t.Errorf("invalid theme should error: %q", bad)
}
}
func TestNewThemePresetsWired(t *testing.T) {
neon, ok := lookupTheme("neon")
if !ok {
t.Fatal("theme 'neon' is not registered")
}
if !neon.IsDark {
t.Error("theme 'neon' should be marked as dark")
}
dune, ok := lookupTheme("dune")
if !ok {
t.Fatal("theme 'dune' is not registered")
}
if dune.IsDark {
t.Error("theme 'dune' should be marked as light")
}
for _, name := range []string{"neon", "dune"} {
if !validThemeMode(name) {
t.Errorf("%q should be a valid --theme/ZERO_THEME value", name)
}
}
if !contains(themeModes, "neon") || !contains(themeModes, "dune") {
t.Errorf("themeModes = %v, want it to include neon and dune (the /theme picker list)", themeModes)
}
}
// The --theme flag and ZERO_THEME both resolve through resolveThemeMode, and
// applyTheme must actually swap zeroTheme to the resolved preset's own palette,
// not silently fall back to a built-in.
func TestNewThemePresetsResolveThroughCLIAndEnvPath(t *testing.T) {
defer applyTheme(themeDark, true)
if got := resolveThemeMode("dune", ""); got != themeMode("dune") {
t.Fatalf(`resolveThemeMode("dune", "") = %q, want "dune"`, got)
}
if got := resolveThemeMode("", "neon"); got != themeMode("neon") {
t.Fatalf(`resolveThemeMode("", "neon") = %q, want "neon"`, got)
}
applyTheme(themeMode("dune"), true)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, dunePalette.ink) {
t.Error("applying \"dune\" did not swap zeroTheme to the dune palette")
}
applyTheme(themeMode("neon"), true)
if r, _, _, _ := zeroTheme.inkColor.RGBA(); r != mustR(t, neonPalette.ink) {
t.Error("applying \"neon\" did not swap zeroTheme to the neon palette")
}
}
func TestExtendedThemeContrastInvariants(t *testing.T) {
// Skip validation for old built-in themes if they have established, non-compliant palettes,
// but enforce strict compliance on the newly introduced 'neon' and 'dune' themes.
for _, entry := range themeRegistry {
if entry.Name != "neon" && entry.Name != "dune" {
continue
}
name, pal := entry.Name, entry.Palette
// Finding 1: Permission and status/success surfaces
if r := wcagRatio(t, pal.amber, pal.permBg); r < 4.5 {
t.Errorf("%s: amber on permBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.onAccent, pal.amber); r < 4.5 {
t.Errorf("%s: onAccent on amber contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.green, pal.panel); r < 4.5 {
t.Errorf("%s: green on panel contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.amber, pal.panel); r < 4.5 {
t.Errorf("%s: amber on panel contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.red, pal.panel); r < 4.5 {
t.Errorf("%s: red on panel contrast %.2f < 4.5", name, r)
}
// Finding 2: Selected row secondary text
if r := wcagRatio(t, pal.faint, pal.selBg); r < 4.5 {
t.Errorf("%s: faint on selBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.faintest, pal.selBg); r < 4.5 {
t.Errorf("%s: faintest on selBg contrast %.2f < 4.5", name, r)
}
// Finding 3: Diff gutter pairings
if r := wcagRatio(t, pal.faintest, pal.addBg); r < 4.5 {
t.Errorf("%s: faintest on addBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.faintest, pal.delBg); r < 4.5 {
t.Errorf("%s: faintest on delBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.green, pal.addBg); r < 4.5 {
t.Errorf("%s: green on addBg contrast %.2f < 4.5", name, r)
}
if r := wcagRatio(t, pal.red, pal.delBg); r < 4.5 {
t.Errorf("%s: red on delBg contrast %.2f < 4.5", name, r)
}
}
}
// hexChannels splits a #rrggbb token into its 8-bit channels.
func hexChannels(t *testing.T, hexColor string) (int, int, int) {
t.Helper()
h := strings.TrimPrefix(hexColor, "#")
v, err := strconv.ParseUint(h, 16, 32)
if err != nil || len(h) != 6 {
t.Fatalf("bad hex %q", hexColor)
}
return int((v >> 16) & 0xff), int((v >> 8) & 0xff), int(v & 0xff)
}
// xterm256Hex returns the nearest xterm-256 color (the 6x6x6 cube plus the
// 24-step grayscale ramp, by squared RGB distance): how a terminal without
// truecolor support downsamples the palette's hex tokens before rendering.
func xterm256Hex(t *testing.T, hexColor string) string {
t.Helper()
r, g, b := hexChannels(t, hexColor)
levels := []int{0, 95, 135, 175, 215, 255}
bestR, bestG, bestB := 0, 0, 0
bestDistance := math.MaxFloat64
try := func(cr, cg, cb int) {
d := float64((r-cr)*(r-cr) + (g-cg)*(g-cg) + (b-cb)*(b-cb))
if d < bestDistance {
bestDistance, bestR, bestG, bestB = d, cr, cg, cb
}
}
for _, cr := range levels {
for _, cg := range levels {
for _, cb := range levels {
try(cr, cg, cb)
}
}
}
for i := 0; i < 24; i++ {
gray := 8 + 10*i
try(gray, gray, gray)
}
return fmt.Sprintf("#%02x%02x%02x", bestR, bestG, bestB)
}
// Hex-level AA does not guarantee the rendered pairs hold on a 256-color
// terminal, which quantizes every token to its nearest xterm entry first.
// Guard the pairs that regressed: Dune's selected-row affordances (accent
// caret/favorite star and blue local-model dot over selBg via onSel) and
// Neon's diff bands, whose previous values all quantized to the same grays.
func TestExtendedThemeANSI256Contrast(t *testing.T) {
palettes := map[string]palette{}
for _, entry := range themeRegistry {
palettes[entry.Name] = entry.Palette
}
q := func(hexColor string) string { return xterm256Hex(t, hexColor) }
dune := palettes["dune"]
for _, pair := range []struct{ name, fg, bg string }{
{"accent on selBg", dune.accent, dune.selBg},
{"blue on selBg", dune.blue, dune.selBg},
{"faintest on selBg", dune.faintest, dune.selBg},
{"ink on selBg", dune.ink, dune.selBg},
} {
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
t.Errorf("dune: %s = %.2f < 4.5 after xterm-256 quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
}
}
neon := palettes["neon"]
greenish := func(hexColor string) bool {
r, g, b := hexChannels(t, hexColor)
return g > r && g > b
}
reddish := func(hexColor string) bool {
r, g, b := hexChannels(t, hexColor)
return r > g && r > b
}
if q(neon.addBg) == q(neon.delBg) || !greenish(q(neon.addBg)) || !reddish(q(neon.delBg)) {
t.Errorf("neon: add/del row bands lose their green/red identity after quantization: addBg %s -> %s, delBg %s -> %s",
neon.addBg, q(neon.addBg), neon.delBg, q(neon.delBg))
}
if q(neon.addBgWord) == q(neon.delBgWord) || !greenish(q(neon.addBgWord)) || !reddish(q(neon.delBgWord)) {
t.Errorf("neon: word-span bands lose their green/red identity after quantization: addBgWord %s -> %s, delBgWord %s -> %s",
neon.addBgWord, q(neon.addBgWord), neon.delBgWord, q(neon.delBgWord))
}
if q(neon.addBgWord) == q(neon.addBg) {
t.Errorf("neon: changed span is indistinguishable from its add row after quantization (both %s)", q(neon.addBg))
}
if q(neon.delBgWord) == q(neon.delBg) {
t.Errorf("neon: changed span is indistinguishable from its del row after quantization (both %s)", q(neon.delBg))
}
if r := wcagRatio(t, q(neon.green), q(neon.addBg)); r < 4.5 {
t.Errorf("neon: green on addBg = %.2f < 4.5 after quantization", r)
}
if r := wcagRatio(t, q(neon.red), q(neon.delBg)); r < 4.5 {
t.Errorf("neon: red on delBg = %.2f < 4.5 after quantization", r)
}
// The two rendered diff-content pairs a 256-color terminal actually
// shows: line numbers (faintest on addBg/delBg) and highlighted changed
// spans (addInk/delInk on their word bands).
for _, pair := range []struct{ name, fg, bg string }{
{"faintest on addBg", neon.faintest, neon.addBg},
{"faintest on delBg", neon.faintest, neon.delBg},
{"addInk on addBgWord", neon.addInk, neon.addBgWord},
{"delInk on delBgWord", neon.delInk, neon.delBgWord},
} {
if r := wcagRatio(t, q(pair.fg), q(pair.bg)); r < 4.5 {
t.Errorf("neon: %s = %.2f < 4.5 after quantization (%s on %s)", pair.name, r, q(pair.fg), q(pair.bg))
}
}
// Error-card borders are non-text UI components: they need 3:1 against
// the panel (WCAG 1.4.11) to be distinguishable at all, in both truecolor
// and 256-color renderings.
if r := wcagRatio(t, neon.cardErr, neon.panel); r < 3.0 {
t.Errorf("neon: cardErr border on panel = %.2f < 3.0", r)
}
if r := wcagRatio(t, q(neon.cardErr), q(neon.panel)); r < 3.0 {
t.Errorf("neon: cardErr border on panel = %.2f < 3.0 after quantization", r)
}
}
func mustR(t *testing.T, hex string) uint32 {
t.Helper()
r, _, _, _ := lipgloss.Color(hex).RGBA()
return r
}