Skip to main content

Overview

The UI is built with Bubble Tea and follows The Elm Architecture. The main Model manages four page models and handles navigation.

Main Model

Model Struct

ui/model.go:27-39
type Model struct {
    cfg      *config.Config
    renderer *lipgloss.Renderer
    width    int
    height   int

    activePage Page

    home     HomeModel
    skills   SkillsModel
    projects ProjectsModel
    contact  ContactModel
}
Fields:
  • cfg - Portfolio data loaded from config.yaml
  • renderer - Lipgloss renderer (session-specific)
  • width, height - Current terminal dimensions
  • activePage - Currently displayed page (enum)
  • home, skills, projects, contact - Page model instances

Page Type

ui/model.go:11-21
type Page int

const (
    PageHome Page = iota
    PageSkills
    PageProjects
    PageContact
)

var pageNames = []string{"Home", "Skills", "Projects", "Contact"}
Page is an enum (0-3) used for tab navigation and routing.

Initialization

ui/model.go:41-53
func NewModel(cfg *config.Config, renderer *lipgloss.Renderer, width, height int) Model {
    return Model{
        cfg:        cfg,
        renderer:   renderer,
        width:      width,
        height:     height,
        activePage: PageHome,
        home:       NewHomeModel(cfg),
        skills:     NewSkillsModel(cfg),
        projects:   NewProjectsModel(cfg),
        contact:    NewContactModel(cfg),
    }
}
Creates all page models upfront. Each page keeps reference to config.

Keyboard Controls

Global shortcuts handled in Model.Update:
KeyActionCode Location
q, Ctrl+CQuit applicationui/model.go:68-69
, hPrevious pageui/model.go:70-74
, lNext pageui/model.go:75-79
Page-specific shortcuts (Skills/Projects):
KeyActionPages
, kMove cursor upSkills, Projects
, jMove cursor downSkills, Projects

Update Method

ui/model.go:59-97
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    switch msg := msg.(type) {
    case tea.WindowSizeMsg:
        m.width = msg.Width
        m.height = msg.Height
        return m, nil

    case tea.KeyMsg:
        switch msg.String() {
        case "ctrl+c", "q":
            return m, tea.Quit
        case "left", "h":
            if m.activePage > PageHome {
                m.activePage--
            }
            return m, nil
        case "right", "l":
            if m.activePage < PageContact {
                m.activePage++
            }
            return m, nil
        }
    }

    // Delegate to active page
    var cmd tea.Cmd
    cw, ch := m.contentWidth(), m.contentHeight()
    switch m.activePage {
    case PageHome:
        m.home, cmd = m.home.Update(msg, cw, ch)
    case PageSkills:
        m.skills, cmd = m.skills.Update(msg, cw, ch)
    case PageProjects:
        m.projects, cmd = m.projects.Update(msg, cw, ch)
    case PageContact:
        m.contact, cmd = m.contact.Update(msg, cw, ch)
    }
    return m, cmd
}
Message flow:
  1. Handle window resizes
  2. Process global keyboard shortcuts
  3. Delegate remaining messages to active page

Layout Components

Tab Bar

Rendered at the top with 3 lines height:
ui/model.go:141-178
func (m Model) renderTabBar() string {
    r := m.renderer
    var tabs []string

    for i, name := range pageNames {
        if Page(i) == m.activePage {
            tab := r.NewStyle().
                Foreground(lipgloss.Color(Base)).
                Background(lipgloss.Color(Blue)).
                Bold(true).
                Padding(0, 2).
                Render(name)
            tabs = append(tabs, tab)
        } else {
            tab := r.NewStyle().
                Foreground(lipgloss.Color(Subtext0)).
                Background(lipgloss.Color(Surface0)).
                Padding(0, 2).
                Render(name)
            tabs = append(tabs, tab)
        }
    }

    row := lipgloss.JoinHorizontal(lipgloss.Top, tabs...)
    gap := r.NewStyle().
        Background(lipgloss.Color(Surface0)).
        Render(strings.Repeat(" ", max(0, m.width-lipgloss.Width(row))))

    fullRow := row + gap

    border := r.NewStyle().
        Foreground(lipgloss.Color(Surface1)).
        Render(strings.Repeat("─", m.width))

    return lipgloss.JoinVertical(lipgloss.Left, "", fullRow, border)
}
Styling:
  • Active tab: Blue background, bold text
  • Inactive tabs: Gray background, dimmed text
  • Bottom border with horizontal line

Status Bar

Rendered at bottom with contextual keyboard hints:
ui/model.go:180-196
func (m Model) renderStatusBar() string {
    r := m.renderer

    hints := "  ← → pages"
    switch m.activePage {
    case PageSkills, PageProjects:
        hints += "  │  ↑ ↓ select"
    }
    hints += "  │  q quit"

    return r.NewStyle().
        Foreground(lipgloss.Color(Overlay0)).
        Background(lipgloss.Color(Mantle)).
        Width(m.width).
        Padding(0, 1).
        Render(hints)
}
Behavior:
  • Shows navigation hints on all pages
  • Adds ”↑ ↓ select” hint on Skills and Projects pages
  • Always shows quit shortcut

Content Area

Dynamically sized based on terminal dimensions:
ui/model.go:129-139
func (m Model) contentWidth() int {
    return m.width
}

func (m Model) contentHeight() int {
    h := m.height - tabBarHeight - statusBarHeight
    if h < 1 {
        return 1
    }
    return h
}
Constants: tabBarHeight = 3, statusBarHeight = 1

Page Components

HomeModel

Displays ASCII art, title, and intro text centered on screen. Structure:
ui/home.go:9-15
type HomeModel struct {
    cfg *config.Config
}

func NewHomeModel(cfg *config.Config) HomeModel {
    return HomeModel{cfg: cfg}
}
Update Method:
ui/home.go:17-19
func (m HomeModel) Update(msg tea.Msg, width, height int) (HomeModel, tea.Cmd) {
    return m, nil
}
No internal state - purely presentational. View Method:
ui/home.go:21-47
func (m HomeModel) View(width, height int, r *lipgloss.Renderer) string {
    artStyle := r.NewStyle().
        Foreground(lipgloss.Color(Mauve)).
        Bold(true).
        Align(lipgloss.Center).
        Width(width)

    titleStyle := r.NewStyle().
        Foreground(lipgloss.Color(Blue)).
        Bold(true).
        Align(lipgloss.Center).
        Width(width).
        Padding(1, 0)

    introStyle := r.NewStyle().
        Foreground(lipgloss.Color(Subtext0)).
        Align(lipgloss.Center).
        Width(width)

    art := artStyle.Render(m.cfg.ASCIIArt)
    title := titleStyle.Render(m.cfg.Title)
    intro := introStyle.Render(m.cfg.Intro)

    content := lipgloss.JoinVertical(lipgloss.Center, art, title, intro)

    return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)
}
Layout:
  • ASCII art in purple/mauve
  • Title in blue with top/bottom padding
  • Intro text in gray
  • All content centered vertically and horizontally

SkillsModel

Two-column layout: category list on left, selected category skills on right. Structure:
ui/skills.go:12-18
type SkillsModel struct {
    cfg    *config.Config
    cursor int
}

func NewSkillsModel(cfg *config.Config) SkillsModel {
    return SkillsModel{cfg: cfg}
}
Update Method:
ui/skills.go:20-35
func (m SkillsModel) Update(msg tea.Msg, width, height int) (SkillsModel, tea.Cmd) {
    if msg, ok := msg.(tea.KeyMsg); ok {
        switch msg.String() {
        case "up", "k":
            if m.cursor > 0 {
                m.cursor--
            }
        case "down", "j":
            if m.cursor < len(m.cfg.Skills)-1 {
                m.cursor++
            }
        }
    }
    return m, nil
}
Manages cursor position for category selection. View Method (simplified):
ui/skills.go:37-116
func (m SkillsModel) View(width, height int, r *lipgloss.Renderer) string {
    leftWidth := width*3/10 - 2
    rightWidth := width - leftWidth - 5

    // Left column: category list
    var leftLines []string
    for i, cat := range m.cfg.Skills {
        if i == m.cursor {
            line := r.NewStyle().
                Foreground(lipgloss.Color(Blue)).
                Bold(true).
                Render("  ▸ " + cat.Category)
            leftLines = append(leftLines, line)
        } else {
            line := r.NewStyle().
                Foreground(lipgloss.Color(Overlay1)).
                Render("    " + cat.Category)
            leftLines = append(leftLines, line)
        }
    }

    // Right column: skills for selected category
    var rightLines []string
    if m.cursor < len(m.cfg.Skills) {
        cat := m.cfg.Skills[m.cursor]
        // ... render skill items with progress bars
    }

    return lipgloss.JoinHorizontal(lipgloss.Top, leftPane, divider, rightPane)
}
Skill Item Rendering:
ui/skills.go:72-95
for _, item := range cat.Items {
    if item.Level > 0 {
        // Render with strength bar
        nameStyle := r.NewStyle().
            Foreground(lipgloss.Color(Green)).
            Width(18)
        filled := r.NewStyle().
            Foreground(lipgloss.Color(Blue)).
            Render(strings.Repeat("█", item.Level*2))
        empty := r.NewStyle().
            Foreground(lipgloss.Color(Surface1)).
            Render(strings.Repeat("░", (5-item.Level)*2))
        level := r.NewStyle().
            Foreground(lipgloss.Color(Subtext0)).
            Render(fmt.Sprintf(" %d/5", item.Level))
        line := "  " + nameStyle.Render(item.Name) + " " + filled + empty + level
        rightLines = append(rightLines, line)
    } else {
        line := r.NewStyle().
            Foreground(lipgloss.Color(Green)).
            Render("  ● " + item.Name)
        rightLines = append(rightLines, line)
    }
}
Layout:
  • Left 30%: Category list with cursor indicator
  • Vertical divider
  • Right 70%: Skills with optional progress bars (1-5 filled blocks)

ProjectsModel

Similar two-column layout for projects. Structure:
ui/projects.go:11-17
type ProjectsModel struct {
    cfg    *config.Config
    cursor int
}

func NewProjectsModel(cfg *config.Config) ProjectsModel {
    return ProjectsModel{cfg: cfg}
}
Update Method:
ui/projects.go:19-34
func (m ProjectsModel) Update(msg tea.Msg, width, height int) (ProjectsModel, tea.Cmd) {
    if msg, ok := msg.(tea.KeyMsg); ok {
        switch msg.String() {
        case "up", "k":
            if m.cursor > 0 {
                m.cursor--
            }
        case "down", "j":
            if m.cursor < len(m.cfg.Projects)-1 {
                m.cursor++
            }
        }
    }
    return m, nil
}
View Method (project details):
ui/projects.go:58-90
var rightLines []string
if m.cursor < len(m.cfg.Projects) {
    proj := m.cfg.Projects[m.cursor]

    name := r.NewStyle().
        Foreground(lipgloss.Color(Peach)).
        Bold(true).
        Render(proj.Name)
    rightLines = append(rightLines, name)

    rightLines = append(rightLines, r.NewStyle().
        Foreground(lipgloss.Color(Surface1)).
        Render(strings.Repeat("─", min(rightWidth-4, 30))))

    desc := r.NewStyle().
        Foreground(lipgloss.Color(Text)).
        Width(rightWidth - 6).
        Render(proj.Description)
    rightLines = append(rightLines, desc)

    rightLines = append(rightLines, "")

    tech := r.NewStyle().
        Foreground(lipgloss.Color(Sapphire)).
        Render("  ⚡ " + strings.Join(proj.Tech, " · "))
    rightLines = append(rightLines, tech)

    url := r.NewStyle().
        Foreground(lipgloss.Color(Lavender)).
        Underline(true).
        Render("  🔗 " + proj.URL)
    rightLines = append(rightLines, url)
}
Layout:
  • Left 30%: Project name list with cursor
  • Right 70%: Name, description, tech stack, URL

ContactModel

Centered card with contact information. Structure:
ui/contact.go:11-17
type ContactModel struct {
    cfg *config.Config
}

func NewContactModel(cfg *config.Config) ContactModel {
    return ContactModel{cfg: cfg}
}
Update Method:
ui/contact.go:19-21
func (m ContactModel) Update(msg tea.Msg, width, height int) (ContactModel, tea.Cmd) {
    return m, nil
}
View Method:
ui/contact.go:23-76
func (m ContactModel) View(width, height int, r *lipgloss.Renderer) string {
    labelStyle := r.NewStyle().
        Foreground(lipgloss.Color(Mauve)).
        Bold(true).
        Width(14).
        Align(lipgloss.Right)

    valueStyle := r.NewStyle().
        Foreground(lipgloss.Color(Text)).
        PaddingLeft(2)

    type row struct {
        label string
        value string
    }

    rows := []row{
        {"✉  Email", m.cfg.Contact.Email},
        {"  GitHub", m.cfg.Contact.GitHub},
        {"  LinkedIn", m.cfg.Contact.LinkedIn},
        {"🌐  Website", m.cfg.Contact.Website},
        {"𝕏  Twitter", m.cfg.Contact.Twitter},
    }

    var lines []string
    for _, r := range rows {
        if r.value == "" {
            continue
        }
        line := lipgloss.JoinHorizontal(lipgloss.Top,
            labelStyle.Render(r.label),
            valueStyle.Render(r.value),
        )
        lines = append(lines, line)
    }

    header := headerStyle.Render("Get In Touch")
    content := strings.Join(lines, "\n\n")

    card := r.NewStyle().
        Border(lipgloss.RoundedBorder()).
        BorderForeground(lipgloss.Color(Surface1)).
        Padding(2, 4).
        Render(lipgloss.JoinVertical(lipgloss.Center, header, "", content))

    return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, card)
}
Layout:
  • Rounded border card
  • Header: “Get In Touch”
  • Rows: Icon + label (right-aligned) + value
  • Only shows non-empty contact fields
  • Centered on screen

Color Theme

All components use Catppuccin Mocha palette defined in ui/theme.go:3-30:
const (
    Blue      = "#89b4fa"  // Primary accent
    Mauve     = "#cba6f7"  // Secondary accent
    Green     = "#a6e3a1"  // Success/skills
    Peach     = "#fab387"  // Projects
    Lavender  = "#b4befe"  // Links
    Sapphire  = "#74c7ec"  // Tech stack
    Text      = "#cdd6f4"  // Main text
    Subtext0  = "#a6adc8"  // Dimmed text
    Surface0  = "#313244"  // Backgrounds
    Surface1  = "#45475a"  // Borders
    Base      = "#1e1e2e"  // Base background
    Mantle    = "#181825"  // Status bar
    // ... and more
)

Common Patterns

Page Interface

All page models follow this pattern:
type PageModel struct {
    cfg    *config.Config
    cursor int  // optional, for list pages
}

func (m PageModel) Update(msg tea.Msg, width, height int) (PageModel, tea.Cmd)
func (m PageModel) View(width, height int, r *lipgloss.Renderer) string

Two-Column Layout

Skills and Projects use identical layout logic:
leftWidth := width*3/10 - 2
rightWidth := width - leftWidth - 5

leftPane := r.NewStyle().Width(leftWidth).Render(...)
divider := r.NewStyle().Render(strings.Repeat("│\n", height))
rightPane := r.NewStyle().Width(rightWidth).Render(...)

return lipgloss.JoinHorizontal(lipgloss.Top, leftPane, divider, rightPane)

Centered Content

Home and Contact use Lipgloss’s Place for centering:
content := lipgloss.JoinVertical(lipgloss.Center, ...elements)
return lipgloss.Place(width, height, lipgloss.Center, lipgloss.Center, content)

Build docs developers (and LLMs) love