appstyles

package
v0.6.0 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 24, 2026 License: MIT Imports: 5 Imported by: 0

Documentation

Index

Constants

View Source
const DefaultTheme = "cais-dusk"

DefaultTheme is the theme a fresh AppModel starts with. Cais-dusk is the app's default palette (changed 2026-08-18, was cais-dark); cais-dark and cais-day remain selectable through the theme picker (docs/DESIGN.md §11).

Variables

Active is the one Theme in effect. Everything that draws reads it fresh each render - see the Theme doc comment - so assigning a different registered Theme here and re-rendering is the whole of what a theme switch needs to do.

View Source
var DocStyle = lipgloss.NewStyle()

DocStyle is an empty style kept only for its frame size (Padding/Border), which does not depend on color - see ContainersList.go's WindowSizeMsg handling.

View Source
var Themes = map[string]Theme{

	"cais-dark": newTheme(themeParams{
		Name:   "cais-dark",
		Dark:   true,
		Accent: lipgloss.Color("#5FB8AF"),
		Text:   lipgloss.Color("#F2EFE9"),
		Panel:  lipgloss.Color("#0F2B33"),
		Modal:  lipgloss.Color("#3A5860"),
		Danger: lipgloss.Color("#E6736E"),

		Running:  lipgloss.Color("#7DC79E"),
		Stopped:  lipgloss.Color("#78939A"),
		Starting: lipgloss.Color("#F5C98A"),
		Err:      lipgloss.Color("#EE7090"),
	}),

	"cais-dusk": newTheme(themeParams{
		Name:   "cais-dusk",
		Dark:   true,
		Accent: lipgloss.Color("#3FA9A0"),
		Text:   lipgloss.Color("#E8E4DC"),
		Panel:  lipgloss.Color("#1A2230"),
		Modal:  lipgloss.Color("#33425E"),
		Danger: lipgloss.Color("#E07060"),

		Running:  lipgloss.Color("#7DC79E"),
		Stopped:  lipgloss.Color("#7E8A9A"),
		Starting: lipgloss.Color("#F2C674"),
		Err:      lipgloss.Color("#EE7090"),
	}),

	"cais-day": newTheme(themeParams{
		Name:   "cais-day",
		Dark:   false,
		Accent: lipgloss.Color("#1F5E5E"),
		Text:   lipgloss.Color("#1B3232"),
		Panel:  lipgloss.Color("#F1ECE2"),
		Modal:  lipgloss.Color("#FBF7EF"),
		Danger: lipgloss.Color("#B33A3A"),

		Running:  lipgloss.Color("#1E7F4E"),
		Stopped:  lipgloss.Color("#5A6F75"),
		Starting: lipgloss.Color("#B07933"),
		Err:      lipgloss.Color("#C0243F"),
	}),

	"catppuccin-mocha": newTheme(themeParams{
		Name:   "catppuccin-mocha",
		Dark:   true,
		Accent: lipgloss.Color("#CBA6F7"),
		Text:   lipgloss.Color("#CDD6F4"),
		Panel:  lipgloss.Color("#11111B"),
		Modal:  lipgloss.Color("#45475A"),
		Danger: lipgloss.Color("#EBA0AC"),

		Running:  lipgloss.Color("#A6E3A1"),
		Stopped:  lipgloss.Color("#7F849C"),
		Starting: lipgloss.Color("#F9E2AF"),
		Err:      lipgloss.Color("#F38BA8"),
	}),

	"gruvbox-dark": newTheme(themeParams{
		Name:   "gruvbox-dark",
		Dark:   true,
		Accent: lipgloss.Color("#FE8019"),
		Text:   lipgloss.Color("#EBDBB2"),
		Panel:  lipgloss.Color("#1D2021"),
		Modal:  lipgloss.Color("#504945"),
		Danger: lipgloss.Color("#FB4934"),

		Running:  lipgloss.Color("#B8BB26"),
		Stopped:  lipgloss.Color("#928374"),
		Starting: lipgloss.Color("#FABD2F"),
		Err:      lipgloss.Color("#FB4934"),
	}),

	"tokyo-night": newTheme(themeParams{
		Name:   "tokyo-night",
		Dark:   true,
		Accent: lipgloss.Color("#7AA2F7"),
		Text:   lipgloss.Color("#C0CAF5"),
		Panel:  lipgloss.Color("#16161E"),
		Modal:  lipgloss.Color("#394B70"),
		Danger: lipgloss.Color("#DB4B4B"),

		Running:  lipgloss.Color("#9ECE6A"),
		Stopped:  lipgloss.Color("#737AA2"),
		Starting: lipgloss.Color("#E0AF68"),
		Err:      lipgloss.Color("#F7768E"),
	}),

	"nord": newTheme(themeParams{
		Name:   "nord",
		Dark:   true,
		Accent: lipgloss.Color("#88C0D0"),
		Text:   lipgloss.Color("#D8DEE9"),
		Panel:  lipgloss.Color("#242933"),
		Modal:  lipgloss.Color("#4C566A"),
		Danger: lipgloss.Color("#BF616A"),

		Running:  lipgloss.Color("#A3BE8C"),
		Stopped:  lipgloss.Color("#7B88A1"),
		Starting: lipgloss.Color("#EBCB8B"),
		Err:      lipgloss.Color("#BF616A"),
	}),

	"dracula": newTheme(themeParams{
		Name:   "dracula",
		Dark:   true,
		Accent: lipgloss.Color("#BD93F9"),
		Text:   lipgloss.Color("#F8F8F2"),
		Panel:  lipgloss.Color("#1E1F29"),
		Modal:  lipgloss.Color("#44475A"),
		Danger: lipgloss.Color("#FF5555"),

		Running:  lipgloss.Color("#50FA7B"),
		Stopped:  lipgloss.Color("#6272A4"),
		Starting: lipgloss.Color("#FFB86C"),
		Err:      lipgloss.Color("#FF5555"),
	}),

	"solarized-dark": newTheme(themeParams{
		Name:   "solarized-dark",
		Dark:   true,
		Accent: lipgloss.Color("#268BD2"),
		Text:   lipgloss.Color("#93A1A1"),
		Panel:  lipgloss.Color("#001A21"),
		Modal:  lipgloss.Color("#073642"),
		Danger: lipgloss.Color("#DC322F"),

		Running:  lipgloss.Color("#859900"),
		Stopped:  lipgloss.Color("#657B83"),
		Starting: lipgloss.Color("#B58900"),
		Err:      lipgloss.Color("#DC322F"),
	}),

	"everforest-dark": newTheme(themeParams{
		Name:   "everforest-dark",
		Dark:   true,
		Accent: lipgloss.Color("#A7C080"),
		Text:   lipgloss.Color("#D3C6AA"),
		Panel:  lipgloss.Color("#232A2E"),
		Modal:  lipgloss.Color("#4F585E"),
		Danger: lipgloss.Color("#E67E80"),

		Running:  lipgloss.Color("#83C092"),
		Stopped:  lipgloss.Color("#859289"),
		Starting: lipgloss.Color("#DBBC7F"),
		Err:      lipgloss.Color("#E67E80"),
	}),

	"rose-pine": newTheme(themeParams{
		Name:   "rose-pine",
		Dark:   true,
		Accent: lipgloss.Color("#EBBCBA"),
		Text:   lipgloss.Color("#E0DEF4"),
		Panel:  lipgloss.Color("#191724"),
		Modal:  lipgloss.Color("#403D52"),
		Danger: lipgloss.Color("#EB6F92"),

		Running:  lipgloss.Color("#9CCFD8"),
		Stopped:  lipgloss.Color("#908CAA"),
		Starting: lipgloss.Color("#F6C177"),
		Err:      lipgloss.Color("#EB6F92"),
	}),

	"monokai-pro": newTheme(themeParams{
		Name:   "monokai-pro",
		Dark:   true,
		Accent: lipgloss.Color("#FF6188"),
		Text:   lipgloss.Color("#FCFCFA"),
		Panel:  lipgloss.Color("#221F22"),
		Modal:  lipgloss.Color("#2E272E"),
		Danger: lipgloss.Color("#F92672"),

		Running:  lipgloss.Color("#A9DC76"),
		Stopped:  lipgloss.Color("#7C797D"),
		Starting: lipgloss.Color("#FFD866"),
		Err:      lipgloss.Color("#F92672"),
	}),

	"gruvbox-light": newTheme(themeParams{
		Name:   "gruvbox-light",
		Dark:   false,
		Accent: lipgloss.Color("#CC241D"),
		Text:   lipgloss.Color("#3C3836"),
		Panel:  lipgloss.Color("#F0EBC8"),
		Modal:  lipgloss.Color("#FFFDF0"),
		Danger: lipgloss.Color("#9D0006"),

		Running:  lipgloss.Color("#79740E"),
		Stopped:  lipgloss.Color("#7C6F64"),
		Starting: lipgloss.Color("#B57614"),
		Err:      lipgloss.Color("#9D0006"),
	}),

	"catppuccin-latte": newTheme(themeParams{
		Name:   "catppuccin-latte",
		Dark:   false,
		Accent: lipgloss.Color("#9A6300"),
		Text:   lipgloss.Color("#33364A"),
		Panel:  lipgloss.Color("#E9EBF0"),
		Modal:  lipgloss.Color("#F8F9FC"),
		Danger: lipgloss.Color("#D20F39"),

		Running:  lipgloss.Color("#2B7A1C"),
		Stopped:  lipgloss.Color("#6B6F80"),
		Starting: lipgloss.Color("#B24C08"),
		Err:      lipgloss.Color("#D20F39"),
	}),
}

Themes is the registry a theme picker (post-alpha, see docs/ROADMAP.md) will choose from. Every entry is built through newTheme rather than a bare struct literal, so a registered theme can't leave a field zero-valued the way a hand-written literal could - which matters here because a nil color.Color renders as no SGR at all, i.e. a background-bleed bug. See src/appstyles/Theme_test.go and src/appstyles/Background_test.go.

Functions

func Contrast

func Contrast(a, b color.Color) float64

Contrast is the WCAG 2.x contrast ratio between two opaque colors.

func FillBackground

func FillBackground(bg color.Color, block string) string

FillBackground repaints `block` so every cell on every line carries `bg`.

Why this is needed: a terminal's SGR reset (`\x1b[m`) clears the background along with everything else, and it applies until the next SGR. lipgloss closes every styled run with a reset, so any *unstyled* text that follows a styled run on the same line renders on the terminal's default background — a visible notch in an otherwise solid panel.

Two things in the render pipeline produce exactly that unstyled text:

  • lipgloss.JoinVertical/JoinHorizontal pad shorter blocks to the widest block with a bare strings.Repeat(" ", n) — see join.go. Unlike a style's own Width() padding, which alignTextHorizontal runs through the style's whitespace renderer, Join's padding carries no SGR at all.
  • bubbles components (the lists' "No items." row, for one) join their inner rows the same way.

Wrapping the result in an outer Background() style does not help: that only styles the padding the outer style itself adds, so the bare spaces already sitting inside the block survive untouched.

So the fix has to run over the finished string. For each line we re-assert `bg` immediately after every reset, and open the line with it, which leaves any explicit inner background (a title pill, a status dot) intact while closing every gap around it. Foreground is deliberately not re-asserted: the runs we are patching are whitespace, and real text always brings its own foreground.

Apply this once per background tier, at the point that tier is established (see the tier comments in this package and docs/DESIGN.md). Applying it at an outer tier only would paint inner panels with the outer tier's color.

func HasBackgroundBleed

func HasBackgroundBleed(block string) bool

HasBackgroundBleed reports whether any line in block contains a run of spaces that will render on the terminal's default background: spaces that follow a reset with no SGR in between. It is the inverse of what FillBackground guarantees, and exists so tests in any package can assert the invariant on a fully rendered frame rather than eyeballing a screenshot.

func InkOn

func InkOn(fill color.Color) color.Color

InkOn returns whichever of the theme's two fixed inks reads better on fill.

InkOnLight/InkOnDark do not vary with the theme (see the Theme field comment) because the fills they sit on - a status pill, the accent title chip - are not derived from the surface tiers. What *does* vary is which of the two is correct, and that is a property of the fill, not of the call site. Hard-coding it worked while one dark theme existed; with a light theme and ten imported palettes in the registry, the same call site draws on a #BC3FBC magenta in one theme and a #A7C080 sage in another.

func NormalTitle

func NormalTitle() lipgloss.Style

NormalTitle is THE accent title chip: bold ink derived from the theme accent on an accent background, with one space of padding on each side. Every title - the Details/GroupDetails frames, the Services, Groups and Containers list titles, and every modal heading - renders through this one style, so a theme or style change here lands on all of them at once. See PanelFrame.go.

The style deliberately carries no margin: the left gutter is the container's job. A bubbles list provides it via its internal TitleBar padding, and the panel frame adds MarginLeft(2) to match that gutter - see renderPanelFrame.

func SetTheme

func SetTheme(name string) bool

SetTheme assigns a new active theme by name. Everything that draws reads Active fresh on each render, so the next frame repaints in the new palette. Exported so the theme picker modal can apply themes live as the cursor moves.

Returns false if name is not in the registry, so the caller can report the error. When config file persistence lands, this function gains a tea.Cmd return that writes the chosen name to disk - one line added, no caller changes needed.

Types

type Theme

type Theme struct {
	Name string
	// Dark says which way the tiers derive: a dark theme raises a surface's
	// attention by lightening it, a light theme by darkening it. See
	// newTheme.
	Dark bool

	// Accent is the brand color: focus, the wordmark, the active tab, title
	// chips. It does not vary with Dark - see newTheme.
	Accent color.Color

	// Text tiers, most to least emphasis.
	TextPrimary color.Color
	TextMuted   color.Color
	TextDim     color.Color

	// PanelBg is the base surface color every background tier below derives
	// from, and is also BackgroundRecessed's value directly.
	PanelBg color.Color
	// BackgroundContent/Panel/Elevated are tiers 2/3/4 in docs/DESIGN.md's
	// "Background tiers, and sealing them": the frame, an unfocused panel,
	// and a focused panel.
	BackgroundContent  color.Color
	BackgroundPanel    color.Color
	BackgroundElevated color.Color
	// BackgroundRecessed sits *below* the panel tier, for insets like the
	// empty-state cards - see docs/DESIGN.md. Equal to PanelBg by
	// construction: both are the tier ladder's un-raised base.
	BackgroundRecessed color.Color
	// ModalBg is the surface every modal - and an active list row - is drawn
	// on: a distinct register from the panel tiers, not derived from
	// PanelBg.
	ModalBg color.Color

	// BorderDefault rims an ordinary panel; in a dark theme it is darker
	// than PanelBg so it all but disappears against a *recessed* fill.
	// BorderCard rims a recessed surface, so it has to go the other way -
	// lighter than PanelBg in a dark theme - or the rim vanishes into what
	// it is meant to outline. See docs/DESIGN.md.
	BorderDefault color.Color
	BorderCard    color.Color

	// Status tiers reflect one container or group's own state, and do not
	// vary with Dark: a "running" dot is the same green whichever theme is
	// active. See InkOnLight/InkOnDark for the text that sits on top of one.
	StatusRunning  color.Color
	StatusStopped  color.Color
	StatusStarting color.Color
	StatusError    color.Color

	// Danger is app-level alert chrome - the error banner, an inline
	// validation message - a different concept from StatusError (one
	// service's own state) even though earlier code used one hex for both
	// by coincidence.
	Danger color.Color

	// InkOnLight and InkOnDark are deliberately theme-invariant: a status
	// pill's fill (StatusRunning green, StatusStarting amber, StatusError
	// red/pink) is the same hue whichever theme is active, so the text that
	// reads legibly on it can't follow Dark either - a bright pill needs
	// dark ink and a dark pill needs light ink regardless of the *app's*
	// theme. See GroupDetailsPanel.go's statusPill.
	InkOnLight color.Color
	InkOnDark  color.Color
}

Theme is every color the app draws with, resolved to concrete values. One field per semantic token, so a registered Theme is the app's complete visual vocabulary - no component builds a color of its own.

A Theme is inert data, not a service: nothing here reads the terminal or does I/O. Active (below) is the one Theme in effect, and everything that draws reads it fresh on every render rather than caching a color at package init - see the package-level styles in styles.go for why that distinction matters. That is the whole of what lets a later switch repaint the app: assign a different registered Theme to Active and the next frame draws it.

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL