Files
ccs/macos-bar/Sources/CCSBarApp/BarSubscriptionCard.swift
T
Tam Nhu Tran 5aa94b5019 feat(bar-app): retune subscription card palette for the dark theme
Replace the raw system green/yellow/orange/red headroom colors (garish on the
dark surface and colliding with the brand orange) with a muted, intuitive
green-amber-coral-red palette that leans coral for 'low' so a near-empty
window never reads as the accent. Drop the cool indigo card wash for a neutral
elevated surface so the warm bars no longer clash; subscription identity stays
on the section header and badge.
2026-06-09 15:13:56 -04:00

263 lines
8.8 KiB
Swift

import SwiftUI
import CCSBarCore
/// Dedicated card for a first-party subscription (Claude Code / Codex).
///
/// Design goal: bar-first, glanceable in under a second. Each quota window is
/// rendered as an aligned row:
/// <label> [████████░░░░] 41% 9h 2m
///
/// The binding window (the one the subscription runs out of first) is
/// highlighted and is the only place where the at-risk pace warning appears.
/// Verbose prose lines ("week window · resets in ...") are removed entirely.
struct BarSubscriptionCard: View {
let row: BarSummaryRow
/// Injected clock — defaults to live Date() in production, pinned in previews
/// and tests so countdown math is deterministic.
var now: Date = Date()
private var windows: [QuotaWindowDetail] { row.quotaWindows ?? [] }
private var binding: QuotaWindowDetail? {
BarQuotaGauge.selectBindingWindow(windows)
}
var body: some View {
VStack(alignment: .leading, spacing: 6) {
titleRow
if windows.isEmpty {
emptyState
} else {
windowBarList
staleFootnote
}
}
.padding(.vertical, 9)
.padding(.horizontal, 10)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
BarTheme.cardSurface,
in: RoundedRectangle(cornerRadius: 9))
}
// MARK: Title row
/// Health dot + product name + reauth chip + tier chip. No pause toggle —
/// subscriptions are not routable pool accounts.
private var titleRow: some View {
HStack(spacing: 6) {
Circle()
.fill(healthColor)
.frame(width: 8, height: 8)
Text(BarFormatting.providerLabel(row.provider))
.font(.system(.body, design: .default).weight(.semibold))
.lineLimit(1)
if row.needsReauth {
Chip("reauth", tint: BarTheme.bandRed)
}
Spacer(minLength: 4)
if let tier = row.tier {
Chip(tier, tint: BarTheme.subscription)
}
}
}
// MARK: Window bar list
/// All quota windows rendered as aligned bar rows, binding window highlighted.
private var windowBarList: some View {
// Ordered display: core windows first (5h, week), then Opus/Sonnet.
let ordered = orderedWindows
let bindingKey = binding?.key
return VStack(alignment: .leading, spacing: 3) {
ForEach(ordered) { w in
windowBarRow(w, isBinding: w.key == bindingKey)
}
}
}
/// Stable display order: five_hour → seven_day → seven_day_opus → seven_day_sonnet.
private var orderedWindows: [QuotaWindowDetail] {
windows.sorted { a, b in
keyRank(a.key) < keyRank(b.key)
}
}
private func keyRank(_ key: String) -> Int {
switch key {
case "five_hour": return 0
case "seven_day": return 1
case "seven_day_opus": return 2
case "seven_day_sonnet": return 3
default: return 4
}
}
/// One bar row: short label | bar | remaining% | reset chip | [atRisk warning].
///
/// Layout uses fixed column widths so bars across rows align vertically,
/// making headroom comparisons instant.
private func windowBarRow(_ w: QuotaWindowDetail, isBinding: Bool) -> some View {
let band = BarQuotaGauge.band(percentage: w.remainingPercent, status: "ok")
let fill = BarQuotaGauge.fillFraction(percentage: w.remainingPercent, status: "ok") ?? 0
let barColor = color(for: band)
let isAtRisk = isBinding && BarQuotaGauge.atRisk(
usedPercent: w.usedPercent,
remainingPercent: w.remainingPercent,
resetAt: w.resetAt,
windowMinutes: w.windowMinutes,
now: now)
return HStack(spacing: 0) {
// Short label: max 5 chars to keep alignment tight.
Text(shortLabel(for: w))
.font(
isBinding
? .system(.caption2, design: .monospaced).weight(.semibold)
: .system(.caption2, design: .monospaced))
.foregroundStyle(isBinding ? .primary : .secondary)
.frame(width: 28, alignment: .leading)
// Horizontal fill bar — wider than the old secondary thinBar so fine
// gradations are visible. Remaining fraction fills from the left so a
// full bar = healthy, an empty bar = exhausted.
ZStack(alignment: .leading) {
Capsule().fill(Color.primary.opacity(isBinding ? 0.14 : 0.09))
Capsule()
.fill(barColor)
.frame(width: max(2, 88 * fill))
}
.frame(width: 88, height: isBinding ? 6 : 4)
Spacer(minLength: 5)
// Remaining percentage — monospaced so digits are column-stable.
Text("\(Int(w.remainingPercent.rounded()))%")
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(isBinding ? barColor : .secondary)
.frame(width: 28, alignment: .trailing)
Spacer(minLength: 5)
// Compact reset chip — terse duration or calendar date.
resetChip(for: w, isBinding: isBinding)
// At-risk warning: shown only on the binding window when pace says we
// will exhaust before the reset. Kept compact (⚠ + duration) so the
// row does not blow out to a second line.
if isAtRisk, let pace = paceWarningText(for: w) {
Text(pace)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(BarTheme.bandCoral)
.lineLimit(1)
.padding(.leading, 5)
}
}
}
/// Terse window label for the bar list, at most 4-5 chars:
/// five_hour → "5h"
/// seven_day → "wk"
/// seven_day_opus → "Son" (sic — this is the Opus sub-budget inside the week)
/// seven_day_sonnet → "Son"
///
/// Fall back to the backend-supplied label truncated to 5 chars so unknown
/// future keys still render acceptably.
private func shortLabel(for w: QuotaWindowDetail) -> String {
switch w.key {
case "five_hour": return "5h"
case "seven_day": return "wk"
case "seven_day_opus": return "Opus"
case "seven_day_sonnet": return "Son"
default:
let s = w.label
return s.count <= 5 ? s : String(s.prefix(4)) + "…"
}
}
/// Compact reset chip: muted small text showing how long until the window
/// refreshes. Uses BarCardFormatting.shortReset which delegates to Core's
/// compactDuration for <24h durations (days-tier included).
private func resetChip(for w: QuotaWindowDetail, isBinding: Bool) -> some View {
Group {
if let t = BarCardFormatting.shortReset(iso: w.resetAt, now: now) {
Text(t)
.font(.system(.caption2, design: .monospaced))
.foregroundStyle(isBinding ? .secondary : .tertiary)
}
}
.frame(width: 42, alignment: .trailing)
}
/// Extract the "~Th Mm" part from paceClause for the at-risk inline warning.
/// Returns nil when paceClause returns nil or the limit-reached path fires
/// (those are handled by the bar color, not an extra label).
private func paceWarningText(for w: QuotaWindowDetail) -> String? {
guard let clause = BarQuotaGauge.paceClause(
usedPercent: w.usedPercent,
remainingPercent: w.remainingPercent,
resetAt: w.resetAt,
windowMinutes: w.windowMinutes,
status: row.quotaStatus,
now: now),
clause.hasPrefix("~")
else { return nil }
// Strip "left at this pace" suffix to keep the inline chip terse.
// "~2h 30m left at this pace" → "⚠ ~2h 30m"
let core = clause
.replacingOccurrences(of: " left at this pace", with: "")
return "⚠ \(core)"
}
// MARK: Stale footnote (Codex older-session data)
/// "as of HH:mm (older session)" caption when the Codex reading came from an
/// older session. The bar still renders — the data is real, just not live.
@ViewBuilder private var staleFootnote: some View {
if let stale = row.staleAsOf, let clock = BarCardFormatting.clockTime(iso: stale) {
HStack(spacing: 4) {
Image(systemName: "clock.arrow.circlepath")
.font(.system(size: 9))
.foregroundStyle(.tertiary)
Text("as of \(clock), older session")
.font(.caption2)
.foregroundStyle(.tertiary)
}
}
}
// MARK: Empty / error state
/// No quota windows (reauth / error row): plain status text, no bar.
private var emptyState: some View {
Text(
row.needsReauth
? "reauth needed"
: BarFormatting.quotaLabel(percentage: row.quotaPercentage, status: row.quotaStatus)
)
.font(.caption)
.foregroundStyle(.secondary)
}
// MARK: Shared helpers
private var healthColor: Color {
switch row.health {
case "error": return BarTheme.bandRed
case "warning": return BarTheme.bandAmber
default: return BarTheme.bandGreen
}
}
private func color(for band: BarQuotaGauge.Band) -> Color {
switch band {
case .green: return BarTheme.bandGreen
case .yellow: return BarTheme.bandAmber
case .orange: return BarTheme.bandCoral
case .red: return BarTheme.bandRed
case .none: return .secondary
}
}
}