Files
terminalShop-old/docs/theme-implementation.md
2026-04-09 17:39:59 +02:00

7.6 KiB

Theme System Implementation Reference

Use this document when implementing or modifying the theme system. Every decision and pattern is explained so context loss doesn't stall progress.


What We're Doing

Replacing 40+ scattered lipgloss.Color("#XXXXXX") calls across 13 TUI files with a centralized Theme struct in pkg/tui/theme/. Copied closely from the reference implementation in terminal-shop-source/packages/go/pkg/tui/theme/.


New Files to Create

pkg/tui/theme/theme.go   — Theme struct, BasicTheme(), all color/style methods
pkg/tui/theme/huh.go     — HuhTheme(), copyFieldStyles(), copyTextStyles()

Files to Modify (in order)

Step File Type of change
1 pkg/tui/theme/theme.go Create
2 pkg/tui/theme/huh.go Create
3 pkg/tui/model.go Add renderer + theme fields; update constructors
4 main.go Create renderer from SSH session; pass to NewModelWithAuth
5 pkg/tui/views.go 3 color swaps
6 pkg/tui/splash.go 2 color swaps
7 pkg/tui/header.go 4 color swaps
8 pkg/tui/footer.go 2 color swaps
9 pkg/tui/breadcrumbs.go 3 color swaps
10 pkg/tui/shop.go 6 color swaps
11 pkg/tui/cart.go 9 color swaps
12 pkg/tui/account.go 14 color swaps
13 pkg/tui/menu.go 4 color swaps
14 pkg/tui/help.go 5 color swaps
15 pkg/tui/confirmation.go 5 color swaps
16 pkg/tui/shipping_form.go 4 color swaps + .WithTheme()
17 pkg/tui/payment_form.go 5 color swaps + .WithTheme()

Color Mapping (hardcoded → theme)

Hardcoded Semantic role Text style method Color accessor
#FFFFFF active / selected text m.theme.TextAccent() m.theme.Accent()
#AAAAAA body / regular text m.theme.TextBody() m.theme.Body()
#666666 hints / inactive / dim text m.theme.TextDim() m.theme.Dim()
#666666 border foreground — m.theme.Border()
#4682B4 highlight / brand / steel blue m.theme.TextHighlight() m.theme.Highlight()
#999999 form label text m.theme.TextLabel() m.theme.Label()
#00FF00 success / order placed m.theme.TextSuccess() m.theme.Success()
"196" error text m.theme.TextError() m.theme.Error()
"205" loading / spinner m.theme.TextLoading() —
"196" + "52" error banner bg+fg m.theme.PanelError() —
"C6DDF0" user name (light blue) m.theme.TextHighlight() —
#CCCCCC muted subtext m.theme.TextBody() —

Note: #666666 is used for BOTH dim text AND border foreground in this project. Both Dim() and Border() return the same value. Use Border() in BorderForeground(), use Dim() / TextDim() for text styling.

Note: Dynamic product colors (coffee.Color = "#8B4513" etc.) are NOT replaced. Keep those as lipgloss.Color(coffee.Color).


Theme Colors (what BasicTheme sets)

background = AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
border     = AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
body       = Color("#AAAAAA")
dim        = Color("#666666")
accent     = AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
highlight  = Color("#4682B4")
brand      = Color("#4682B4")   // same as highlight for this project
label      = Color("#999999")
success    = Color("#00FF00")
error      = Color("196")

How the Renderer Works

The reference implementation creates a *lipgloss.Renderer per SSH session:

// main.go — SSH handler
renderer := bubbletea.MakeRenderer(s)   // s = ssh.Session

This lets lipgloss detect the correct color profile for each client's terminal (e.g. TrueColor for Ghostty vs 256-color for basic terminals). All style creation inside the theme uses renderer.NewStyle() instead of the global lipgloss.NewStyle().

In this project:

  • SSH sessions → renderer from bubbletea.MakeRenderer(s)
  • Local / test mode → lipgloss.DefaultRenderer()

Usage Pattern in Views

All view functions are methods on Model, so m.theme is always available.

Text-only style (before → after):

// before
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Width(18)

// after
m.theme.TextAccent().Bold(true).Width(18)

Border foreground (before → after):

// before
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666"))

// after
lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Border())

Selected item background (before → after):

// before
lipgloss.NewStyle().Background(lipgloss.Color("#4682B4")).Foreground(lipgloss.Color("#FFFFFF"))

// after
lipgloss.NewStyle().Background(m.theme.Highlight()).Foreground(m.theme.Accent())

lipgloss.NewStyle() stays for layout-only styles (Width, Margin, Padding, Align with no color). Only color-setting calls move to the theme.


Huh Form Theme

HuhTheme() creates a *huh.Theme applied to shipping and payment forms.

Applied by chaining .WithTheme(m.theme.Form()) on the huh.NewForm(...) call:

// shipping_form.go and payment_form.go — in buildShippingForm / buildPaymentForm
f := huh.NewForm(...).
    WithShowErrors(false).
    WithShowHelp(false).
    WithTheme(m.theme.Form())   // ← add this line

Form theme styles:

  • Focused border: thick left border in accent color (#FFFFFF)
  • Field title + body text: body color (#AAAAAA)
  • Cursor: highlight color (#4682B4)
  • Placeholder text: dim color (#666666)
  • Error: color "196"
  • Blurred fields: hidden border (same styles, border invisible)

Constructor Changes (model.go)

Before: NewModel(username string) initializes everything. NewModelWithAuth(fingerprint, pubKey, apiURL, secret, stripeKey) calls NewModel.

After: A private newModelWithRenderer(username, renderer) does the heavy init. Both public constructors call it:

func NewModel(username string) Model {
    return newModelWithRenderer(username, lipgloss.DefaultRenderer())
}

func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint, pubKey, apiURL, secret, stripeKey string) Model {
    m := newModelWithRenderer("", renderer)
    m.Fingerprint = fingerprint
    // ... etc
    return m
}

NewModelWithAuth signature change: renderer moves to first parameter. Update the call in main.go accordingly.


Theme Methods Quick Reference

// Color accessors (lipgloss.TerminalColor — use in .Foreground() / .Background())
m.theme.Accent()      // #FFFFFF / #11181C adaptive
m.theme.Body()        // #AAAAAA
m.theme.Dim()         // #666666
m.theme.Border()      // #666666 / #D7DBDF adaptive
m.theme.Highlight()   // #4682B4
m.theme.Brand()       // #4682B4
m.theme.Label()       // #999999
m.theme.Success()     // #00FF00
m.theme.Error()       // "196"
m.theme.Background()  // #000000 / #FBFCFD adaptive

// Style helpers (lipgloss.Style — chain .Bold(), .Width(), .Padding() etc.)
m.theme.Base()           // base style, body foreground
m.theme.TextAccent()     // white text
m.theme.TextBody()       // gray text (#AAAAAA)
m.theme.TextDim()        // dim text (#666666)
m.theme.TextHighlight()  // steel blue text (#4682B4)
m.theme.TextBrand()      // same as TextHighlight
m.theme.TextLabel()      // label text (#999999)
m.theme.TextSuccess()    // green text (#00FF00)
m.theme.TextError()      // error text ("196")
m.theme.TextLoading()    // loading pink text ("205")
m.theme.PanelError()     // error banner: dark red bg + red fg
m.theme.Form()           // *huh.Theme for form styling