Colour themes, defaulting to gruvbox dark
CI / test (pull_request) Successful in 4s

Every colour was a 256-colour ANSI index hardcoded in styles.go, so changing
the palette meant editing the styles themselves. This puts a semantic token set
between the two: styles name roles, a theme supplies the colours.

internal/theme holds the twelve tokens, the two built-ins (gruvbox-dark, the
new default, and gruvbox-light) and the loader for user themes in
~/.config/terdut-tui/themes/. A user file may 'extends:' a built-in and
override only what it cares about, and may shadow a built-in name to tweak it
in place. Unknown keys, malformed colours and incomplete themes are refused
with a message naming what went wrong.

Colours are truecolor hex now: lipgloss downsamples for 256- and 16-colour
terminals and honours NO_COLOR, so themes carry no fallbacks of their own.
An ANSI index is still accepted for anyone who would rather follow their
terminal's own palette.

The 21 package-level style vars become a Styles struct on the Model, which is
what rule 3 asked for all along; the four free functions in view.go take one as
their first argument. The embedded bubbles components are restyled from the
same tokens — otherwise a theme would leave a pink selected row and grey help
text behind. Note that the table's Cell style deliberately keeps no foreground:
bubbles renders cells before wrapping the row in Selected, so a colour there
cuts the selection highlight short.
This commit is contained in:
Niklas Ye
2026-08-20 11:06:36 +02:00
parent dc53d49c3e
commit 4a579bdbc6
15 changed files with 996 additions and 226 deletions
+118 -118
View File
@@ -26,8 +26,8 @@ func (m Model) View() string {
}
func (m Model) renderHeader() string {
title := styleHeader.Render("terdut-tui")
right := styleMuted.Render(m.serverURL)
title := m.styles.Header.Render("terdut-tui")
right := m.styles.Muted.Render(m.serverURL)
return spread(title, right, m.width)
}
@@ -35,31 +35,31 @@ func (m Model) renderTabs() string {
var tabs []string
for i, name := range sectionNames {
if section(i) == m.activeSection {
tabs = append(tabs, styleTabActive.Render(name))
tabs = append(tabs, m.styles.TabActive.Render(name))
} else {
tabs = append(tabs, styleTabInactive.Render(name))
tabs = append(tabs, m.styles.TabInactive.Render(name))
}
}
sep := styleMuted.Render(strings.Repeat("─", m.width))
sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
return strings.Join(tabs, "") + "\n" + sep
}
func (m Model) renderBody() string {
if m.err != nil {
return "\n" + styleError.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + styleMuted.Render(" Press r to retry.")
return "\n" + m.styles.Error.Render(fmt.Sprintf(" Error: %v", m.err)) +
"\n" + m.styles.Muted.Render(" Press r to retry.")
}
if !m.connected {
return "\n" + styleMuted.Render(" Connecting…")
return "\n" + m.styles.Muted.Render(" Connecting…")
}
switch m.mode {
case modeIncidentDetail, modeAlertDetail:
return m.renderDetail()
case modeNote:
return m.renderPrompt(styleHeader.Render("Note: ") + m.noteInput.View())
return m.renderPrompt(m.styles.Header.Render("Note: ") + m.noteInput.View())
case modeSnooze:
return m.renderPrompt(styleHeader.Render("Snooze for: ") + m.snoozeInput.View())
return m.renderPrompt(m.styles.Header.Render("Snooze for: ") + m.snoozeInput.View())
case modeConfirm:
switch m.confirmTarget {
case confirmDeleteNote, confirmResolveIncident:
@@ -90,9 +90,9 @@ func (m Model) renderBody() string {
func (m Model) renderFooter() string {
withStatus := func(actions string) string {
rendered := styleFooter.Render(actions)
rendered := m.styles.Footer.Render(actions)
if m.statusMsg != "" {
return styleStatus.Render(" "+m.statusMsg) + "\n" + rendered
return m.styles.Status.Render(" "+m.statusMsg) + "\n" + rendered
}
return "\n" + rendered
}
@@ -108,13 +108,13 @@ func (m Model) renderFooter() string {
return withStatus(" i·open incident esc·back")
case modeNote:
return "\n" + styleFooter.Render(" enter·submit esc·cancel")
return "\n" + m.styles.Footer.Render(" enter·submit esc·cancel")
case modeSnooze:
return "\n" + styleFooter.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
return "\n" + m.styles.Footer.Render(" enter·snooze esc·cancel (e.g. 30m, 2h, 24h)")
case modeConfirm:
return "\n" + styleError.Render(" "+m.confirmPrompt())
return "\n" + m.styles.Error.Render(" "+m.confirmPrompt())
case modeUserPicker:
if m.pickerTarget == pickerIncidentAssignee {
@@ -159,7 +159,7 @@ func (m Model) renderFooter() string {
case sectionUsers:
return withStatus(" n·new user t·topic d·delete k·API keys r·refresh tab·section q·quit")
}
return "\n" + styleFooter.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
return "\n" + m.styles.Footer.Render(m.help.ShortHelpView(m.keys.ShortHelp()))
}
}
@@ -241,9 +241,9 @@ func (m Model) renderIncidents() string {
var content string
switch {
case m.loading && len(m.incidents) == 0:
content = styleMuted.Render(" Loading incidents…")
content = m.styles.Muted.Render(" Loading incidents…")
case len(m.incidents) == 0:
content = styleMuted.Render(
content = m.styles.Muted.Render(
fmt.Sprintf(" No %s incidents.", filterLabel(m.incidentFilter)))
default:
content = m.incidentTable.View()
@@ -256,9 +256,9 @@ func (m Model) renderAlerts() string {
var content string
switch {
case m.loading && len(m.alerts) == 0:
content = styleMuted.Render(" Loading alerts…")
content = m.styles.Muted.Render(" Loading alerts…")
case len(m.alerts) == 0:
content = styleMuted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter)))
content = m.styles.Muted.Render(fmt.Sprintf(" No %s alerts.", filterLabel(m.alertFilter)))
default:
content = m.alertTable.View()
}
@@ -267,10 +267,10 @@ func (m Model) renderAlerts() string {
func (m Model) renderArchived() string {
if m.archivedLoading {
return "\n" + styleMuted.Render(" Loading archived incidents…")
return "\n" + m.styles.Muted.Render(" Loading archived incidents…")
}
if len(m.archivedIncidents) == 0 {
return "\n" + styleMuted.Render(" No archived incidents.")
return "\n" + m.styles.Muted.Render(" No archived incidents.")
}
return "\n" + m.archivedTable.View()
}
@@ -286,12 +286,12 @@ func (m Model) renderIncidentStatsBar() string {
mttr = humanSeconds(m.incidentStats.MTTRSeconds)
}
left := fmt.Sprintf(" %s %s %s %s",
styleTriggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
styleAcknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
styleMuted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
m.styles.Triggered.Render(fmt.Sprintf("Triggered: %d", triggered)),
m.styles.Acknowledged.Render(fmt.Sprintf("Acked: %d", acked)),
m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
m.styles.Muted.Render(fmt.Sprintf("MTTA %s · MTTR %s", mtta, mttr)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter)))
right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.incidentFilter)))
return spread(left, right, m.width)
}
@@ -304,10 +304,10 @@ func (m Model) renderAlertStatsBar() string {
}
left := fmt.Sprintf(" Total: %d %s %s",
total,
styleFiring.Render(fmt.Sprintf("Firing: %d", firing)),
styleResolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
m.styles.Firing.Render(fmt.Sprintf("Firing: %d", firing)),
m.styles.Resolved.Render(fmt.Sprintf("Resolved: %d", resolved)),
)
right := styleMuted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter)))
right := m.styles.Muted.Render(fmt.Sprintf("filter: %s [f] ", filterLabel(m.alertFilter)))
return spread(left, right, m.width)
}
@@ -324,20 +324,20 @@ func spread(left, right string, width int) string {
func (m Model) renderSchedule() string {
if m.scheduleLoading {
return "\n" + styleMuted.Render(" Loading schedule…")
return "\n" + m.styles.Muted.Render(" Loading schedule…")
}
var onCallLine string
if m.currentOnCall != nil {
onCallLine = fmt.Sprintf(" On-call today: %s",
styleAlertName.Render(m.currentOnCall.Username))
m.styles.AlertName.Render(m.currentOnCall.Username))
} else {
onCallLine = styleMuted.Render(" On-call today: nobody scheduled")
onCallLine = m.styles.Muted.Render(" On-call today: nobody scheduled")
}
from := m.scheduleWindow
to := m.scheduleWindow.AddDate(0, 0, 6)
windowLabel := styleMuted.Render(fmt.Sprintf(" %s — %s",
windowLabel := m.styles.Muted.Render(fmt.Sprintf(" %s — %s",
from.Format("Jan 02"), to.Format("Jan 02, 2006")))
header := "\n" + spread(onCallLine, windowLabel, m.width) + "\n"
@@ -346,12 +346,12 @@ func (m Model) renderSchedule() string {
func (m Model) renderUserPicker() string {
if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…")
return "\n" + m.styles.Muted.Render(" Loading users…")
}
if m.pickerTarget == pickerIncidentAssignee {
header := fmt.Sprintf("\n Assign %s to:\n\n",
styleBold.Render(m.selectedIncident.Title))
m.styles.Bold.Render(m.selectedIncident.Title))
return header + m.userPickerTable.View()
}
@@ -376,7 +376,7 @@ func (m Model) renderUserPicker() string {
}
}
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", styleBold.Render(scope))
header := fmt.Sprintf("\n Assign on-call for %s — select a user:\n\n", m.styles.Bold.Render(scope))
return header + m.userPickerTable.View()
}
@@ -384,14 +384,14 @@ func (m Model) renderUserPicker() string {
func (m Model) renderDetail() string {
if m.detailLoading {
return "\n" + styleMuted.Render(" Loading…")
return "\n" + m.styles.Muted.Render(" Loading…")
}
return m.detailViewport.View()
}
// renderPrompt puts an input line under the detail pane.
func (m Model) renderPrompt(prompt string) string {
sep := styleMuted.Render(strings.Repeat("─", m.width))
sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
return m.detailViewport.View() + "\n" + sep + "\n" + prompt
}
@@ -401,7 +401,7 @@ func (m Model) renderStats() string {
// Only announce loading before the first result: a background refresh must not
// blank the page out from under whoever is reading it.
if m.statsLoading && !m.statsLoaded {
return "\n" + styleMuted.Render(" Loading statistics…")
return "\n" + m.styles.Muted.Render(" Loading statistics…")
}
return m.statsViewport.View()
}
@@ -418,16 +418,16 @@ func line(style lipgloss.Style, s string) string {
// ── Content builders ───────────────────────────────────────────────────────
func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
func buildIncidentDetailContent(s Styles, inc api.Incident, timeline []api.IncidentEvent, cursor, width int) string {
now := time.Now()
var b strings.Builder
contentW := width - 4
// Title + status header
title := styleAlertName.Render(inc.Title)
status := incidentStatusStyle(inc.Status).Render(incidentStatusLabel(inc))
title := s.AlertName.Render(inc.Title)
status := s.IncidentStatus(inc.Status).Render(incidentStatusLabel(inc))
if inc.Severity != "" {
status += " " + severityStyle(inc.Severity).Render(strings.ToUpper(inc.Severity))
status += " " + s.Severity(inc.Severity).Render(strings.ToUpper(inc.Severity))
}
gap := contentW - lipgloss.Width(title) - lipgloss.Width(status)
if gap < 1 {
@@ -440,9 +440,9 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.TriggeredAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, inc.TriggeredAt)))
if inc.AssignedTo != "" {
b.WriteString(fmt.Sprintf(" Assigned: %s\n", styleBold.Render(inc.AssignedTo)))
b.WriteString(fmt.Sprintf(" Assigned: %s\n", s.Bold.Render(inc.AssignedTo)))
} else {
b.WriteString(line(styleMuted, " Assigned: nobody"))
b.WriteString(line(s.Muted, " Assigned: nobody"))
}
if inc.AcknowledgedByID != nil {
@@ -450,14 +450,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
if inc.AcknowledgedAt != nil {
ackAt = " at " + inc.AcknowledgedAt.UTC().Format("2006-01-02 15:04 UTC")
}
b.WriteString(line(styleResolved,
b.WriteString(line(s.Resolved,
fmt.Sprintf(" Acked: %s%s", inc.AcknowledgedBy, ackAt)))
} else {
b.WriteString(line(styleMuted, " Acked: not acknowledged"))
b.WriteString(line(s.Muted, " Acked: not acknowledged"))
}
if inc.IsSnoozed() {
b.WriteString(line(styleSnoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
b.WriteString(line(s.Snoozed, fmt.Sprintf(" Snoozed: until %s (%s)",
inc.SnoozedUntil.UTC().Format("2006-01-02 15:04 UTC"), humanUntil(now, *inc.SnoozedUntil))))
}
@@ -470,14 +470,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
inc.ResolvedAt.UTC().Format("2006-01-02 15:04 UTC"), humanAgo(now, *inc.ResolvedAt), source))
}
if inc.ArchivedAt != nil {
b.WriteString(line(styleMuted, " Archived: "+
b.WriteString(line(s.Muted, " Archived: "+
inc.ArchivedAt.UTC().Format("2006-01-02 15:04 UTC")))
}
b.WriteString("\n")
// Group labels — the correlation Alertmanager applied.
if len(inc.GroupLabels) > 0 {
b.WriteString(divider("Grouped By", width))
b.WriteString(divider(s, "Grouped By", width))
for _, k := range sortedKeys(inc.GroupLabels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(inc.GroupLabels[k], contentW-24)))
}
@@ -485,14 +485,14 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
}
// Member alerts
b.WriteString(divider(fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
b.WriteString(divider(s, fmt.Sprintf("Alerts (%d)", len(inc.Alerts)), width))
if len(inc.Alerts) == 0 {
b.WriteString(line(styleMuted, " No alerts."))
b.WriteString(line(s.Muted, " No alerts."))
} else {
for _, a := range inc.Alerts {
marker := styleFiring.Render("●")
marker := s.Firing.Render("●")
if a.Status != "firing" {
marker = styleResolved.Render("✓")
marker = s.Resolved.Render("✓")
}
instance := a.Labels["instance"]
if instance == "" {
@@ -500,29 +500,29 @@ func buildIncidentDetailContent(inc api.Incident, timeline []api.IncidentEvent,
}
b.WriteString(fmt.Sprintf(" %s %-28s %-26s %s\n",
marker, truncate(a.Name, 28), truncate(instance, 26),
styleMuted.Render("last seen "+humanAgo(now, a.ReceivedAt))))
s.Muted.Render("last seen "+humanAgo(now, a.ReceivedAt))))
}
}
b.WriteString("\n")
// Timeline — the only history the server keeps.
notes := noteEvents(timeline)
b.WriteString(divider(fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
b.WriteString(divider(s, fmt.Sprintf("Timeline (%d events, %d notes)", len(timeline), len(notes)), width))
if len(timeline) == 0 {
b.WriteString(line(styleMuted, " Nothing recorded yet."))
b.WriteString(line(s.Muted, " Nothing recorded yet."))
} else {
noteIndex := 0
for _, e := range timeline {
when := styleMuted.Render(humanAgo(now, e.CreatedAt))
when := s.Muted.Render(humanAgo(now, e.CreatedAt))
if e.Type != api.EventNote {
b.WriteString(fmt.Sprintf(" %-52s %s\n", eventLabel(e), when))
continue
}
marker := " "
author := styleBold.Render(e.Username)
author := s.Bold.Render(e.Username)
if noteIndex == cursor {
marker = styleSelected.Render("> ")
author = styleSelected.Render(e.Username)
marker = s.Selected.Render("> ")
author = s.Selected.Render(e.Username)
}
b.WriteString(fmt.Sprintf("%s%-50s %s\n", marker, author+" wrote", when))
b.WriteString(" " + e.Detail + "\n")
@@ -626,21 +626,21 @@ func notifyKind(detail string) string {
return " (" + detail + ")"
}
func buildAlertDetailContent(alert api.Alert, width int) string {
func buildAlertDetailContent(s Styles, alert api.Alert, width int) string {
now := time.Now()
var b strings.Builder
contentW := width - 4
name := styleAlertName.Render(alert.Name)
name := s.AlertName.Render(alert.Name)
var statusStr string
if alert.Status == "firing" {
statusStr = styleFiring.Render("● FIRING")
statusStr = s.Firing.Render("● FIRING")
} else {
label := "✓ RESOLVED"
if alert.ResolutionSource != nil {
label += " · " + *alert.ResolutionSource
}
statusStr = styleResolved.Render(label)
statusStr = s.Resolved.Render(label)
}
gap := contentW - lipgloss.Width(name) - lipgloss.Width(statusStr)
if gap < 1 {
@@ -660,15 +660,15 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
}
if alert.IncidentID != nil {
b.WriteString(fmt.Sprintf(" Incident: %s %s\n",
styleBold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
styleMuted.Render("press i to open it")))
s.Bold.Render(fmt.Sprintf("#%d", *alert.IncidentID)),
s.Muted.Render("press i to open it")))
} else {
b.WriteString(line(styleMuted, " Incident: none"))
b.WriteString(line(s.Muted, " Incident: none"))
}
b.WriteString("\n")
if len(alert.Labels) > 0 {
b.WriteString(divider("Labels", width))
b.WriteString(divider(s, "Labels", width))
for _, k := range sortedKeys(alert.Labels) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Labels[k], contentW-24)))
}
@@ -676,7 +676,7 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
}
if len(alert.Annotations) > 0 {
b.WriteString(divider("Annotations", width))
b.WriteString(divider(s, "Annotations", width))
for _, k := range sortedKeys(alert.Annotations) {
b.WriteString(fmt.Sprintf(" %-22s %s\n", k, truncate(alert.Annotations[k], contentW-24)))
}
@@ -684,14 +684,14 @@ func buildAlertDetailContent(alert api.Alert, width int) string {
}
// Alerts carry no workflow state: it all lives on the incident.
b.WriteString(divider("", width))
b.WriteString(line(styleMuted,
b.WriteString(divider(s, "", width))
b.WriteString(line(s.Muted,
" Alerts are read-only — acknowledge, assign, note and resolve on the incident."))
return b.String()
}
func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
func buildStatsContent(s Styles, incidents *api.IncidentStats, top []api.TopAlert, byHour []api.HourStat, byDay []api.DayStat, width int) string {
barWidth := width/2 - 10
if barWidth < 8 {
barWidth = 8
@@ -704,41 +704,41 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
b.WriteString("\n")
// Response times first: they are what a rota is actually judged on.
b.WriteString(divider("Incident Response", width))
b.WriteString(divider(s, "Incident Response", width))
if incidents == nil {
b.WriteString(line(styleMuted, " No data."))
b.WriteString(line(s.Muted, " No data."))
} else {
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Incidents total",
styleBold.Render(fmt.Sprintf("%d", incidents.Total))))
s.Bold.Render(fmt.Sprintf("%d", incidents.Total))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Triggered",
styleTriggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
s.Triggered.Render(fmt.Sprintf("%d", incidents.Triggered))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Acknowledged",
styleAcknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
s.Acknowledged.Render(fmt.Sprintf("%d", incidents.Acknowledged))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Resolved",
styleResolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
s.Resolved.Render(fmt.Sprintf("%d", incidents.Resolved))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to acknowledge",
styleBold.Render(humanSeconds(incidents.MTTASeconds))))
s.Bold.Render(humanSeconds(incidents.MTTASeconds))))
b.WriteString(fmt.Sprintf(" %-28s %s\n", "Mean time to resolve",
styleBold.Render(humanSeconds(incidents.MTTRSeconds))))
s.Bold.Render(humanSeconds(incidents.MTTRSeconds))))
if incidents.MTTASeconds == nil || incidents.MTTRSeconds == nil {
b.WriteString(line(styleMuted, " (— means nothing has been acknowledged or resolved yet)"))
b.WriteString(line(s.Muted, " (— means nothing has been acknowledged or resolved yet)"))
}
}
b.WriteString("\n")
b.WriteString(divider("Top Alerts", width))
b.WriteString(divider(s, "Top Alerts", width))
if len(top) == 0 {
b.WriteString(line(styleMuted, " No data."))
b.WriteString(line(s.Muted, " No data."))
} else {
maxCount := top[0].Count
for i, a := range top {
bar := styleResolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth)))
bar := s.Resolved.Render(strings.Repeat("█", renderBarWidth(a.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2d. %-30s %s %d\n", i+1, truncate(a.Name, 30), bar, a.Count))
}
}
b.WriteString("\n")
b.WriteString(divider("Alerts by Hour (UTC)", width))
b.WriteString(divider(s, "Alerts by Hour (UTC)", width))
if len(byHour) > 0 {
maxCount := 0
for _, h := range byHour {
@@ -747,15 +747,15 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
}
}
for _, h := range byHour {
bar := styleFiring.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth)))
bar := s.Firing.Render(strings.Repeat("█", renderBarWidth(h.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %2dh %-*s %d\n", h.Hour, barWidth, bar, h.Count))
}
} else {
b.WriteString(line(styleMuted, " No data."))
b.WriteString(line(s.Muted, " No data."))
}
b.WriteString("\n")
b.WriteString(divider("Alerts by Day", width))
b.WriteString(divider(s, "Alerts by Day", width))
if len(byDay) > 0 {
maxCount := 0
for _, d := range byDay {
@@ -764,11 +764,11 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
}
}
for _, d := range byDay {
bar := styleAccent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth)))
bar := s.Accent.Render(strings.Repeat("█", renderBarWidth(d.Count, maxCount, barWidth)))
b.WriteString(fmt.Sprintf(" %-4s %-*s %d\n", d.DayName[:3], barWidth, bar, d.Count))
}
} else {
b.WriteString(line(styleMuted, " No data."))
b.WriteString(line(s.Muted, " No data."))
}
return b.String()
@@ -778,22 +778,22 @@ func buildStatsContent(incidents *api.IncidentStats, top []api.TopAlert, byHour
func (m Model) renderUsers() string {
if m.usersLoading {
return "\n" + styleMuted.Render(" Loading users…")
return "\n" + m.styles.Muted.Render(" Loading users…")
}
if len(m.users) == 0 {
return "\n" + styleMuted.Render(" No users found. Press n to create one.")
return "\n" + m.styles.Muted.Render(" No users found. Press n to create one.")
}
return "\n" + m.userManageTable.View()
}
func (m Model) renderUserCreate() string {
header := "\n " + styleBold.Render("Create new user") + "\n\n"
header := "\n " + m.styles.Bold.Render("Create new user") + "\n\n"
usernameLabel := " Username: "
emailLabel := " Email: "
if m.userFormFocus == 0 {
usernameLabel = styleSelected.Render(" Username: ")
usernameLabel = m.styles.Selected.Render(" Username: ")
} else {
emailLabel = styleSelected.Render(" Email: ")
emailLabel = m.styles.Selected.Render(" Email: ")
}
return header +
usernameLabel + m.userFormInputs[0].View() + "\n" +
@@ -801,59 +801,59 @@ func (m Model) renderUserCreate() string {
}
func (m Model) renderUserNotifyEdit() string {
header := fmt.Sprintf("\n Push notifications for %s\n", styleBold.Render(m.selectedUser.Username))
hint := line(styleMuted,
header := fmt.Sprintf("\n Push notifications for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(m.styles.Muted,
" The ntfy topic this user's pages go to. Leave it empty to clear it —\n"+
" their incidents then page the server's shared fallback topic, which\n"+
" carries no Acknowledge button.")
label := styleSelected.Render(" Topic: ")
label := m.styles.Selected.Render(" Topic: ")
return header + "\n" + hint + "\n" + label + m.ntfyTopicInput.View() + "\n"
}
func (m Model) renderAPIKeyMenu() string {
header := fmt.Sprintf("\n API keys for %s\n", styleBold.Render(m.selectedUser.Username))
warning := line(styleMuted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
header := fmt.Sprintf("\n API keys for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
warning := line(m.styles.Muted, " Keys cannot be listed — only new keys can be created,\n or existing ones revoked by their integer ID.")
options := "\n" +
styleAccent.Render(" n") + " · create a new API key\n" +
styleAccent.Render(" r") + " · revoke a key by ID\n"
m.styles.Accent.Render(" n") + " · create a new API key\n" +
m.styles.Accent.Render(" r") + " · revoke a key by ID\n"
return header + "\n" + warning + options
}
func (m Model) renderAPIKeyCreate() string {
header := fmt.Sprintf("\n New API key for %s\n\n", styleBold.Render(m.selectedUser.Username))
label := styleSelected.Render(" Key name: ")
header := fmt.Sprintf("\n New API key for %s\n\n", m.styles.Bold.Render(m.selectedUser.Username))
label := m.styles.Selected.Render(" Key name: ")
return header + label + m.apiKeyNameInput.View() + "\n"
}
func (m Model) renderAPIKeyReveal() string {
sep := styleMuted.Render(strings.Repeat("─", m.width))
warn := styleError.Render(" !! COPY NOW — this key will NEVER be shown again !!")
nameLine := fmt.Sprintf(" Key name: %s", styleBold.Render(m.revealedAPIKey.Name))
sep := m.styles.Muted.Render(strings.Repeat("─", m.width))
warn := m.styles.Error.Render(" !! COPY NOW — this key will NEVER be shown again !!")
nameLine := fmt.Sprintf(" Key name: %s", m.styles.Bold.Render(m.revealedAPIKey.Name))
idLine := fmt.Sprintf(" Key ID: %s %s",
styleBold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
styleMuted.Render("(save this — needed for future revocation)"))
m.styles.Bold.Render(fmt.Sprintf("%d", m.revealedAPIKey.ID)),
m.styles.Muted.Render("(save this — needed for future revocation)"))
keyLine := styleResolved.Render(" " + m.revealedAPIKey.Key)
keyLine := m.styles.Resolved.Render(" " + m.revealedAPIKey.Key)
return "\n" + sep + "\n\n" +
warn + "\n\n" +
nameLine + "\n" +
idLine + "\n\n" +
styleMuted.Render(" Key value:") + "\n" +
m.styles.Muted.Render(" Key value:") + "\n" +
keyLine + "\n\n" +
sep + "\n"
}
func (m Model) renderAPIKeyRevokeByID() string {
header := fmt.Sprintf("\n Revoke API key for %s\n", styleBold.Render(m.selectedUser.Username))
hint := line(styleMuted, " Enter the integer key ID (shown when the key was created).")
label := styleSelected.Render(" Key ID: ")
header := fmt.Sprintf("\n Revoke API key for %s\n", m.styles.Bold.Render(m.selectedUser.Username))
hint := line(m.styles.Muted, " Enter the integer key ID (shown when the key was created).")
label := m.styles.Selected.Render(" Key ID: ")
return header + "\n" + hint + "\n" + label + m.apiKeyRevokeInput.View() + "\n"
}
// ── Helpers ────────────────────────────────────────────────────────────────
func divider(title string, width int) string {
func divider(s Styles, title string, width int) string {
prefix := "── "
if title != "" {
prefix += title + " "
@@ -862,7 +862,7 @@ func divider(title string, width int) string {
if remaining > 0 {
prefix += strings.Repeat("─", remaining)
}
return styleMuted.Render(prefix) + "\n"
return s.Muted.Render(prefix) + "\n"
}
func sortedKeys(m map[string]string) []string {