theme system

This commit is contained in:
2026-04-09 17:39:59 +02:00
parent 062242e346
commit 639b75578e
23 changed files with 660 additions and 188 deletions

4
Procfile Normal file
View File

@@ -0,0 +1,4 @@
api: bash -c 'cd api && go run main.go'
ssh: go run main.go
webhook: stripe listen --api-key $STRIPE_SECRET_KEY --forward-to localhost:8080/api/v1/webhooks/stripe
tunnel: ngrok http 8080 --log stdout

View File

@@ -47,7 +47,6 @@ go run main.go
**Features missing vs reference (terminal.shop):** **Features missing vs reference (terminal.shop):**
- [ ] Splash/loading screen on connect - [ ] Splash/loading screen on connect
- [ ] Animated logo with blinking cursor - [ ] Animated logo with blinking cursor
- [ ] Subscriptions (recurring orders) — subscribe, manage, cancel
- [ ] API token management page - [ ] API token management page
- [ ] OAuth app management page - [ ] OAuth app management page
- [ ] About page - [ ] About page

93
docs/gap-analysis.md Normal file
View File

@@ -0,0 +1,93 @@
# Gap Analysis: Terminal Coffee Shop vs Reference (terminal.shop)
**Date:** 2026-04-08
---
## What's Already Working
- SSH server with key-based auth + auto user creation
- Shop page with live detail panel
- Cart (add/remove, synced to API)
- Full checkout flow: shipping → payment → confirmation
- Saved addresses and saved cards
- Order history in account view
- FAQ page
- About page
- Splash screen with blinking cursor animation
- Responsive layout (small/medium/large)
- Token refresh
- Full backend API (Chi router, PostgreSQL, GORM, Stripe webhooks)
- Shippo shipping integration
---
## Missing Features (vs reference)
### 1. API Token Management
- **Reference:** Create, view, and revoke personal API tokens
- **Current:** Not implemented
- **Reference file:** `tokens.go`
- **Task:** Add tokens section under account view with list + create/revoke actions
### 2. OAuth App Management
- **Reference:** Create, edit, delete OAuth apps with redirect URIs; uses `huh` form
- **Current:** Not implemented
- **Reference file:** `apps.go`
- **Task:** Add apps section under account view with form-based CRUD
### 3. Keyboard Interactive SSH Auth (Anonymous Sessions)
- **Reference:** Supports keyboard interactive auth in addition to public key, generates UUID fingerprints for anonymous users
- **Current:** Public key auth only (`WithPublicKeyAuth` in `main.go`)
- **Task:** Add `WithKeyboardInteractiveAuth` to Wish options; generate UUID fingerprint for anonymous sessions
---
## Code Quality Issues
### Theme System
- **Reference:** Centralized `theme.Theme` struct with methods (`Base()`, `TextAccent()`, `Border()`, `Brand()`, `Accent()`)
- **Current:** 40+ hardcoded hex colors scattered across view files (e.g. `#4682B4`, `#FFFFFF`, `#666666`)
- **Task:** Create `pkg/tui/theme/theme.go`, extract all colors and styles, update all view files to reference theme
### State Management
- **Reference:** Nested state substruct: `state.shop`, `state.cart`, `state.account`, etc. — each feature owns its state
- **Current:** Flat `Model` struct with 80+ fields, all mixed together
- **Task:** Refactor into nested substructs per feature area (breaking change — do after features are stable)
### Viewport-Based Scrolling
- **Reference:** Uses `charmbracelet/bubbles/viewport` throughout for scrollable content
- **Current:** Manual scroll offset math with hardcoded heights (e.g. `-9` for cart viewport height)
- **Task:** Replace manual scroll math with `viewport.Model` in cart, account, and FAQ views
---
## Architecture Comparison
| Area | Current | Reference |
|------|---------|-----------|
| State management | Flat `Model` struct | Nested `state` substructs per feature |
| Theme/styling | 40+ hardcoded colors | Centralized `theme.Theme` |
| Scrolling | Manual offset math | `viewport.Model` |
| Backend | Full custom API (Chi + GORM) | External SDK (`terminal-sdk-go`) |
| Auth | SSH pubkey + JWT | SSH pubkey + keyboard interactive |
| Product model | Custom `Coffee` struct | SDK `terminal.Product` with variants |
| Forms | `huh` (partial) | `huh` (comprehensive, with huh theme) |
---
## Recommended Order of Work
1. **Fix known bugs** — address delete bug and placeholder item (quick wins, low risk)
2. **Theme system** — extract colors; unblocks all visual polish
3. **Viewport-based scrolling** — replace manual math, fixes responsiveness
4. **API token management** — useful feature, account section
5. **OAuth app management** — lowest priority unless API access is a goal
6. **Keyboard interactive SSH auth** — enables anonymous browsing
7. **Nested state structs** — last, after features are stable (pure refactor)

View File

@@ -0,0 +1,80 @@
# Custom Card Form with Stripe.js
## Why
The reference (terminal.shop) uses their own branded card input page with Stripe.js
instead of Stripe Hosted Checkout. This means:
- Full control over the UI/branding
- No webhooks needed for card saving (token POSTed directly to API)
- No Stripe Checkout Session required
- Cleaner flow, fewer moving parts
## How it would work
1. TUI calls `POST /api/v1/cards/collect` → gets `https://yourdomain.com/pay/TOKEN`
2. User opens URL → sees your own card form (HTML + Stripe.js)
3. Stripe.js tokenizes card in the browser (client-side, no raw card data APIs needed)
4. Page POSTs token + TOKEN to your API: `POST /api/v1/cards/collect/TOKEN`
5. API saves the card to the database
6. TUI polling detects new card and auto-proceeds
## What to build
### 1. HTML page served at `GET /pay/{token}`
Instead of redirecting to Stripe, serve an HTML page with Stripe Elements.
The TOKEN from the URL is used to associate the card with the right user.
```html
<!DOCTYPE html>
<html>
<head>
<script src="https://js.stripe.com/v3/"></script>
</head>
<body>
<form id="card-form">
<div id="card-element"></div>
<button type="submit">Save Card</button>
</form>
<script>
const stripe = Stripe('pk_test_YOUR_PUBLISHABLE_KEY');
const elements = stripe.elements();
const card = elements.create('card');
card.mount('#card-element');
document.getElementById('card-form').addEventListener('submit', async (e) => {
e.preventDefault();
const { token } = await stripe.createToken(card);
await fetch('/api/v1/cards/collect/TOKEN_FROM_URL', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: token.id })
});
window.location.href = '/card-added';
});
</script>
</body>
</html>
```
### 2. New API endpoint: `POST /api/v1/cards/collect/{token}`
- Looks up the user from the token (stored in DB or memory map)
- Calls Stripe to convert token → PaymentMethod
- Attaches to customer
- Saves card to DB
- No webhook needed
### 3. Remove Stripe Checkout Session logic from CollectCard
`CollectCard` just generates a token, stores user association, returns the URL.
No `stripeSession.New()` call needed.
## Notes
- Requires a publicly accessible HTTPS domain (same as current approach)
- The in-memory token→userID map works fine (same TTL concerns as current approach)
- Could remove the `checkout.session.completed` webhook handler entirely
- Would need to remove the Stripe Checkout Session dependency from `cards.go`

View File

@@ -0,0 +1,225 @@
# 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)
```go
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**:
```go
// 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):**
```go
// before
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Width(18)
// after
m.theme.TextAccent().Bold(true).Width(18)
```
**Border foreground (before → after):**
```go
// 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):**
```go
// 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:
```go
// 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:
```go
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
```go
// 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
```

View File

@@ -33,6 +33,8 @@ var (
) )
func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) { func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
renderer := bubbletea.MakeRenderer(s)
// Extract SSH public key from session // Extract SSH public key from session
pubKey := s.PublicKey() pubKey := s.PublicKey()
@@ -43,7 +45,7 @@ func teaHandler(s ssh.Session) (tea.Model, []tea.ProgramOption) {
} }
// Create TUI model with user context (no registration screen needed!) // Create TUI model with user context (no registration screen needed!)
m := tui.NewModelWithAuth(fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey) m := tui.NewModelWithAuth(renderer, fingerprint, pubKeyStr, apiURL, authFingerprintKey, stripePublicKey)
return m, []tea.ProgramOption{tea.WithAltScreen()} return m, []tea.ProgramOption{tea.WithAltScreen()}
} }

View File

@@ -26,7 +26,7 @@ func (Coffee) TableName() string {
return "coffees" return "coffees"
} }
// AccountMenuItems something // AccountMenuItems
var AccountMenuItems = []string{ var AccountMenuItems = []string{
"order history", "order history",
"faq", "faq",

View File

@@ -19,15 +19,15 @@ func (m Model) BuildAccountView(availableHeight int) string {
for i, item := range models.AccountMenuItems { for i, item := range models.AccountMenuItems {
if m.AccountCursor == i { if m.AccountCursor == i {
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
Background(lipgloss.Color("#4682B4")). Background(m.theme.Highlight()).
Foreground(lipgloss.Color("#FFFFFF")). Foreground(m.theme.Accent()).
Padding(0, 1). Padding(0, 1).
Width(leftWidth - 2). Width(leftWidth - 2).
Align(lipgloss.Left) Align(lipgloss.Left)
leftPanel += style.Render(item) + "\n" leftPanel += style.Render(item) + "\n"
} else { } else {
style := lipgloss.NewStyle(). style := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")). Foreground(m.theme.Body()).
Padding(0, 1). Padding(0, 1).
Width(leftWidth - 2). Width(leftWidth - 2).
Align(lipgloss.Left) Align(lipgloss.Left)
@@ -41,10 +41,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
// Build the detail view (right panel) based on cursor position // Build the detail view (right panel) based on cursor position
detailView := "" detailView := ""
if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) { if m.AccountCursor >= 0 && m.AccountCursor < len(models.AccountMenuItems) {
titleStyle := lipgloss.NewStyle(). titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
MarginBottom(2)
// On small terminals, use full content width // On small terminals, use full content width
detailContentWidth := rightWidth - 2 detailContentWidth := rightWidth - 2
@@ -52,9 +49,7 @@ func (m Model) BuildAccountView(availableHeight int) string {
detailContentWidth = m.widthContent - 2 detailContentWidth = m.widthContent - 2
} }
contentStyle := lipgloss.NewStyle(). contentStyle := m.theme.TextBody().Width(detailContentWidth)
Foreground(lipgloss.Color("#AAAAAA")).
Width(detailContentWidth)
selectedItem := models.AccountMenuItems[m.AccountCursor] selectedItem := models.AccountMenuItems[m.AccountCursor]
switch selectedItem { switch selectedItem {
@@ -84,15 +79,13 @@ func (m Model) BuildAccountView(availableHeight int) string {
} }
if m.OrderViewState == 0 { if m.OrderViewState == 0 {
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) hintStyle := m.theme.TextDim()
lines += "\n" + hintStyle.Render("enter: browse orders") lines += "\n" + hintStyle.Render("enter: browse orders")
} }
detailView = lines detailView = lines
} }
case "faq": case "faq":
questionStyle := lipgloss.NewStyle(). questionStyle := m.theme.TextHighlight().Bold(true)
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
faqContent := "" faqContent := ""
for i, faq := range m.FAQs { for i, faq := range m.FAQs {
@@ -104,16 +97,12 @@ func (m Model) BuildAccountView(availableHeight int) string {
} }
detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent detailView = titleStyle.Render("FAQ") + "\n\n" + faqContent
if !m.FaqFocused { if !m.FaqFocused {
hintStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) hintStyle := m.theme.TextDim()
detailView += "\n\n" + hintStyle.Render("enter: scroll faq") detailView += "\n\n" + hintStyle.Render("enter: scroll faq")
} }
case "about": case "about":
accentStyle := lipgloss.NewStyle(). accentStyle := m.theme.TextHighlight().Bold(true)
Foreground(lipgloss.Color("#4682B4")). userStyle := m.theme.TextAccent().Bold(true)
Bold(true)
userStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("C6DDF0")).
Bold(true)
aboutContent := contentStyle.Render(wordWrap( aboutContent := contentStyle.Render(wordWrap(
"This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:", "This project is heavily inspired by the wonderful team who made TerminalDotShop. I want to thank them for open-sourcing the code for that project. Cudos to those smart, funny, amazing looking devs:",
@@ -199,16 +188,11 @@ func (m Model) BuildAccountView(availableHeight int) string {
// Shows order number, date, status, total, and the first 2 items as a preview. // Shows order number, date, status, total, and the first 2 items as a preview.
func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string { func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool, boxPadding int, containerWidth int) string {
// Order header line: "Order #123" left, "Jan 02 2026 PAID" right // Order header line: "Order #123" left, "Jan 02 2026 PAID" right
nameStyle := lipgloss.NewStyle(). nameStyle := m.theme.TextAccent().Bold(true)
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
dimStyle := lipgloss.NewStyle(). dimStyle := m.theme.TextDim()
Foreground(lipgloss.Color("#666666"))
statusStyle := lipgloss.NewStyle(). statusStyle := m.theme.TextHighlight().Bold(true)
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID)) orderLabel := nameStyle.Render(fmt.Sprintf("Order #%d", order.ID))
total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0)) total := nameStyle.Render(fmt.Sprintf("$%.2f", float64(order.Total)/100.0))
@@ -254,13 +238,13 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool,
if isSelected { if isSelected {
itemBox = lipgloss.NewStyle(). itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true). Border(lipgloss.NormalBorder(), true).
BorderForeground(lipgloss.Color("#FFFFFF")). BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2). Padding(boxPadding, 2).
Width(boxWidth) Width(boxWidth)
} else { } else {
itemBox = lipgloss.NewStyle(). itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#666666")). BorderForeground(m.theme.Border()).
Padding(boxPadding, 2). Padding(boxPadding, 2).
Width(boxWidth) Width(boxWidth)
} }
@@ -274,20 +258,13 @@ func (m Model) buildOrderCard(order models.Order, boxWidth int, isSelected bool,
// buildOrderDetailView renders the full detail view for a single order. // buildOrderDetailView renders the full detail view for a single order.
func (m Model) buildOrderDetailView(order models.Order, width int) string { func (m Model) buildOrderDetailView(order models.Order, width int) string {
titleStyle := lipgloss.NewStyle(). titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(1)
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
MarginBottom(1)
labelStyle := lipgloss.NewStyle(). labelStyle := m.theme.TextHighlight().Bold(true)
Foreground(lipgloss.Color("#4682B4")).
Bold(true)
valueStyle := lipgloss.NewStyle(). valueStyle := m.theme.TextBody()
Foreground(lipgloss.Color("#AAAAAA"))
dimStyle := lipgloss.NewStyle(). dimStyle := m.theme.TextDim()
Foreground(lipgloss.Color("#666666"))
var b strings.Builder var b strings.Builder
@@ -366,12 +343,8 @@ func (m Model) computeFaqScrollMax() int {
detailContentWidth = m.widthContent - 2 detailContentWidth = m.widthContent - 2
} }
questionStyle := lipgloss.NewStyle(). questionStyle := m.theme.TextHighlight().Bold(true)
Foreground(lipgloss.Color("#4682B4")). contentStyle := m.theme.TextBody().Width(detailContentWidth)
Bold(true)
contentStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#AAAAAA")).
Width(detailContentWidth)
faqContent := "" faqContent := ""
for i, faq := range m.FAQs { for i, faq := range m.FAQs {
@@ -382,10 +355,7 @@ func (m Model) computeFaqScrollMax() int {
} }
} }
titleStyle := lipgloss.NewStyle(). titleStyle := m.theme.TextAccent().Bold(true).MarginBottom(2)
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
MarginBottom(2)
detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent detailView := titleStyle.Render("FAQ") + "\n\n" + faqContent
totalLines := len(strings.Split(detailView, "\n")) totalLines := len(strings.Split(detailView, "\n"))

View File

@@ -10,15 +10,9 @@ func (m Model) BuildBreadcrumbs() string {
return "" return ""
} }
activeStyle := lipgloss.NewStyle(). activeStyle := m.theme.TextAccent().Bold(true)
Foreground(lipgloss.Color("#FFFFFF")). inactiveStyle := m.theme.TextDim()
Bold(true) sepStyle := m.theme.TextDim()
inactiveStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
sepStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color("#666666"))
sep := sepStyle.Render(" / ") sep := sepStyle.Render(" / ")

View File

@@ -29,8 +29,7 @@ func (m Model) updateCartViewport() Model {
func (m Model) generateCartContent() string { func (m Model) generateCartContent() string {
if len(m.Cart) == 0 { if len(m.Cart) == 0 {
emptyStyle := lipgloss.NewStyle(). emptyStyle := m.theme.TextDim().
Foreground(lipgloss.Color("#666666")).
Align(lipgloss.Center). Align(lipgloss.Center).
Width(m.widthContent). Width(m.widthContent).
Padding(2, 0) Padding(2, 0)
@@ -49,23 +48,17 @@ func (m Model) generateCartContent() string {
for idx, item := range itemSlice { for idx, item := range itemSlice {
itemTotal := item.Quantity * item.Coffee.Price itemTotal := item.Quantity * item.Coffee.Price
nameStyle := lipgloss.NewStyle(). nameStyle := m.theme.TextAccent().Bold(true)
Bold(true).
Foreground(lipgloss.Color("#FFFFFF"))
individualPriceStyle := lipgloss.NewStyle(). individualPriceStyle := lipgloss.NewStyle().
Foreground(lipgloss.Color(item.Coffee.Color)). Foreground(lipgloss.Color(item.Coffee.Color)).
Bold(true) Bold(true)
infoStyle := lipgloss.NewStyle(). infoStyle := m.theme.TextBody()
Foreground(lipgloss.Color("#AAAAAA"))
quantityStyle := lipgloss.NewStyle(). quantityStyle := m.theme.TextAccent()
Foreground(lipgloss.Color("#FFFFFF"))
totalPriceStyle := lipgloss.NewStyle(). totalPriceStyle := m.theme.TextAccent().Bold(true)
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true)
nameText := nameStyle.Render(item.Coffee.Name) nameText := nameStyle.Render(item.Coffee.Name)
individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", float64(item.Coffee.Price)/100)) individualPriceText := individualPriceStyle.Render(fmt.Sprintf(" $%.2f", float64(item.Coffee.Price)/100))
@@ -97,18 +90,18 @@ func (m Model) generateCartContent() string {
if idx == m.CartCursor { if idx == m.CartCursor {
itemBox = lipgloss.NewStyle(). itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder(), true). Border(lipgloss.NormalBorder(), true).
BorderForeground(lipgloss.Color("#FFFFFF")). BorderForeground(m.theme.Accent()).
Padding(boxPadding, 2). Padding(boxPadding, 2).
Width(boxWidth). Width(boxWidth).
Foreground(lipgloss.Color("#FFFFFF")). Foreground(m.theme.Accent()).
Bold(true) Bold(true)
} else { } else {
itemBox = lipgloss.NewStyle(). itemBox = lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#666666")). BorderForeground(m.theme.Border()).
Padding(boxPadding, 2). Padding(boxPadding, 2).
Width(boxWidth). Width(boxWidth).
Foreground(lipgloss.Color("#FFFFFF")) Foreground(m.theme.Accent())
} }
centered := lipgloss.NewStyle(). centered := lipgloss.NewStyle().

View File

@@ -3,8 +3,6 @@ package tui
import ( import (
"fmt" "fmt"
"terminalShop/pkg/models"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss" "github.com/charmbracelet/lipgloss"
@@ -12,52 +10,42 @@ import (
// RenderConfirmation renders the order confirmation view // RenderConfirmation renders the order confirmation view
func (m Model) RenderConfirmation() string { func (m Model) RenderConfirmation() string {
titleStyle := lipgloss.NewStyle(). titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 0, 1, 0)
sectionStyle := lipgloss.NewStyle(). sectionStyle := m.theme.TextBody().Padding(0, 0, 1, 2)
Foreground(lipgloss.Color("#CCCCCC")).
Padding(0, 0, 1, 2)
labelStyle := lipgloss.NewStyle(). labelStyle := m.theme.TextDim()
Foreground(lipgloss.Color("#666666"))
valueStyle := lipgloss.NewStyle(). valueStyle := m.theme.TextAccent()
Foreground(lipgloss.Color("#FFFFFF"))
successStyle := lipgloss.NewStyle(). successStyle := m.theme.TextSuccess().Bold(true).Padding(1, 0)
Foreground(lipgloss.Color("#00FF00")).
Bold(true).
Padding(1, 0)
title := titleStyle.Render("Order Confirmation") title := titleStyle.Render("Order Confirmation")
// Shipping summary // Shipping summary
shippingSection := "" shippingSection := ""
if m.ShippingInfo != nil { if m.ConfirmShipping != nil {
shippingSection = sectionStyle.Render( shippingSection = sectionStyle.Render(
labelStyle.Render("Ship to: ") + labelStyle.Render("Ship to: ") +
valueStyle.Render(m.ShippingInfo.Name) + "\n" + valueStyle.Render(m.ConfirmShipping.Name) + "\n" +
labelStyle.Render(" ") + labelStyle.Render(" ") +
valueStyle.Render(m.ShippingInfo.Street) + "\n" + valueStyle.Render(m.ConfirmShipping.Street) + "\n" +
labelStyle.Render(" ") + labelStyle.Render(" ") +
valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ShippingInfo.City, m.ShippingInfo.State, m.ShippingInfo.Zip, m.ShippingInfo.Country)), valueStyle.Render(fmt.Sprintf("%s, %s %s, %s", m.ConfirmShipping.City, m.ConfirmShipping.State, m.ConfirmShipping.Zip, m.ConfirmShipping.Country)),
) )
} }
// Cart summary // Items summary
cartSection := "" cartSection := ""
total := 0 for _, item := range m.ConfirmItems {
items := m.GetCartItemsSlice() line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price*item.Quantity)/100)
for _, item := range items {
line := fmt.Sprintf(" %s x%d $%.2f", item.Coffee.Name, item.Quantity, float64(item.Coffee.Price * item.Quantity)/100)
cartSection += sectionStyle.Render(line) + "\n" cartSection += sectionStyle.Render(line) + "\n"
total += item.Coffee.Price * item.Quantity
} }
cartSection += sectionStyle.Render( cartSection += sectionStyle.Render(
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(total)/100)), labelStyle.Render("Shipping: ") + valueStyle.Render("$0.00"),
) + "\n"
cartSection += sectionStyle.Render(
labelStyle.Render("Total: ") + valueStyle.Render(fmt.Sprintf("$%.2f", float64(m.ConfirmTotal)/100)),
) )
success := successStyle.Render("Order placed successfully!") success := successStyle.Render("Order placed successfully!")
@@ -122,10 +110,10 @@ func (m Model) ConfirmUpdate(msg tea.Msg) (Model, tea.Cmd) {
} }
m = m.SwitchPage(shopPage) m = m.SwitchPage(shopPage)
m.ShippingInfo = nil
m.CheckingOut = false m.CheckingOut = false
m.Cart = make(map[uint]*models.CartItem) m.ConfirmTotal = 0
m.CartCursor = 0 m.ConfirmItems = nil
m.ConfirmShipping = nil
m = m.resetPageState() m = m.resetPageState()
return m, func() tea.Msg { return m, func() tea.Msg {
if m.APIClient != nil { if m.APIClient != nil {

View File

@@ -8,13 +8,10 @@ import (
func (m Model) BuildFooter() string { func (m Model) BuildFooter() string {
// Style for keybinds (bold) // Style for keybinds (bold)
keybindStyle := lipgloss.NewStyle(). keybindStyle := m.theme.TextDim().Bold(true)
Foreground(lipgloss.Color("#666666")).
Bold(true)
// Style for descriptions (normal) // Style for descriptions (normal)
descStyle := lipgloss.NewStyle(). descStyle := m.theme.TextDim()
Foreground(lipgloss.Color("#666666"))
// On small terminals, only show the menu hint // On small terminals, only show the menu hint
if m.size == small { if m.size == small {

View File

@@ -16,9 +16,9 @@ func (m Model) BuildHeader() string {
} }
// Define colors // Define colors
grayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) grayStyle := m.theme.TextBody()
boldWhiteStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) boldWhiteStyle := m.theme.TextAccent().Bold(true)
boldGrayStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true) boldGrayStyle := m.theme.TextBody().Bold(true)
// Build cart tab (used in all sizes) // Build cart tab (used in all sizes)
itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount)) itemCountText := grayStyle.Render(fmt.Sprintf("[%d]", itemCount))
@@ -51,10 +51,10 @@ func (m Model) BuildHeader() string {
case medium: case medium:
menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu") menuTab := boldGrayStyle.Render("m") + " " + grayStyle.Render("menu")
logo := boldWhiteStyle.Render("terminal coffee") logo := boldWhiteStyle.Render("terminal coffee")
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|") separator := m.theme.TextAccent().Render("|")
tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab tabsContent = menuTab + " " + separator + " " + logo + " " + separator + " " + cartTab
default: default:
separator := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Render("|") separator := m.theme.TextAccent().Render("|")
var shopKeybind, shopName string var shopKeybind, shopName string
if m.currentPage == shopPage { if m.currentPage == shopPage {

View File

@@ -6,9 +6,9 @@ import (
) )
func (m Model) BuildHelpView() string { func (m Model) BuildHelpView() string {
bold := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) bold := m.theme.TextAccent().Bold(true)
base := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) base := m.theme.TextBody()
header := lipgloss.NewStyle().Foreground(lipgloss.Color("#4682B4")).Bold(true) header := m.theme.TextHighlight().Bold(true)
rowStyle := lipgloss.NewStyle().Padding(0, 1) rowStyle := lipgloss.NewStyle().Padding(0, 1)
sections := []struct { sections := []struct {
@@ -76,12 +76,12 @@ func (m Model) BuildHelpView() string {
} }
} }
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) logoStyle := m.theme.TextAccent().Bold(true)
logo := logoStyle.Render("keybindings") logo := logoStyle.Render("keybindings")
modal := lipgloss.NewStyle(). modal := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
BorderForeground(lipgloss.Color("#666666")). BorderForeground(m.theme.Border()).
Padding(1, 2). Padding(1, 2).
Render(content) Render(content)

View File

@@ -8,9 +8,9 @@ import (
func (m Model) BuildMenuView() string { func (m Model) BuildMenuView() string {
bold := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) bold := m.theme.TextAccent().Bold(true)
base := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")) base := m.theme.TextBody()
rowStyle := lipgloss.NewStyle().Padding(0,1) rowStyle := lipgloss.NewStyle().Padding(0,1)
rows := []string{ rows := []string{
@@ -28,10 +28,10 @@ func (m Model) BuildMenuView() string {
} }
logoStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) logoStyle := m.theme.TextAccent().Bold(true)
logo := logoStyle.Render("terminal coffee") logo := logoStyle.Render("terminal coffee")
modal := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(lipgloss.Color("#666666")).Padding(1,2).Render(menuContent) modal := lipgloss.NewStyle().Border(lipgloss.NormalBorder()).BorderForeground(m.theme.Border()).Padding(1, 2).Render(menuContent)
hint := base.Render("press esc to close") hint := base.Render("press esc to close")

View File

@@ -9,11 +9,13 @@ import (
"github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/viewport" "github.com/charmbracelet/bubbles/viewport"
tea "github.com/charmbracelet/bubbletea" tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
"github.com/stripe/stripe-go/v78" "github.com/stripe/stripe-go/v78"
stripetoken "github.com/stripe/stripe-go/v78/token" stripetoken "github.com/stripe/stripe-go/v78/token"
"terminalShop/pkg/api" "terminalShop/pkg/api"
"terminalShop/pkg/models" "terminalShop/pkg/models"
"terminalShop/pkg/tui/theme"
) )
// termSize represents the terminal size category for responsive layout. // termSize represents the terminal size category for responsive layout.
@@ -101,6 +103,10 @@ type Model struct {
ErrorMsg string // error message if API fetch fails ErrorMsg string // error message if API fetch fails
APIClient *api.Client APIClient *api.Client
// Rendering
renderer *lipgloss.Renderer
theme theme.Theme
// Checkout state // Checkout state
ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step) ShippingForm *ShippingFormState // Shipping form state (nil when not in shipping step)
PaymentForm *PaymentFormState // Payment form state (nil when not in payment step) PaymentForm *PaymentFormState // Payment form state (nil when not in payment step)
@@ -130,6 +136,11 @@ type Model struct {
splashDelayDone bool // true when minimum display time has elapsed splashDelayDone bool // true when minimum display time has elapsed
splashCursor bool // toggles for blinking cursor animation splashCursor bool // toggles for blinking cursor animation
// Confirmation screen — snapshot populated on checkout, cleared when leaving confirm page
ConfirmTotal int // server-confirmed order total in cents
ConfirmItems []models.CartItem // cart items at time of purchase
ConfirmShipping *models.Address // shipping address at time of purchase
} }
type shopState struct { type shopState struct {
@@ -610,11 +621,18 @@ func (m Model) GetCartItemsSlice() []*models.CartItem {
return items return items
} }
// NewModel creates a new model with default coffee options // NewModel creates a new model using the default renderer.
func NewModel(username string) Model { func NewModel(username string) Model {
return newModelWithRenderer(username, lipgloss.DefaultRenderer())
}
// newModelWithRenderer creates a new model with the given renderer.
func newModelWithRenderer(username string, renderer *lipgloss.Renderer) Model {
m := Model{ m := Model{
Username: username, Username: username,
currentPage: splashPage, currentPage: splashPage,
renderer: renderer,
theme: theme.BasicTheme(renderer),
Coffees: []models.Coffee{ Coffees: []models.Coffee{
{ {
Name: "Espresso", Name: "Espresso",
@@ -684,9 +702,9 @@ func NewModel(username string) Model {
return m return m
} }
// NewModelWithAuth creates a new model with user authentication context // NewModelWithAuth creates a new model with user authentication context.
func NewModelWithAuth(fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model { func NewModelWithAuth(renderer *lipgloss.Renderer, fingerprint string, pubKeyStr string, apiURL string, clientSecret string, stripePublicKey string) Model {
m := NewModel("") m := newModelWithRenderer("", renderer)
m.Fingerprint = fingerprint m.Fingerprint = fingerprint
m.SSHPublicKeyStr = pubKeyStr m.SSHPublicKeyStr = pubKeyStr
m.AuthFingerprintKey = clientSecret m.AuthFingerprintKey = clientSecret

View File

@@ -99,7 +99,8 @@ func (m Model) buildPaymentForm(state *PaymentFormState) *huh.Form {
), ),
). ).
WithShowErrors(false). WithShowErrors(false).
WithShowHelp(false) WithShowHelp(false).
WithTheme(m.theme.Form())
if m.size < medium { if m.size < medium {
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent) f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
@@ -236,17 +237,10 @@ func (m Model) UpdatePaymentForm(msg tea.Msg, state *PaymentFormState) tea.Cmd {
// RenderPaymentForm renders the payment form view // RenderPaymentForm renders the payment form view
func (m Model) RenderPaymentForm(state *PaymentFormState) string { func (m Model) RenderPaymentForm(state *PaymentFormState) string {
if state.submitting { if state.submitting {
loadingStyle := lipgloss.NewStyle(). return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing payment...")
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(1)
return loadingStyle.Render("Processing payment...")
} }
titleStyle := lipgloss.NewStyle(). titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 0, 1, 0)
cardLast4 := "" cardLast4 := ""
if len(state.CardNumber) >= 4 { if len(state.CardNumber) >= 4 {
@@ -330,10 +324,10 @@ func (m Model) PaymentPageView() string {
} }
func (m Model) RenderCardList() string { func (m Model) RenderCardList() string {
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0) titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) activeStyle := m.theme.TextAccent().Bold(true)
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) inactiveStyle := m.theme.TextDim()
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) labelStyle := m.theme.TextLabel()
title := titleStyle.Render("Select Payment Method") title := titleStyle.Render("Select Payment Method")
@@ -378,8 +372,8 @@ func (m Model) RenderCardList() string {
} }
func (m Model) renderPaymentHttpsView() string { func (m Model) renderPaymentHttpsView() string {
baseStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) baseStyle := m.theme.TextLabel()
accentStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#4682B4")) accentStyle := m.theme.TextHighlight()
if m.CollectURL == nil { if m.CollectURL == nil {
return baseStyle.Render(" generating payment link...") return baseStyle.Render(" generating payment link...")
@@ -436,11 +430,19 @@ func (m Model) PaymentUpdate(msg tea.Msg) (Model, tea.Cmd) {
m.PaymentForm = m.InitPaymentForm() m.PaymentForm = m.InitPaymentForm()
return m, m.PaymentForm.form.Init() return m, m.PaymentForm.form.Init()
} }
m = m.SwitchPage(confirmPage) // Snapshot order details before clearing cart/shipping
m.OrdersLoaded = false m.ConfirmTotal = msg.Total
for _, item := range m.GetCartItemsSlice() {
m.ConfirmItems = append(m.ConfirmItems, *item)
}
m.ConfirmShipping = m.ShippingInfo
// Clear cart and shipping immediately so they don't appear stale elsewhere
m.Cart = make(map[uint]*models.CartItem) m.Cart = make(map[uint]*models.CartItem)
m.CartCursor = 0 m.CartCursor = 0
m.ShippingInfo = nil m.ShippingInfo = nil
m.OrdersLoaded = false
m = m.SwitchPage(confirmPage)
return m, nil return m, nil
case PaymentFormErrorMsg: case PaymentFormErrorMsg:

View File

@@ -121,7 +121,8 @@ func (m Model) buildShippingForm(state *ShippingFormState) *huh.Form {
), ),
). ).
WithShowErrors(false). WithShowErrors(false).
WithShowHelp(false) WithShowHelp(false).
WithTheme(m.theme.Form())
if m.size < medium { if m.size < medium {
f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent) f = f.WithLayout(huh.LayoutStack).WithWidth(m.widthContent)
@@ -241,19 +242,10 @@ func (m Model) UpdateShippingForm(msg tea.Msg, state *ShippingFormState) tea.Cmd
// RenderShippingForm renders the shipping form view // RenderShippingForm renders the shipping form view
func (m Model) RenderShippingForm(state *ShippingFormState) string { func (m Model) RenderShippingForm(state *ShippingFormState) string {
if state.submitting { if state.submitting {
loadingStyle := lipgloss.NewStyle(). return m.theme.TextAccent().Bold(true).Padding(1).Render("Processing shipping information...")
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(1)
return loadingStyle.Render("Processing shipping information...")
} }
titleStyle := lipgloss.NewStyle(). title := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0).Render("Shipping Address")
Foreground(lipgloss.Color("#FFFFFF")).
Bold(true).
Padding(0, 0, 1, 0)
title := titleStyle.Render("Shipping Address")
form := state.form.View() form := state.form.View()
return lipgloss.JoinVertical( return lipgloss.JoinVertical(
@@ -312,10 +304,10 @@ func (m Model) ShippingPageView() string {
} }
func (m Model) RenderAddressList() string { func (m Model) RenderAddressList() string {
titleStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true).Padding(0, 0, 1, 0) titleStyle := m.theme.TextAccent().Bold(true).Padding(0, 0, 1, 0)
activeStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Bold(true) activeStyle := m.theme.TextAccent().Bold(true)
inactiveStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#666666")) inactiveStyle := m.theme.TextDim()
labelStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("#999999")) labelStyle := m.theme.TextLabel()
title := titleStyle.Render("Selected Shipping Address") title := titleStyle.Render("Selected Shipping Address")

View File

@@ -66,7 +66,7 @@ func (m Model) updateShopViewports() Model {
func (m Model) getShopMenuContent() string { func (m Model) getShopMenuContent() string {
menuWidth, _ := m.calculateShopWidths() menuWidth, _ := m.calculateShopWidths()
selectedColor := lipgloss.Color("#4682B4") selectedColor := m.theme.Highlight()
if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) { if m.shop.selected >= 0 && m.shop.selected < len(m.Coffees) {
if c := m.Coffees[m.shop.selected].Color; c != "" { if c := m.Coffees[m.shop.selected].Color; c != "" {
selectedColor = lipgloss.Color(c) selectedColor = lipgloss.Color(c)
@@ -76,19 +76,17 @@ func (m Model) getShopMenuContent() string {
var normal, highlighted lipgloss.Style var normal, highlighted lipgloss.Style
if m.size < large { if m.size < large {
menuWidth = m.widthContent menuWidth = m.widthContent
normal = lipgloss.NewStyle(). normal = m.theme.TextBody().
Width(menuWidth - 1).Align(lipgloss.Center)
highlighted = m.theme.TextAccent().
Width(menuWidth - 1).Align(lipgloss.Center). Width(menuWidth - 1).Align(lipgloss.Center).
Foreground(lipgloss.Color("#AAAAAA")) Background(selectedColor).Bold(true)
highlighted = lipgloss.NewStyle().
Width(menuWidth - 1).Align(lipgloss.Center).
Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
} else { } else {
normal = lipgloss.NewStyle(). normal = m.theme.TextBody().
Width(menuWidth + 2).Padding(0, 1)
highlighted = m.theme.TextAccent().
Width(menuWidth + 2).Padding(0, 1). Width(menuWidth + 2).Padding(0, 1).
Foreground(lipgloss.Color("#AAAAAA")) Background(selectedColor).Bold(true)
highlighted = lipgloss.NewStyle().
Width(menuWidth + 2).Padding(0, 1).
Background(selectedColor).Foreground(lipgloss.Color("#FFFFFF")).Bold(true)
} }
var b strings.Builder var b strings.Builder
@@ -118,8 +116,8 @@ func (m Model) getShopDetailContent() string {
} }
detail := lipgloss.JoinVertical(lipgloss.Left, detail := lipgloss.JoinVertical(lipgloss.Left,
lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FFFFFF")).Render(coffee.Name), m.theme.TextAccent().Bold(true).Render(coffee.Name),
lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Render( m.theme.TextBody().Render(
fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType), fmt.Sprintf("%s | %doz | %s", coffee.RoastType, coffee.Ounces, coffee.BeanType),
), ),
"", "",
@@ -127,7 +125,7 @@ func (m Model) getShopDetailContent() string {
fmt.Sprintf("$%.2f", float64(coffee.Price)/100), fmt.Sprintf("$%.2f", float64(coffee.Price)/100),
), ),
"", "",
lipgloss.NewStyle().Foreground(lipgloss.Color("#FFFFFF")).Width(detailWidth).Render(coffee.Description), m.theme.TextAccent().Width(detailWidth).Render(coffee.Description),
"", "",
fmt.Sprintf("- %d +", quantity), fmt.Sprintf("- %d +", quantity),
) )

View File

@@ -45,11 +45,11 @@ func (m Model) splashViewInitCmd() tea.Msg {
} }
func (m Model) SplashView() string { func (m Model) SplashView() string {
accent := lipgloss.NewStyle().Foreground(lipgloss.Color("#AAAAAA")).Bold(true) accent := m.theme.TextBody().Bold(true)
var cursor string var cursor string
if m.splashCursor { if m.splashCursor {
cursor = lipgloss.NewStyle().Background(lipgloss.Color("#4682B4")).Render(" ") cursor = lipgloss.NewStyle().Background(m.theme.Highlight()).Render(" ")
} else { } else {
cursor = " " cursor = " "
} }

39
pkg/tui/theme/huh.go Normal file
View File

@@ -0,0 +1,39 @@
package theme
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// HuhTheme creates a huh form theme using this project's color palette.
func HuhTheme(t Theme) *huh.Theme {
var th huh.Theme
th.FieldSeparator = t.renderer.NewStyle().SetString("\n\n")
f := &th.Focused
f.Base = t.renderer.NewStyle().
PaddingLeft(1).
BorderStyle(lipgloss.ThickBorder()).
BorderLeft(true).
BorderForeground(t.accent)
f.Title = t.renderer.NewStyle().Foreground(t.body)
f.Description = t.renderer.NewStyle().Foreground(t.body)
f.TextInput.Cursor = t.renderer.NewStyle().Foreground(t.highlight)
f.TextInput.Placeholder = t.renderer.NewStyle().Foreground(t.dim)
f.TextInput.Prompt = t.renderer.NewStyle().Foreground(t.accent)
f.TextInput.Text = t.renderer.NewStyle().Foreground(t.accent)
f.ErrorIndicator = t.renderer.NewStyle().Foreground(t.error)
f.ErrorMessage = t.renderer.NewStyle().Foreground(t.error)
th.Help = help.New().Styles
th.Blurred = copyFieldStyles(*f)
th.Blurred.Base = th.Blurred.Base.BorderStyle(lipgloss.HiddenBorder())
th.Blurred.Title = th.Blurred.Title.Foreground(t.body)
return &th
}
// copyFieldStyles returns a copy of FieldStyles (value type, assignment is a copy).
func copyFieldStyles(f huh.FieldStyles) huh.FieldStyles { return f }

79
pkg/tui/theme/theme.go Normal file
View File

@@ -0,0 +1,79 @@
package theme
import (
"github.com/charmbracelet/huh"
"github.com/charmbracelet/lipgloss"
)
// Theme holds all colors and styles for the terminal coffee shop TUI.
type Theme struct {
renderer *lipgloss.Renderer
background lipgloss.TerminalColor
border lipgloss.TerminalColor
body lipgloss.TerminalColor
dim lipgloss.TerminalColor
accent lipgloss.TerminalColor
highlight lipgloss.TerminalColor
brand lipgloss.TerminalColor
label lipgloss.TerminalColor
success lipgloss.TerminalColor
error lipgloss.TerminalColor
base lipgloss.Style
form *huh.Theme
}
// BasicTheme constructs the default theme for terminal coffee shop.
func BasicTheme(renderer *lipgloss.Renderer) Theme {
t := Theme{renderer: renderer}
t.background = lipgloss.AdaptiveColor{Dark: "#000000", Light: "#FBFCFD"}
t.border = lipgloss.AdaptiveColor{Dark: "#666666", Light: "#D7DBDF"}
t.body = lipgloss.Color("#AAAAAA")
t.dim = lipgloss.Color("#666666")
t.accent = lipgloss.AdaptiveColor{Dark: "#FFFFFF", Light: "#11181C"}
t.highlight = lipgloss.Color("#4682B4")
t.brand = lipgloss.Color("#4682B4")
t.label = lipgloss.Color("#999999")
t.success = lipgloss.Color("#00FF00")
t.error = lipgloss.Color("196")
t.base = renderer.NewStyle().Foreground(t.body)
t.form = HuhTheme(t)
return t
}
// Color accessors — use in .Foreground() / .Background() calls.
func (t Theme) Background() lipgloss.TerminalColor { return t.background }
func (t Theme) Border() lipgloss.TerminalColor { return t.border }
func (t Theme) Body() lipgloss.TerminalColor { return t.body }
func (t Theme) Dim() lipgloss.TerminalColor { return t.dim }
func (t Theme) Accent() lipgloss.TerminalColor { return t.accent }
func (t Theme) Highlight() lipgloss.TerminalColor { return t.highlight }
func (t Theme) Brand() lipgloss.TerminalColor { return t.brand }
func (t Theme) Label() lipgloss.TerminalColor { return t.label }
func (t Theme) Success() lipgloss.TerminalColor { return t.success }
func (t Theme) Error() lipgloss.TerminalColor { return t.error }
// Style helpers — return a copy of base with the correct foreground.
// Chain .Bold(), .Width(), .Padding() etc. on the returned style.
func (t Theme) Base() lipgloss.Style { return t.base }
func (t Theme) TextBody() lipgloss.Style { return t.Base().Foreground(t.body) }
func (t Theme) TextAccent() lipgloss.Style { return t.Base().Foreground(t.accent) }
func (t Theme) TextDim() lipgloss.Style { return t.Base().Foreground(t.dim) }
func (t Theme) TextHighlight() lipgloss.Style { return t.Base().Foreground(t.highlight) }
func (t Theme) TextBrand() lipgloss.Style { return t.Base().Foreground(t.brand) }
func (t Theme) TextLabel() lipgloss.Style { return t.Base().Foreground(t.label) }
func (t Theme) TextSuccess() lipgloss.Style { return t.Base().Foreground(t.success) }
func (t Theme) TextError() lipgloss.Style { return t.Base().Foreground(t.error) }
func (t Theme) TextLoading() lipgloss.Style { return t.Base().Foreground(lipgloss.Color("205")) }
// PanelError returns a style for the error banner: dark red bg, red fg.
func (t Theme) PanelError() lipgloss.Style {
return t.Base().Background(lipgloss.Color("52")).Foreground(lipgloss.Color("196"))
}
// Form returns the huh form theme for shipping and payment forms.
func (t Theme) Form() *huh.Theme { return t.form }

View File

@@ -8,8 +8,7 @@ import (
// ResizeView renders a message when the terminal is too small. // ResizeView renders a message when the terminal is too small.
func (m Model) ResizeView() string { func (m Model) ResizeView() string {
msg := lipgloss.NewStyle(). msg := m.theme.TextDim().
Foreground(lipgloss.Color("#666666")).
Align(lipgloss.Center). Align(lipgloss.Center).
Render("Terminal too small.\nPlease resize.") Render("Terminal too small.\nPlease resize.")
@@ -74,10 +73,10 @@ func (m Model) View() string {
var content string var content string
if m.Loading { if m.Loading {
loadingStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("205")).Padding(2, 4) loadingStyle := m.theme.TextLoading().Padding(2, 4)
content = loadingStyle.Render("Loading products from API...") content = loadingStyle.Render("Loading products from API...")
} else if m.ErrorMsg != "" { } else if m.ErrorMsg != "" {
errorStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("196")).Background(lipgloss.Color("52")).Padding(0, 1).MarginBottom(1) errorStyle := m.theme.PanelError().Padding(0, 1).MarginBottom(1)
errorBanner := errorStyle.Render(m.ErrorMsg) errorBanner := errorStyle.Render(m.ErrorMsg)
content = errorBanner + "\n" + m.buildPageContent(availableContentHeight) content = errorBanner + "\n" + m.buildPageContent(availableContentHeight)
} else { } else {