feat(ui): import comprehensive UX system from beads
Import beads' UX design system into gastown: - Add internal/ui/ package with Ayu theme colors and semantic styling - styles.go: AdaptiveColor definitions for light/dark mode - terminal.go: TTY detection, NO_COLOR/CLICOLOR support - markdown.go: Glamour rendering with agent mode bypass - pager.go: Smart paging with GT_PAGER support - Add colorized help output (internal/cmd/help.go) - Group headers in accent color - Command names styled for scannability - Flag types and defaults muted - Add gt thanks command (internal/cmd/thanks.go) - Contributor display with same logic as bd thanks - Styled with Ayu theme colors - Update gt doctor to match bd doctor UX - Category grouping (Core, Infrastructure, Rig, Patrol, etc.) - Semantic icons (✓ ⚠ ✖) with Ayu colors - Tree connectors for detail lines - Summary line with pass/warn/fail counts - Warnings section at end with numbered issues - Migrate existing styles to use ui package - internal/style/style.go uses ui.ColorPass etc. - internal/tui/feed/styles.go uses ui package colors Co-Authored-By: SageOx <ox@sageox.ai>
This commit is contained in:
486
internal/ui/styles.go
Normal file
486
internal/ui/styles.go
Normal file
@@ -0,0 +1,486 @@
|
||||
// Package ui provides terminal styling for gastown CLI output.
|
||||
// Uses the Ayu color theme with adaptive light/dark mode support.
|
||||
// Design philosophy: semantic colors that communicate meaning at a glance,
|
||||
// minimal visual noise, and consistent rendering across all commands.
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
"github.com/muesli/termenv"
|
||||
)
|
||||
|
||||
func init() {
|
||||
if !ShouldUseColor() {
|
||||
// disable colors when not appropriate (non-TTY, NO_COLOR, etc.)
|
||||
lipgloss.SetColorProfile(termenv.Ascii)
|
||||
} else {
|
||||
// use TrueColor for distinct priority/status colors in modern terminals
|
||||
lipgloss.SetColorProfile(termenv.TrueColor)
|
||||
}
|
||||
}
|
||||
|
||||
// Ayu theme color palette
|
||||
// Dark: https://terminalcolors.com/themes/ayu/dark/
|
||||
// Light: https://terminalcolors.com/themes/ayu/light/
|
||||
// Source: https://github.com/ayu-theme/ayu-colors
|
||||
var (
|
||||
// Core semantic colors (Ayu theme - adaptive light/dark)
|
||||
ColorPass = lipgloss.AdaptiveColor{
|
||||
Light: "#86b300", // ayu light bright green
|
||||
Dark: "#c2d94c", // ayu dark bright green
|
||||
}
|
||||
ColorWarn = lipgloss.AdaptiveColor{
|
||||
Light: "#f2ae49", // ayu light bright yellow
|
||||
Dark: "#ffb454", // ayu dark bright yellow
|
||||
}
|
||||
ColorFail = lipgloss.AdaptiveColor{
|
||||
Light: "#f07171", // ayu light bright red
|
||||
Dark: "#f07178", // ayu dark bright red
|
||||
}
|
||||
ColorMuted = lipgloss.AdaptiveColor{
|
||||
Light: "#828c99", // ayu light muted
|
||||
Dark: "#6c7680", // ayu dark muted
|
||||
}
|
||||
ColorAccent = lipgloss.AdaptiveColor{
|
||||
Light: "#399ee6", // ayu light bright blue
|
||||
Dark: "#59c2ff", // ayu dark bright blue
|
||||
}
|
||||
|
||||
// === Workflow Status Colors ===
|
||||
// Only actionable states get color - open/closed match standard text
|
||||
ColorStatusOpen = lipgloss.AdaptiveColor{
|
||||
Light: "", // standard text color
|
||||
Dark: "",
|
||||
}
|
||||
ColorStatusInProgress = lipgloss.AdaptiveColor{
|
||||
Light: "#f2ae49", // yellow - active work, very visible
|
||||
Dark: "#ffb454",
|
||||
}
|
||||
ColorStatusClosed = lipgloss.AdaptiveColor{
|
||||
Light: "#9099a1", // slightly dimmed - visually shows "done"
|
||||
Dark: "#8090a0",
|
||||
}
|
||||
ColorStatusBlocked = lipgloss.AdaptiveColor{
|
||||
Light: "#f07171", // red - needs attention
|
||||
Dark: "#f26d78",
|
||||
}
|
||||
ColorStatusPinned = lipgloss.AdaptiveColor{
|
||||
Light: "#d2a6ff", // purple - special/elevated
|
||||
Dark: "#d2a6ff",
|
||||
}
|
||||
ColorStatusHooked = lipgloss.AdaptiveColor{
|
||||
Light: "#59c2ff", // cyan - actively worked by agent
|
||||
Dark: "#59c2ff",
|
||||
}
|
||||
|
||||
// === Priority Colors ===
|
||||
// P0/P1/P2 get color - they need attention
|
||||
// P3/P4 are neutral (low/backlog don't need visual urgency)
|
||||
ColorPriorityP0 = lipgloss.AdaptiveColor{
|
||||
Light: "#f07171", // bright red - critical, demands attention
|
||||
Dark: "#f07178",
|
||||
}
|
||||
ColorPriorityP1 = lipgloss.AdaptiveColor{
|
||||
Light: "#ff8f40", // orange - high priority, needs attention soon
|
||||
Dark: "#ff8f40",
|
||||
}
|
||||
ColorPriorityP2 = lipgloss.AdaptiveColor{
|
||||
Light: "#e6b450", // muted gold - medium priority, visible but calm
|
||||
Dark: "#e6b450",
|
||||
}
|
||||
ColorPriorityP3 = lipgloss.AdaptiveColor{
|
||||
Light: "", // neutral - low priority
|
||||
Dark: "",
|
||||
}
|
||||
ColorPriorityP4 = lipgloss.AdaptiveColor{
|
||||
Light: "", // neutral - backlog
|
||||
Dark: "",
|
||||
}
|
||||
|
||||
// === Issue Type Colors ===
|
||||
// Bugs and epics get color - they need attention
|
||||
// All other types use standard text
|
||||
ColorTypeBug = lipgloss.AdaptiveColor{
|
||||
Light: "#f07171", // bright red - bugs are problems
|
||||
Dark: "#f26d78",
|
||||
}
|
||||
ColorTypeFeature = lipgloss.AdaptiveColor{
|
||||
Light: "", // standard text color
|
||||
Dark: "",
|
||||
}
|
||||
ColorTypeTask = lipgloss.AdaptiveColor{
|
||||
Light: "", // standard text color
|
||||
Dark: "",
|
||||
}
|
||||
ColorTypeEpic = lipgloss.AdaptiveColor{
|
||||
Light: "#d2a6ff", // purple - larger scope work
|
||||
Dark: "#d2a6ff",
|
||||
}
|
||||
ColorTypeChore = lipgloss.AdaptiveColor{
|
||||
Light: "", // standard text color
|
||||
Dark: "",
|
||||
}
|
||||
|
||||
// === Issue ID Color ===
|
||||
// IDs use standard text color - subtle, not attention-grabbing
|
||||
ColorID = lipgloss.AdaptiveColor{
|
||||
Light: "", // standard text color
|
||||
Dark: "",
|
||||
}
|
||||
)
|
||||
|
||||
// Core styles - consistent across all commands
|
||||
var (
|
||||
PassStyle = lipgloss.NewStyle().Foreground(ColorPass)
|
||||
WarnStyle = lipgloss.NewStyle().Foreground(ColorWarn)
|
||||
FailStyle = lipgloss.NewStyle().Foreground(ColorFail)
|
||||
MutedStyle = lipgloss.NewStyle().Foreground(ColorMuted)
|
||||
AccentStyle = lipgloss.NewStyle().Foreground(ColorAccent)
|
||||
)
|
||||
|
||||
// Issue ID style
|
||||
var IDStyle = lipgloss.NewStyle().Foreground(ColorID)
|
||||
|
||||
// Status styles for workflow states
|
||||
var (
|
||||
StatusOpenStyle = lipgloss.NewStyle().Foreground(ColorStatusOpen)
|
||||
StatusInProgressStyle = lipgloss.NewStyle().Foreground(ColorStatusInProgress)
|
||||
StatusClosedStyle = lipgloss.NewStyle().Foreground(ColorStatusClosed)
|
||||
StatusBlockedStyle = lipgloss.NewStyle().Foreground(ColorStatusBlocked)
|
||||
StatusPinnedStyle = lipgloss.NewStyle().Foreground(ColorStatusPinned)
|
||||
StatusHookedStyle = lipgloss.NewStyle().Foreground(ColorStatusHooked)
|
||||
)
|
||||
|
||||
// Priority styles - P0 is bold for extra emphasis
|
||||
var (
|
||||
PriorityP0Style = lipgloss.NewStyle().Foreground(ColorPriorityP0).Bold(true)
|
||||
PriorityP1Style = lipgloss.NewStyle().Foreground(ColorPriorityP1)
|
||||
PriorityP2Style = lipgloss.NewStyle().Foreground(ColorPriorityP2)
|
||||
PriorityP3Style = lipgloss.NewStyle().Foreground(ColorPriorityP3)
|
||||
PriorityP4Style = lipgloss.NewStyle().Foreground(ColorPriorityP4)
|
||||
)
|
||||
|
||||
// Type styles for issue categories
|
||||
var (
|
||||
TypeBugStyle = lipgloss.NewStyle().Foreground(ColorTypeBug)
|
||||
TypeFeatureStyle = lipgloss.NewStyle().Foreground(ColorTypeFeature)
|
||||
TypeTaskStyle = lipgloss.NewStyle().Foreground(ColorTypeTask)
|
||||
TypeEpicStyle = lipgloss.NewStyle().Foreground(ColorTypeEpic)
|
||||
TypeChoreStyle = lipgloss.NewStyle().Foreground(ColorTypeChore)
|
||||
)
|
||||
|
||||
// CategoryStyle for section headers - bold with accent color
|
||||
var CategoryStyle = lipgloss.NewStyle().Bold(true).Foreground(ColorAccent)
|
||||
|
||||
// BoldStyle for emphasis
|
||||
var BoldStyle = lipgloss.NewStyle().Bold(true)
|
||||
|
||||
// CommandStyle for command names - subtle contrast, not attention-grabbing
|
||||
var CommandStyle = lipgloss.NewStyle().Foreground(lipgloss.AdaptiveColor{
|
||||
Light: "#5c6166", // slightly darker than standard
|
||||
Dark: "#bfbdb6", // slightly brighter than standard
|
||||
})
|
||||
|
||||
// Status icons - consistent semantic indicators
|
||||
// Design: small Unicode symbols, NOT emoji-style icons for visual consistency
|
||||
const (
|
||||
IconPass = "✓"
|
||||
IconWarn = "⚠"
|
||||
IconFail = "✖"
|
||||
IconSkip = "-"
|
||||
IconInfo = "ℹ"
|
||||
)
|
||||
|
||||
// Issue status icons - used consistently across all commands
|
||||
// Design principle: icons > text labels for scannability
|
||||
const (
|
||||
StatusIconOpen = "○" // available to work (hollow circle)
|
||||
StatusIconInProgress = "◐" // active work (half-filled)
|
||||
StatusIconBlocked = "●" // needs attention (filled circle)
|
||||
StatusIconClosed = "✓" // completed (checkmark)
|
||||
StatusIconDeferred = "❄" // scheduled for later (snowflake)
|
||||
StatusIconPinned = "📌" // elevated priority
|
||||
)
|
||||
|
||||
// Priority icon - small filled circle, colored by priority level
|
||||
const PriorityIcon = "●"
|
||||
|
||||
// Tree characters for hierarchical display
|
||||
const (
|
||||
TreeChild = "⎿ " // child indicator
|
||||
TreeLast = "└─ " // last child / detail line
|
||||
TreeIndent = " " // 2-space indent per level
|
||||
)
|
||||
|
||||
// Separators - 42 characters wide
|
||||
const (
|
||||
SeparatorLight = "──────────────────────────────────────────"
|
||||
SeparatorHeavy = "══════════════════════════════════════════"
|
||||
)
|
||||
|
||||
// === Core Render Functions ===
|
||||
|
||||
// RenderPass renders text with pass (green) styling
|
||||
func RenderPass(s string) string {
|
||||
return PassStyle.Render(s)
|
||||
}
|
||||
|
||||
// RenderWarn renders text with warning (yellow) styling
|
||||
func RenderWarn(s string) string {
|
||||
return WarnStyle.Render(s)
|
||||
}
|
||||
|
||||
// RenderFail renders text with fail (red) styling
|
||||
func RenderFail(s string) string {
|
||||
return FailStyle.Render(s)
|
||||
}
|
||||
|
||||
// RenderMuted renders text with muted (gray) styling
|
||||
func RenderMuted(s string) string {
|
||||
return MutedStyle.Render(s)
|
||||
}
|
||||
|
||||
// RenderAccent renders text with accent (blue) styling
|
||||
func RenderAccent(s string) string {
|
||||
return AccentStyle.Render(s)
|
||||
}
|
||||
|
||||
// RenderCategory renders a category header in uppercase with accent color
|
||||
func RenderCategory(s string) string {
|
||||
return CategoryStyle.Render(strings.ToUpper(s))
|
||||
}
|
||||
|
||||
// RenderSeparator renders the light separator line in muted color
|
||||
func RenderSeparator() string {
|
||||
return MutedStyle.Render(SeparatorLight)
|
||||
}
|
||||
|
||||
// RenderBold renders text in bold
|
||||
func RenderBold(s string) string {
|
||||
return BoldStyle.Render(s)
|
||||
}
|
||||
|
||||
// RenderCommand renders a command name with subtle styling
|
||||
func RenderCommand(s string) string {
|
||||
return CommandStyle.Render(s)
|
||||
}
|
||||
|
||||
// === Icon Render Functions ===
|
||||
|
||||
// RenderPassIcon renders the pass icon with styling
|
||||
func RenderPassIcon() string {
|
||||
return PassStyle.Render(IconPass)
|
||||
}
|
||||
|
||||
// RenderWarnIcon renders the warning icon with styling
|
||||
func RenderWarnIcon() string {
|
||||
return WarnStyle.Render(IconWarn)
|
||||
}
|
||||
|
||||
// RenderFailIcon renders the fail icon with styling
|
||||
func RenderFailIcon() string {
|
||||
return FailStyle.Render(IconFail)
|
||||
}
|
||||
|
||||
// RenderSkipIcon renders the skip icon with styling
|
||||
func RenderSkipIcon() string {
|
||||
return MutedStyle.Render(IconSkip)
|
||||
}
|
||||
|
||||
// RenderInfoIcon renders the info icon with styling
|
||||
func RenderInfoIcon() string {
|
||||
return AccentStyle.Render(IconInfo)
|
||||
}
|
||||
|
||||
// === Issue Component Renderers ===
|
||||
|
||||
// RenderID renders an issue ID with semantic styling
|
||||
func RenderID(id string) string {
|
||||
return IDStyle.Render(id)
|
||||
}
|
||||
|
||||
// RenderStatus renders a status with semantic styling
|
||||
// in_progress/blocked/pinned get color; open/closed use standard text
|
||||
func RenderStatus(status string) string {
|
||||
switch status {
|
||||
case "in_progress":
|
||||
return StatusInProgressStyle.Render(status)
|
||||
case "blocked":
|
||||
return StatusBlockedStyle.Render(status)
|
||||
case "pinned":
|
||||
return StatusPinnedStyle.Render(status)
|
||||
case "hooked":
|
||||
return StatusHookedStyle.Render(status)
|
||||
case "closed":
|
||||
return StatusClosedStyle.Render(status)
|
||||
default: // open and others
|
||||
return StatusOpenStyle.Render(status)
|
||||
}
|
||||
}
|
||||
|
||||
// RenderStatusIcon returns the appropriate icon for a status with semantic coloring
|
||||
// This is the canonical source for status icon rendering - use this everywhere
|
||||
func RenderStatusIcon(status string) string {
|
||||
switch status {
|
||||
case "open":
|
||||
return StatusIconOpen // no color - available but not urgent
|
||||
case "in_progress":
|
||||
return StatusInProgressStyle.Render(StatusIconInProgress)
|
||||
case "blocked":
|
||||
return StatusBlockedStyle.Render(StatusIconBlocked)
|
||||
case "closed":
|
||||
return StatusClosedStyle.Render(StatusIconClosed)
|
||||
case "deferred":
|
||||
return MutedStyle.Render(StatusIconDeferred)
|
||||
case "pinned":
|
||||
return StatusPinnedStyle.Render(StatusIconPinned)
|
||||
default:
|
||||
return "?" // unknown status
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatusIcon returns just the icon character without styling
|
||||
// Useful when you need to apply custom styling or for non-TTY output
|
||||
func GetStatusIcon(status string) string {
|
||||
switch status {
|
||||
case "open":
|
||||
return StatusIconOpen
|
||||
case "in_progress":
|
||||
return StatusIconInProgress
|
||||
case "blocked":
|
||||
return StatusIconBlocked
|
||||
case "closed":
|
||||
return StatusIconClosed
|
||||
case "deferred":
|
||||
return StatusIconDeferred
|
||||
case "pinned":
|
||||
return StatusIconPinned
|
||||
default:
|
||||
return "?"
|
||||
}
|
||||
}
|
||||
|
||||
// GetStatusStyle returns the lipgloss style for a given status
|
||||
// Use this when you need to apply the semantic color to custom text
|
||||
func GetStatusStyle(status string) lipgloss.Style {
|
||||
switch status {
|
||||
case "in_progress":
|
||||
return StatusInProgressStyle
|
||||
case "blocked":
|
||||
return StatusBlockedStyle
|
||||
case "closed":
|
||||
return StatusClosedStyle
|
||||
case "deferred":
|
||||
return MutedStyle
|
||||
case "pinned":
|
||||
return StatusPinnedStyle
|
||||
case "hooked":
|
||||
return StatusHookedStyle
|
||||
default: // open and others - no special styling
|
||||
return lipgloss.NewStyle()
|
||||
}
|
||||
}
|
||||
|
||||
// RenderPriority renders a priority level with semantic styling
|
||||
// Format: "● P0" (icon + label)
|
||||
// P0/P1/P2 get color; P3/P4 use standard text
|
||||
func RenderPriority(priority int) string {
|
||||
label := fmt.Sprintf("%s P%d", PriorityIcon, priority)
|
||||
switch priority {
|
||||
case 0:
|
||||
return PriorityP0Style.Render(label)
|
||||
case 1:
|
||||
return PriorityP1Style.Render(label)
|
||||
case 2:
|
||||
return PriorityP2Style.Render(label)
|
||||
case 3:
|
||||
return PriorityP3Style.Render(label)
|
||||
case 4:
|
||||
return PriorityP4Style.Render(label)
|
||||
default:
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
// RenderPriorityCompact renders just the priority label without icon
|
||||
// Format: "P0"
|
||||
// Use when space is constrained or icon would be redundant
|
||||
func RenderPriorityCompact(priority int) string {
|
||||
label := fmt.Sprintf("P%d", priority)
|
||||
switch priority {
|
||||
case 0:
|
||||
return PriorityP0Style.Render(label)
|
||||
case 1:
|
||||
return PriorityP1Style.Render(label)
|
||||
case 2:
|
||||
return PriorityP2Style.Render(label)
|
||||
case 3:
|
||||
return PriorityP3Style.Render(label)
|
||||
case 4:
|
||||
return PriorityP4Style.Render(label)
|
||||
default:
|
||||
return label
|
||||
}
|
||||
}
|
||||
|
||||
// RenderType renders an issue type with semantic styling
|
||||
// bugs and epics get color; all other types use standard text
|
||||
func RenderType(issueType string) string {
|
||||
switch issueType {
|
||||
case "bug":
|
||||
return TypeBugStyle.Render(issueType)
|
||||
case "feature":
|
||||
return TypeFeatureStyle.Render(issueType)
|
||||
case "task":
|
||||
return TypeTaskStyle.Render(issueType)
|
||||
case "epic":
|
||||
return TypeEpicStyle.Render(issueType)
|
||||
case "chore":
|
||||
return TypeChoreStyle.Render(issueType)
|
||||
default:
|
||||
return issueType
|
||||
}
|
||||
}
|
||||
|
||||
// RenderIssueCompact renders a compact one-line issue summary
|
||||
// Format: ID [Priority] [Type] Status - Title
|
||||
// When status is "closed", the entire line is dimmed to show it's done
|
||||
func RenderIssueCompact(id string, priority int, issueType, status, title string) string {
|
||||
line := fmt.Sprintf("%s [P%d] [%s] %s - %s",
|
||||
id, priority, issueType, status, title)
|
||||
if status == "closed" {
|
||||
// entire line is dimmed - visually shows "done"
|
||||
return StatusClosedStyle.Render(line)
|
||||
}
|
||||
return fmt.Sprintf("%s [%s] [%s] %s - %s",
|
||||
RenderID(id),
|
||||
RenderPriority(priority),
|
||||
RenderType(issueType),
|
||||
RenderStatus(status),
|
||||
title,
|
||||
)
|
||||
}
|
||||
|
||||
// RenderPriorityForStatus renders priority with color only if not closed
|
||||
func RenderPriorityForStatus(priority int, status string) string {
|
||||
if status == "closed" {
|
||||
return fmt.Sprintf("P%d", priority)
|
||||
}
|
||||
return RenderPriority(priority)
|
||||
}
|
||||
|
||||
// RenderTypeForStatus renders type with color only if not closed
|
||||
func RenderTypeForStatus(issueType, status string) string {
|
||||
if status == "closed" {
|
||||
return issueType
|
||||
}
|
||||
return RenderType(issueType)
|
||||
}
|
||||
|
||||
// RenderClosedLine renders an entire line in the closed/dimmed style
|
||||
func RenderClosedLine(line string) string {
|
||||
return StatusClosedStyle.Render(line)
|
||||
}
|
||||
Reference in New Issue
Block a user