From 83ee27623fe4afd60d1c0095dd3ccaf663720c70 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Tue, 19 May 2026 13:36:50 +0800 Subject: [PATCH 1/8] {"schema":"decodex/commit/1","summary":"Polish Decodex App account panel UI","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 666 ++++++++++++------ 1 file changed, 449 insertions(+), 217 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 72d1eedcc..19cbfb0c5 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -9,7 +9,7 @@ struct AccountPanelView: View { var body: some View { Group { if #available(macOS 26.0, *) { - GlassEffectContainer(spacing: 10) { + GlassEffectContainer(spacing: 6) { panelContent } } else { @@ -46,7 +46,7 @@ struct AccountPanelView: View { } private var panelContent: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 8) { header accountSummary @@ -68,24 +68,33 @@ struct AccountPanelView: View { footer } - .frame(width: 324) - .padding(14) - .modernGlassSurface(cornerRadius: 22) + .frame(width: 318) + .padding(10) + .modernGlassSurface( + cornerRadius: 18, + tint: Color.accentColor.opacity(0.035), + depth: .panel + ) .controlSize(.small) .symbolRenderingMode(.hierarchical) } private var header: some View { - HStack(alignment: .center, spacing: 12) { + HStack(alignment: .center, spacing: 8) { Image(systemName: store.menuSymbol) - .font(.system(size: 22, weight: .semibold)) - .frame(width: 30, height: 30) - .foregroundStyle(.primary) + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(Color.accentColor) + .frame(width: 28, height: 28) + .modernGlassSurface( + cornerRadius: 9, + tint: Color.accentColor.opacity(0.16), + depth: .control + ) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 2) { Text("Decodex") - .font(.system(size: 15, weight: .semibold)) - Text("\(store.accounts.count) account\(store.accounts.count == 1 ? "" : "s")") + .font(.system(size: 13, weight: .semibold)) + Text(headerSubtitle) .font(.caption2) .foregroundStyle(.secondary) .lineLimit(1) @@ -93,54 +102,66 @@ struct AccountPanelView: View { Spacer() - HStack(spacing: 6) { - Button { - accountPrivacy = emailsHidden ? AccountPrivacy.visibleValue : AccountPrivacy.hiddenValue - } label: { - Image(systemName: emailsHidden ? "eye.slash" : "eye") - .frame(width: 18, height: 18) - } - .iconPanelButtonStyle() - .help(emailsHidden ? "Show account emails" : "Hide account emails") + HStack(spacing: 4) { + PanelIconButtonView( + symbol: emailsHidden ? "eye.slash" : "eye", + tint: .secondary, + isActive: false, + action: { + accountPrivacy = emailsHidden ? AccountPrivacy.visibleValue : AccountPrivacy.hiddenValue + }, + help: emailsHidden ? "Show account emails" : "Hide account emails" + ) - Button { - Task { - await store.refresh(force: true) - } - } label: { - Image(systemName: store.isRefreshing ? "arrow.triangle.2.circlepath.circle" : "arrow.clockwise") - .frame(width: 18, height: 18) - } - .iconPanelButtonStyle() - .help("Refresh") - .disabled(store.isRefreshing) + PanelIconButtonView( + symbol: store.isRefreshing ? "arrow.triangle.2.circlepath.circle" : "arrow.clockwise", + tint: .secondary, + isActive: store.isRefreshing, + isDisabled: store.isRefreshing, + action: { + Task { + await store.refresh(force: true) + } + }, + help: "Refresh" + ) } } } private var accountSummary: some View { - VStack(spacing: 7) { - SummaryRowView( + HStack(spacing: 0) { + SummaryTileView( title: "Codex", value: codexAuthLabel, symbol: "bolt.fill", tint: .yellow ) - SummaryRowView( - title: "Decodex runs", + + Divider() + .opacity(0.5) + .padding(.vertical, 3) + + SummaryTileView( + title: "Runs", value: decodexModeLabel, symbol: hasFixedSelection ? "pin.fill" : "arrow.triangle.branch", tint: hasFixedSelection ? .accentColor : .secondary ) } - .padding(10) - .modernGlassSurface(cornerRadius: 14, tint: .primary.opacity(0.035)) + .padding(.horizontal, 7) + .padding(.vertical, 5) + .modernGlassSurface( + cornerRadius: 11, + tint: Color.primary.opacity(0.045), + depth: .section + ) } private var emptyState: some View { - VStack(alignment: .leading, spacing: 9) { + VStack(alignment: .leading, spacing: 6) { Image(systemName: "person.crop.circle.badge.plus") - .font(.system(size: 24)) + .font(.system(size: 19)) .foregroundStyle(.secondary) Text("No accounts in the local pool") .font(.subheadline.weight(.semibold)) @@ -150,12 +171,12 @@ struct AccountPanelView: View { .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .modernGlassSurface(cornerRadius: 14) + .padding(8) + .modernGlassSurface(cornerRadius: 10, depth: .section) } private var loadingState: some View { - HStack(spacing: 10) { + HStack(spacing: 7) { ProgressView() .controlSize(.small) Text("Loading accounts") @@ -163,13 +184,13 @@ struct AccountPanelView: View { Spacer() } .frame(maxWidth: .infinity, alignment: .leading) - .padding(12) - .modernGlassSurface(cornerRadius: 14) + .padding(8) + .modernGlassSurface(cornerRadius: 10, depth: .section) } private var accountList: some View { ScrollView { - LazyVStack(spacing: 6) { + LazyVStack(spacing: 4) { ForEach(store.accounts) { account in AccountRowView( account: account, @@ -196,31 +217,32 @@ struct AccountPanelView: View { } private var footer: some View { - HStack(spacing: 8) { - Button { - loginPresented = true - } label: { - Label("Add Login", systemImage: "plus.circle") - } - .primaryPanelButtonStyle() - - Button { - Task { - await store.clearSelection() + HStack(spacing: 5) { + PanelPrimaryButtonView( + title: "Add Login", + symbol: "plus.circle", + action: { + loginPresented = true } - } label: { - Label("Balanced", systemImage: "arrow.triangle.branch") - } - .secondaryPanelButtonStyle() - .disabled(!hasFixedSelection) + ) - Spacer() + PanelIconButtonView( + symbol: "arrow.triangle.branch", + tint: .accentColor, + isActive: false, + isDisabled: !hasFixedSelection, + action: { + Task { + await store.clearSelection() + } + }, + help: "Return Decodex runs to balanced selection" + ) SettingsLink { - Image(systemName: "gearshape") - .frame(width: 18, height: 18) + PanelIconLabelView(symbol: "gearshape", tint: .secondary) } - .iconPanelButtonStyle() + .buttonStyle(.plain) .help("Settings") } } @@ -272,16 +294,22 @@ struct AccountPanelView: View { private var accountListHeight: CGFloat { let rows = store.accounts.reduce(CGFloat(0)) { total, account in - total + (account.hasUsageWindowData ? 72 : 50) + total + (account.hasUsageWindowData ? 64 : 44) } - let spacing = CGFloat(max(store.accounts.count - 1, 0)) * 6 + 2 + let spacing = CGFloat(max(store.accounts.count - 1, 0)) * 4 + 2 return min( rows + spacing, - 286 + 248 ) } + private var headerSubtitle: String { + let count = store.accounts.count + let accountLabel = "\(count) account\(count == 1 ? "" : "s")" + return hasFixedSelection ? "\(accountLabel) / pinned runs" : "\(accountLabel) / balanced runs" + } + private var emailsHidden: Bool { accountPrivacy != AccountPrivacy.visibleValue } @@ -305,95 +333,102 @@ struct AccountRowView: View { let logout: () -> Void var body: some View { - HStack(spacing: 8) { - Button(action: useInCodex) { - HStack(spacing: 11) { - AccountAvatarView(account: account, title: displayName) - - VStack(alignment: .leading, spacing: 4) { - Text(displayName) - .font(.subheadline.weight(.semibold)) - .lineLimit(1) - .truncationMode(.middle) - HStack(spacing: 6) { - Text(detailLabel) - .lineLimit(1) - .truncationMode(.middle) - Text("·") - if let planLabel = account.planLabel { - Text(planLabel) - .lineLimit(1) - Text("·") - } - Text(account.statusLabel) - .lineLimit(1) - } - .font(.caption) - .foregroundStyle(.secondary) - - if account.hasUsageWindowData { - HStack(spacing: 6) { - AccountUsageBadgeView( - label: account.windowLabel(seconds: account.primaryWindowSeconds), - remainingPercent: account.primaryRemainingPercent, - tone: account.usageTone( - remainingPercent: account.primaryRemainingPercent - ) - ) - AccountUsageBadgeView( - label: account.windowLabel(seconds: account.secondaryWindowSeconds), - remainingPercent: account.secondaryRemainingPercent, - tone: account.usageTone( - remainingPercent: account.secondaryRemainingPercent - ) - ) - } - } - } + HStack(spacing: 7) { + AccountAvatarView(account: account, title: displayName) - Spacer() + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 4) { + Text(displayName) + .font(.caption.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) if account.codexActive { - StatusPillView(title: "Active", symbol: "bolt.fill", tint: .yellow) - } else { - Text("Use") - .font(.caption.weight(.semibold)) - .foregroundStyle(.secondary) + StatusMarkerView(symbol: "bolt.fill", tint: .yellow) } - } - .padding(.vertical, 6) - .padding(.leading, 8) - .padding(.trailing, 10) - .contentShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - } - .buttonStyle(.plain) - .help("Use in Codex") - Menu { - Button(action: pinForDecodex) { - Label( - account.selected ? "Use balanced Decodex selection" : "Pin for Decodex runs", - systemImage: account.selected ? "pin.slash" : "pin" - ) + if account.selected { + StatusMarkerView(symbol: "pin.fill", tint: .accentColor) + } } - Divider() + HStack(spacing: 4) { + Text(detailLabel) + .lineLimit(1) + .truncationMode(.middle) + Text("·") + if let planLabel = account.planLabel { + Text(planLabel) + .lineLimit(1) + Text("·") + } + Text(account.statusLabel) + .lineLimit(1) + } + .font(.caption2) + .foregroundStyle(.secondary) - Button(role: .destructive, action: logout) { - Label("Remove account", systemImage: "rectangle.portrait.and.arrow.right") + if account.hasUsageWindowData { + HStack(spacing: 4) { + AccountUsageBadgeView( + label: account.windowLabel(seconds: account.primaryWindowSeconds), + remainingPercent: account.primaryRemainingPercent, + tone: account.usageTone( + remainingPercent: account.primaryRemainingPercent + ) + ) + AccountUsageBadgeView( + label: account.windowLabel(seconds: account.secondaryWindowSeconds), + remainingPercent: account.secondaryRemainingPercent, + tone: account.usageTone( + remainingPercent: account.secondaryRemainingPercent + ) + ) + } } - } label: { - Image(systemName: "ellipsis") - .frame(width: 18, height: 18) } - .menuStyle(.button) - .buttonStyle(.borderless) - .help("More actions") + .frame(maxWidth: .infinity, alignment: .leading) + + Spacer(minLength: 4) + + HStack(spacing: 3) { + PanelIconButtonView( + symbol: account.codexActive ? "bolt.fill" : "bolt", + tint: .yellow, + isActive: account.codexActive, + size: 22, + action: useInCodex, + help: account.codexActive ? "Already active in Codex" : "Use in Codex" + ) + + PanelIconButtonView( + symbol: account.selected ? "pin.fill" : "pin", + tint: .accentColor, + isActive: account.selected, + size: 22, + action: pinForDecodex, + help: account.selected ? "Return Decodex to balanced selection" : "Pin for Decodex runs" + ) + + PanelIconButtonView( + symbol: "trash", + tint: .red, + isActive: false, + isDestructive: true, + size: 22, + action: logout, + help: "Remove account" + ) + } } - .padding(3) + .padding(.vertical, 5) + .padding(.leading, 6) + .padding(.trailing, 6) .modernGlassSurface( - cornerRadius: 15, + cornerRadius: 11, tint: account.rowTint, + depth: .row, interactive: true ) } @@ -413,16 +448,20 @@ struct AccountUsageBadgeView: View { let tone: AccountTone var body: some View { - HStack(spacing: 4) { + HStack(spacing: 3) { Text(label) - .font(.caption2.weight(.semibold)) + .font(.caption2.weight(.medium)) .foregroundStyle(.secondary) Text(remainingText) .font(.caption2.monospacedDigit().weight(.semibold)) } - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(color.opacity(0.16), in: Capsule()) + .padding(.horizontal, 4) + .padding(.vertical, 1) + .modernGlassSurface( + cornerRadius: 7, + tint: color.opacity(0.16), + depth: .badge + ) } private var remainingText: String { @@ -449,7 +488,7 @@ struct NoticeView: View { let text: String var body: some View { - HStack(alignment: .top, spacing: 8) { + HStack(alignment: .top, spacing: 7) { Image(systemName: "exclamationmark.triangle") .foregroundStyle(.yellow) Text(text) @@ -457,35 +496,170 @@ struct NoticeView: View { .foregroundStyle(.secondary) .fixedSize(horizontal: false, vertical: true) } - .padding(10) - .modernGlassSurface(cornerRadius: 14, tint: .yellow.opacity(0.08)) + .padding(8) + .modernGlassSurface( + cornerRadius: 10, + tint: .yellow.opacity(0.12), + depth: .section + ) } } -struct SummaryRowView: View { +struct SummaryTileView: View { let title: String let value: String let symbol: String let tint: Color var body: some View { - HStack(spacing: 8) { + HStack(spacing: 5) { Image(systemName: symbol) - .font(.caption.weight(.semibold)) + .font(.caption2.weight(.semibold)) .foregroundStyle(tint) - .frame(width: 16) + .frame(width: 11) - Text(title) - .font(.caption) - .foregroundStyle(.secondary) + VStack(alignment: .leading, spacing: 1) { + Text(title) + .font(.caption2.weight(.medium)) + .foregroundStyle(.secondary) + .lineLimit(1) - Spacer(minLength: 12) + Text(value) + .font(.caption2.weight(.semibold)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +struct StatusMarkerView: View { + let symbol: String + let tint: Color - Text(value) - .font(.caption.weight(.medium)) - .lineLimit(1) - .truncationMode(.middle) + var body: some View { + Image(systemName: symbol) + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(tint) + .frame(width: 14, height: 14) + .modernGlassSurface(cornerRadius: 7, tint: tint.opacity(0.16), depth: .badge) + } +} + +struct PanelPrimaryButtonView: View { + let title: String + let symbol: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Label(title, systemImage: symbol) + .font(.caption.weight(.semibold)) + .frame(maxWidth: .infinity, minHeight: 24) + .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) } + .buttonStyle(.plain) + .foregroundStyle(.primary) + .modernGlassSurface( + cornerRadius: 9, + tint: Color.accentColor.opacity(0.2), + depth: .control, + interactive: true + ) + .help(title) + } +} + +struct PanelIconButtonView: View { + let symbol: String + let tint: Color + let isActive: Bool + let isDestructive: Bool + let isDisabled: Bool + let size: CGFloat + let action: () -> Void + let help: String + + init( + symbol: String, + tint: Color, + isActive: Bool, + isDestructive: Bool = false, + isDisabled: Bool = false, + size: CGFloat = 24, + action: @escaping () -> Void, + help: String + ) { + self.symbol = symbol + self.tint = tint + self.isActive = isActive + self.isDestructive = isDestructive + self.isDisabled = isDisabled + self.size = size + self.action = action + self.help = help + } + + var body: some View { + Button(action: action) { + Image(systemName: symbol) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(foregroundColor) + .frame(width: size, height: size) + .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) + } + .buttonStyle(.plain) + .disabled(isDisabled) + .modernGlassSurface( + cornerRadius: 7, + tint: surfaceTint, + depth: .control, + interactive: !isDisabled + ) + .help(help) + } + + private var foregroundColor: Color { + if isDisabled { + return Color.secondary.opacity(0.45) + } + if usesTint { + return tint + } + return .secondary + } + + private var surfaceTint: Color { + if isDisabled { + return Color.primary.opacity(0.02) + } + if usesTint { + return tint.opacity(isDestructive ? 0.07 : 0.11) + } + return Color.primary.opacity(0.03) + } + + private var usesTint: Bool { + isActive || isDestructive + } +} + +struct PanelIconLabelView: View { + let symbol: String + let tint: Color + + var body: some View { + Image(systemName: symbol) + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(tint) + .frame(width: 24, height: 24) + .modernGlassSurface( + cornerRadius: 7, + tint: Color.primary.opacity(0.03), + depth: .control, + interactive: true + ) } } @@ -496,43 +670,25 @@ struct AccountAvatarView: View { var body: some View { ZStack(alignment: .bottomTrailing) { Circle() - .fill(account.statusColor.opacity(0.14)) + .fill(account.statusColor.opacity(0.12)) .overlay { Circle() - .strokeBorder(account.statusColor.opacity(0.26), lineWidth: 1) + .strokeBorder(account.statusColor.opacity(0.22), lineWidth: 1) } Text(AccountDisplay.initials(from: title)) - .font(.caption.weight(.semibold)) + .font(.caption2.weight(.semibold)) .foregroundStyle(.primary) Circle() .fill(account.statusColor) - .frame(width: 8, height: 8) + .frame(width: 6, height: 6) .overlay { Circle() .stroke(.background, lineWidth: 1.5) } } - .frame(width: 29, height: 29) - } -} - -struct StatusPillView: View { - let title: String - let symbol: String - let tint: Color - - var body: some View { - HStack(spacing: 4) { - Image(systemName: symbol) - Text(title) - } - .font(.caption2.weight(.semibold)) - .foregroundStyle(tint) - .padding(.horizontal, 7) - .padding(.vertical, 4) - .modernGlassSurface(cornerRadius: 9, tint: tint.opacity(0.12)) + .frame(width: 22, height: 22) } } @@ -658,11 +814,20 @@ private enum AccountDisplay { } } +private enum GlassSurfaceDepth { + case panel + case section + case row + case control + case badge +} + private extension View { @ViewBuilder func modernGlassSurface( cornerRadius: CGFloat, tint: Color? = nil, + depth: GlassSurfaceDepth = .section, interactive: Bool = false ) -> some View { if #available(macOS 26.0, *) { @@ -670,44 +835,29 @@ private extension View { configuredGlass(tint: tint, interactive: interactive), in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ) + .glassSurfaceFinish(cornerRadius: cornerRadius, depth: depth) } else { self .background( tint.map { AnyShapeStyle($0) } ?? AnyShapeStyle(.regularMaterial), in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) ) - .overlay { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .strokeBorder(Color(nsColor: .separatorColor).opacity(0.28), lineWidth: 0.5) - } - } - } - - @ViewBuilder - func primaryPanelButtonStyle() -> some View { - if #available(macOS 26.0, *) { - self.buttonStyle(.glassProminent) - } else { - self.buttonStyle(.borderedProminent) - } - } - - @ViewBuilder - func secondaryPanelButtonStyle() -> some View { - if #available(macOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.bordered) + .glassSurfaceFinish(cornerRadius: cornerRadius, depth: depth) } } - @ViewBuilder - func iconPanelButtonStyle() -> some View { - if #available(macOS 26.0, *) { - self.buttonStyle(.glass) - } else { - self.buttonStyle(.borderless) - } + func glassSurfaceFinish(cornerRadius: CGFloat, depth: GlassSurfaceDepth) -> some View { + self + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(edgeGradient(for: depth), lineWidth: edgeWidth(for: depth)) + } + .shadow( + color: Color.black.opacity(shadowOpacity(for: depth)), + radius: shadowRadius(for: depth), + x: 0, + y: shadowOffset(for: depth) + ) } @available(macOS 26.0, *) @@ -722,6 +872,88 @@ private extension View { return glass } + + private func edgeGradient(for depth: GlassSurfaceDepth) -> LinearGradient { + LinearGradient( + colors: [ + Color.white.opacity(edgeHighlightOpacity(for: depth)), + Color.white.opacity(edgeMidOpacity(for: depth)), + Color.black.opacity(edgeShadowOpacity(for: depth)), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + } + + private func edgeWidth(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 0.9 + case .section: return 0.65 + case .row: return 0.55 + case .control: return 0.6 + case .badge: return 0.45 + } + } + + private func edgeHighlightOpacity(for depth: GlassSurfaceDepth) -> Double { + switch depth { + case .panel: return 0.34 + case .section: return 0.24 + case .row: return 0.2 + case .control: return 0.28 + case .badge: return 0.18 + } + } + + private func edgeMidOpacity(for depth: GlassSurfaceDepth) -> Double { + switch depth { + case .panel: return 0.1 + case .section: return 0.08 + case .row: return 0.06 + case .control: return 0.09 + case .badge: return 0.05 + } + } + + private func edgeShadowOpacity(for depth: GlassSurfaceDepth) -> Double { + switch depth { + case .panel: return 0.18 + case .section: return 0.12 + case .row: return 0.1 + case .control: return 0.12 + case .badge: return 0.08 + } + } + + private func shadowOpacity(for depth: GlassSurfaceDepth) -> Double { + switch depth { + case .panel: return 0.18 + case .section: return 0.1 + case .row: return 0.07 + case .control: return 0.08 + case .badge: return 0.04 + } + } + + private func shadowRadius(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 18 + case .section: return 8 + case .row: return 5 + case .control: return 5 + case .badge: return 2 + } + } + + private func shadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 10 + case .section: return 4 + case .row: return 2 + case .control: return 2 + case .badge: return 1 + } + } } private extension CodexAccount { From 8aba7bdbfde1c208e40d55011ee2082d9873e8ae Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 20 May 2026 08:53:51 +0800 Subject: [PATCH 2/8] {"schema":"decodex/commit/1","summary":"Polish Decodex app account panel","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 1240 ++++++++++++----- .../Sources/DecodexApp/AccountStore.swift | 127 ++ .../Sources/DecodexApp/LoginSheetView.swift | 385 ++++- .../Sources/DecodexApp/Models.swift | 8 +- 4 files changed, 1369 insertions(+), 391 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 19cbfb0c5..311f2bd84 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -1,7 +1,158 @@ +import Foundation import SwiftUI +private enum PanelFont { + private static func text( + _ size: CGFloat, + weight: Font.Weight, + design: Font.Design = .default + ) -> Font { + .system(size: size, weight: weight, design: design) + } + + static let headerIcon = text(13.6, weight: .semibold) + static let headerTitle = text(13.6, weight: .semibold) + static let headerSubtitle = text(10.1, weight: .medium) + static let emptyIcon = text(16.5, weight: .medium) + static let emptyTitle = text(11.3, weight: .semibold) + static let emptyBody = text(10.1, weight: .regular) + static let notice = text(9.9, weight: .regular) + static let summaryIcon = text(9.7, weight: .medium) + static let summaryTitle = text(9.4, weight: .medium) + static let summaryValue = text(10.9, weight: .semibold) + static let accountName = text(11.9, weight: .semibold) + static let accountDetail = text(9.9, weight: .medium) + static let usage = text(8.45, weight: .medium) + static let usageMeta = text(7.85, weight: .medium) + static let primaryButton = text(10.7, weight: .semibold) + static let iconButton = text(10, weight: .medium) + static let footerIcon = text(10.1, weight: .medium) +} + +private enum PanelPalette { + static func primaryText(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.9, green: 0.94, blue: 0.99).opacity(0.94) + : Color(red: 0.11, green: 0.17, blue: 0.26).opacity(0.94) + } + + static func secondaryText(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.66, green: 0.74, blue: 0.84).opacity(0.78) + : Color(red: 0.3, green: 0.4, blue: 0.53).opacity(0.8) + } + + static func panelTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.1, green: 0.18, blue: 0.28).opacity(0.5) + : Color(red: 0.56, green: 0.73, blue: 0.9).opacity(0.5) + } + + static func summaryTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.63, green: 0.73, blue: 0.86).opacity(0.115) + : Color.white.opacity(0.48) + } + + static func accountRowTint(_ colorScheme: ColorScheme, isSelected: Bool, isCodexActive: Bool) -> Color { + if isSelected { + return actionBlue(colorScheme).opacity(colorScheme == .dark ? 0.13 : 0.12) + } + if isCodexActive { + return colorScheme == .dark + ? Color(red: 0.62, green: 0.72, blue: 0.84).opacity(0.12) + : Color.white.opacity(0.48) + } + + return colorScheme == .dark + ? Color(red: 0.58, green: 0.68, blue: 0.8).opacity(0.115) + : Color.white.opacity(0.44) + } + + static func usageTray(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.7, green: 0.8, blue: 0.93).opacity(0.045) + : Color.white.opacity(0.16) + } + + static func usageTrayStroke(_ colorScheme: ColorScheme) -> Color { + Color.white.opacity(colorScheme == .dark ? 0.055 : 0.18) + } + + static func addButtonTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.56, green: 0.7, blue: 0.88).opacity(0.24) + : Color(red: 0.86, green: 0.94, blue: 1).opacity(0.84) + } + + static func addButtonStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.78, green: 0.88, blue: 1).opacity(0.22) + : Color(red: 0.24, green: 0.43, blue: 0.64).opacity(0.42) + } + + static func controlTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.68, green: 0.8, blue: 0.94).opacity(0.2) + : Color(red: 0.92, green: 0.97, blue: 1).opacity(0.76) + } + + static func controlStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.8, green: 0.9, blue: 1).opacity(0.18) + : Color(red: 0.28, green: 0.46, blue: 0.66).opacity(0.38) + } + + static func separator(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.white.opacity(0.065) + : Color(red: 0.42, green: 0.58, blue: 0.75).opacity(0.18) + } + + static func actionBlue(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.72, green: 0.84, blue: 0.98) + : Color(red: 0.1, green: 0.28, blue: 0.46) + } + + static func activeGold(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.72, green: 0.82, blue: 0.93) + : Color(red: 0.28, green: 0.42, blue: 0.55) + } + + static func usageMint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.6, green: 0.73, blue: 0.82) + : Color(red: 0.38, green: 0.56, blue: 0.64) + } + + static func warning(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.88, green: 0.58, blue: 0.35) + : Color(red: 0.62, green: 0.36, blue: 0.14) + } + + static func destructive(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.82, green: 0.55, blue: 0.58) + : Color(red: 0.55, green: 0.31, blue: 0.35) + } + + static func progressTrack(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.72, green: 0.82, blue: 0.92).opacity(0.095) + : Color(red: 0.13, green: 0.28, blue: 0.4).opacity(0.11) + } + + static func progressEdge(_ colorScheme: ColorScheme) -> Color { + Color.white.opacity(colorScheme == .dark ? 0.1 : 0.18) + } +} + struct AccountPanelView: View { @ObservedObject var store: AccountStore + @Environment(\.colorScheme) private var colorScheme @State private var pendingLogout: CodexAccount? @State private var loginPresented = false @AppStorage("decodex.operator.accountPrivacy") private var accountPrivacy = AccountPrivacy.hiddenValue @@ -46,7 +197,7 @@ struct AccountPanelView: View { } private var panelContent: some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 6) { header accountSummary @@ -68,11 +219,11 @@ struct AccountPanelView: View { footer } - .frame(width: 318) - .padding(10) + .frame(width: 310) + .padding(9) .modernGlassSurface( cornerRadius: 18, - tint: Color.accentColor.opacity(0.035), + tint: PanelPalette.panelTint(colorScheme), depth: .panel ) .controlSize(.small) @@ -82,21 +233,22 @@ struct AccountPanelView: View { private var header: some View { HStack(alignment: .center, spacing: 8) { Image(systemName: store.menuSymbol) - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(Color.accentColor) + .font(PanelFont.headerIcon) + .foregroundStyle(PanelPalette.actionBlue(colorScheme)) .frame(width: 28, height: 28) .modernGlassSurface( - cornerRadius: 9, - tint: Color.accentColor.opacity(0.16), + cornerRadius: 8, + tint: PanelPalette.controlTint(colorScheme), depth: .control ) VStack(alignment: .leading, spacing: 2) { Text("Decodex") - .font(.system(size: 13, weight: .semibold)) + .font(PanelFont.headerTitle) + .foregroundStyle(PanelPalette.primaryText(colorScheme)) Text(headerSubtitle) - .font(.caption2) - .foregroundStyle(.secondary) + .font(PanelFont.headerSubtitle) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) .lineLimit(1) } @@ -105,7 +257,7 @@ struct AccountPanelView: View { HStack(spacing: 4) { PanelIconButtonView( symbol: emailsHidden ? "eye.slash" : "eye", - tint: .secondary, + tint: PanelPalette.secondaryText(colorScheme), isActive: false, action: { accountPrivacy = emailsHidden ? AccountPrivacy.visibleValue : AccountPrivacy.hiddenValue @@ -115,7 +267,7 @@ struct AccountPanelView: View { PanelIconButtonView( symbol: store.isRefreshing ? "arrow.triangle.2.circlepath.circle" : "arrow.clockwise", - tint: .secondary, + tint: PanelPalette.secondaryText(colorScheme), isActive: store.isRefreshing, isDisabled: store.isRefreshing, action: { @@ -135,25 +287,26 @@ struct AccountPanelView: View { title: "Codex", value: codexAuthLabel, symbol: "bolt.fill", - tint: .yellow + tint: PanelPalette.activeGold(colorScheme) ) - Divider() - .opacity(0.5) + Rectangle() + .fill(PanelPalette.separator(colorScheme)) + .frame(width: 0.5) .padding(.vertical, 3) SummaryTileView( title: "Runs", value: decodexModeLabel, symbol: hasFixedSelection ? "pin.fill" : "arrow.triangle.branch", - tint: hasFixedSelection ? .accentColor : .secondary + tint: hasFixedSelection ? PanelPalette.actionBlue(colorScheme) : PanelPalette.secondaryText(colorScheme) ) } .padding(.horizontal, 7) - .padding(.vertical, 5) + .padding(.vertical, 4) .modernGlassSurface( - cornerRadius: 11, - tint: Color.primary.opacity(0.045), + cornerRadius: 9, + tint: PanelPalette.summaryTint(colorScheme), depth: .section ) } @@ -161,18 +314,19 @@ struct AccountPanelView: View { private var emptyState: some View { VStack(alignment: .leading, spacing: 6) { Image(systemName: "person.crop.circle.badge.plus") - .font(.system(size: 19)) - .foregroundStyle(.secondary) + .font(PanelFont.emptyIcon) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) Text("No accounts in the local pool") - .font(.subheadline.weight(.semibold)) + .font(PanelFont.emptyTitle) + .foregroundStyle(PanelPalette.primaryText(colorScheme)) Text("Add a ChatGPT login before switching the Codex auth file.") - .font(.caption) - .foregroundStyle(.secondary) + .font(PanelFont.emptyBody) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) .fixedSize(horizontal: false, vertical: true) } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) - .modernGlassSurface(cornerRadius: 10, depth: .section) + .modernGlassSurface(cornerRadius: 9, depth: .section) } private var loadingState: some View { @@ -180,40 +334,52 @@ struct AccountPanelView: View { ProgressView() .controlSize(.small) Text("Loading accounts") - .font(.subheadline.weight(.semibold)) + .font(PanelFont.emptyTitle) + .foregroundStyle(PanelPalette.primaryText(colorScheme)) Spacer() } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) - .modernGlassSurface(cornerRadius: 10, depth: .section) + .modernGlassSurface(cornerRadius: 9, depth: .section) } private var accountList: some View { - ScrollView { - LazyVStack(spacing: 4) { - ForEach(store.accounts) { account in - AccountRowView( - account: account, - emailsHidden: emailsHidden, - useInCodex: { - Task { - await store.useInCodex(account) - } - }, - pinForDecodex: { - Task { - await store.select(account) - } - }, - logout: { - pendingLogout = account - } - ) + Group { + if store.accounts.count <= 3 { + accountRows + } else { + ScrollView { + accountRows } + .frame(height: accountListHeight) + .scrollIndicators(.hidden) + } + } + } + + private var accountRows: some View { + VStack(spacing: 5) { + ForEach(store.accounts) { account in + AccountRowView( + account: account, + emailsHidden: emailsHidden, + useInCodex: { + Task { + await store.useInCodex(account) + } + }, + pinForDecodex: { + Task { + await store.select(account) + } + }, + logout: { + pendingLogout = account + } + ) } - .padding(.vertical, 1) } - .frame(height: accountListHeight) + .padding(.vertical, 1) } private var footer: some View { @@ -228,7 +394,7 @@ struct AccountPanelView: View { PanelIconButtonView( symbol: "arrow.triangle.branch", - tint: .accentColor, + tint: PanelPalette.actionBlue(colorScheme), isActive: false, isDisabled: !hasFixedSelection, action: { @@ -240,7 +406,7 @@ struct AccountPanelView: View { ) SettingsLink { - PanelIconLabelView(symbol: "gearshape", tint: .secondary) + PanelIconLabelView(symbol: "gearshape", tint: PanelPalette.actionBlue(colorScheme)) } .buttonStyle(.plain) .help("Settings") @@ -294,13 +460,13 @@ struct AccountPanelView: View { private var accountListHeight: CGFloat { let rows = store.accounts.reduce(CGFloat(0)) { total, account in - total + (account.hasUsageWindowData ? 64 : 44) + total + (account.hasUsageSummary ? 82 : 44) } - let spacing = CGFloat(max(store.accounts.count - 1, 0)) * 4 + 2 + let spacing = CGFloat(max(store.accounts.count - 1, 0)) * 5 + 2 return min( rows + spacing, - 248 + 278 ) } @@ -331,105 +497,88 @@ struct AccountRowView: View { let useInCodex: () -> Void let pinForDecodex: () -> Void let logout: () -> Void + @Environment(\.colorScheme) private var colorScheme var body: some View { - HStack(spacing: 7) { - AccountAvatarView(account: account, title: displayName) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 4) { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .top, spacing: 8) { + VStack(alignment: .leading, spacing: 2) { Text(displayName) - .font(.caption.weight(.semibold)) + .font(PanelFont.accountName) + .foregroundStyle(PanelPalette.primaryText(colorScheme)) .lineLimit(1) .truncationMode(.middle) .layoutPriority(1) - if account.codexActive { - StatusMarkerView(symbol: "bolt.fill", tint: .yellow) - } - - if account.selected { - StatusMarkerView(symbol: "pin.fill", tint: .accentColor) + if account.hasVisibleMetadata { + HStack(spacing: 4) { + if let planLabel = account.planLabel { + Text(planLabel) + .lineLimit(1) + } + if account.planLabel != nil, account.compactHealthLabel != nil { + Text("·") + } + if let healthLabel = account.compactHealthLabel { + Text(healthLabel) + .foregroundStyle(account.statusDisplayColor(colorScheme: colorScheme)) + .lineLimit(1) + } + } + .font(PanelFont.accountDetail) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) } } + .frame(maxWidth: .infinity, alignment: .leading) + + HStack(spacing: 3) { + PanelIconButtonView( + symbol: account.codexActive ? "bolt.fill" : "bolt", + tint: PanelPalette.activeGold(colorScheme), + isActive: account.codexActive, + isSubtle: true, + size: 22, + action: useInCodex, + help: account.codexActive ? "Already active in Codex" : "Use in Codex" + ) - HStack(spacing: 4) { - Text(detailLabel) - .lineLimit(1) - .truncationMode(.middle) - Text("·") - if let planLabel = account.planLabel { - Text(planLabel) - .lineLimit(1) - Text("·") - } - Text(account.statusLabel) - .lineLimit(1) - } - .font(.caption2) - .foregroundStyle(.secondary) - - if account.hasUsageWindowData { - HStack(spacing: 4) { - AccountUsageBadgeView( - label: account.windowLabel(seconds: account.primaryWindowSeconds), - remainingPercent: account.primaryRemainingPercent, - tone: account.usageTone( - remainingPercent: account.primaryRemainingPercent - ) - ) - AccountUsageBadgeView( - label: account.windowLabel(seconds: account.secondaryWindowSeconds), - remainingPercent: account.secondaryRemainingPercent, - tone: account.usageTone( - remainingPercent: account.secondaryRemainingPercent - ) - ) - } + PanelIconButtonView( + symbol: account.selected ? "pin.fill" : "pin", + tint: PanelPalette.actionBlue(colorScheme), + isActive: account.selected, + isSubtle: true, + size: 22, + action: pinForDecodex, + help: account.selected ? "Return Decodex to balanced selection" : "Pin for Decodex runs" + ) + + PanelIconButtonView( + symbol: "trash", + tint: PanelPalette.destructive(colorScheme), + isActive: false, + isDestructive: true, + size: 22, + action: logout, + help: "Remove account" + ) } } - .frame(maxWidth: .infinity, alignment: .leading) - Spacer(minLength: 4) - - HStack(spacing: 3) { - PanelIconButtonView( - symbol: account.codexActive ? "bolt.fill" : "bolt", - tint: .yellow, - isActive: account.codexActive, - size: 22, - action: useInCodex, - help: account.codexActive ? "Already active in Codex" : "Use in Codex" - ) - - PanelIconButtonView( - symbol: account.selected ? "pin.fill" : "pin", - tint: .accentColor, - isActive: account.selected, - size: 22, - action: pinForDecodex, - help: account.selected ? "Return Decodex to balanced selection" : "Pin for Decodex runs" - ) - - PanelIconButtonView( - symbol: "trash", - tint: .red, - isActive: false, - isDestructive: true, - size: 22, - action: logout, - help: "Remove account" - ) + if account.hasUsageSummary { + AccountUsageSummaryView(account: account) } } .padding(.vertical, 5) - .padding(.leading, 6) - .padding(.trailing, 6) + .padding(.leading, 8) + .padding(.trailing, 7) .modernGlassSurface( - cornerRadius: 11, - tint: account.rowTint, - depth: .row, - interactive: true + cornerRadius: 8, + tint: PanelPalette.accountRowTint( + colorScheme, + isSelected: account.selected, + isCodexActive: account.codexActive + ), + depth: .row ) } @@ -437,31 +586,109 @@ struct AccountRowView: View { account.panelDisplayName(emailsHidden: emailsHidden) } - private var detailLabel: String { - account.panelDetailLabel(emailsHidden: emailsHidden) +} + +struct AccountUsageSummaryView: View { + let account: CodexAccount + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + VStack(spacing: 3) { + if account.hasPrimaryUsageData { + AccountUsageMeterView( + label: account.windowLabel(seconds: account.primaryWindowSeconds), + remainingPercent: account.primaryRemainingPercent, + resetAtUnixEpoch: account.primaryResetsAtUnixEpoch, + tone: account.usageTone(remainingPercent: account.primaryRemainingPercent) + ) + } + + if account.hasSecondaryUsageData { + AccountUsageMeterView( + label: account.windowLabel(seconds: account.secondaryWindowSeconds), + remainingPercent: account.secondaryRemainingPercent, + resetAtUnixEpoch: account.secondaryResetsAtUnixEpoch, + tone: account.usageTone(remainingPercent: account.secondaryRemainingPercent) + ) + } + } + .frame(maxWidth: .infinity) + .padding(.horizontal, 5) + .padding(.vertical, 3) + .background { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(PanelPalette.usageTray(colorScheme)) + } + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(PanelPalette.usageTrayStroke(colorScheme), lineWidth: 0.35) + .allowsHitTesting(false) + } } } -struct AccountUsageBadgeView: View { +struct AccountUsageMeterView: View { let label: String let remainingPercent: Int? + let resetAtUnixEpoch: Int? let tone: AccountTone + @Environment(\.colorScheme) private var colorScheme var body: some View { - HStack(spacing: 3) { - Text(label) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) - Text(remainingText) - .font(.caption2.monospacedDigit().weight(.semibold)) - } - .padding(.horizontal, 4) - .padding(.vertical, 1) - .modernGlassSurface( - cornerRadius: 7, - tint: color.opacity(0.16), - depth: .badge - ) + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(label) + .frame(width: 37, alignment: .leading) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + + Text(remainingText) + .frame(width: 32, alignment: .leading) + .foregroundStyle(valueColor) + .monospacedDigit() + + Spacer(minLength: 2) + + Text(resetDisplay.short) + .font(PanelFont.usageMeta) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(colorScheme == .dark ? 0.82 : 0.9)) + .monospacedDigit() + .lineLimit(1) + + if !resetDisplay.date.isEmpty { + Text(resetDisplay.date) + .font(PanelFont.usageMeta) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(colorScheme == .dark ? 0.68 : 0.78)) + .lineLimit(1) + .truncationMode(.middle) + } + } + .frame(height: 10) + + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(trackColor) + Capsule() + .fill(fillStyle) + .frame(width: fillWidth(in: proxy.size.width)) + .overlay(alignment: .top) { + Capsule() + .fill(Color.white.opacity(colorScheme == .dark ? 0.13 : 0.2)) + .frame(height: 0.6) + .allowsHitTesting(false) + } + Capsule() + .strokeBorder(trackEdgeColor, lineWidth: 0.35) + .allowsHitTesting(false) + } + } + .frame(height: 3.4) + } + .font(PanelFont.usage) + .lineLimit(1) + .frame(height: 16) + .frame(maxWidth: .infinity, alignment: .leading) + .accessibilityLabel("\(label) remaining \(remainingText), \(resetDisplay.accessibility)") } private var remainingText: String { @@ -472,34 +699,158 @@ struct AccountUsageBadgeView: View { return "\(remainingPercent)%" } + private var progress: CGFloat { + guard let remainingPercent else { + return 0 + } + + return CGFloat(max(0, min(100, remainingPercent))) / 100 + } + + private func fillWidth(in width: CGFloat) -> CGFloat { + guard remainingPercent != nil else { + return 0 + } + + return max(2, width * progress) + } + private var color: Color { switch tone { - case .codexActive: return .yellow - case .ready: return .green - case .selected: return .accentColor - case .warning: return .yellow - case .danger: return .red - case .neutral: return .secondary + case .codexActive: return PanelPalette.activeGold(colorScheme) + case .ready: return PanelPalette.usageMint(colorScheme) + case .selected: return PanelPalette.actionBlue(colorScheme) + case .warning: return PanelPalette.warning(colorScheme) + case .danger: return PanelPalette.destructive(colorScheme) + case .neutral: return PanelPalette.secondaryText(colorScheme) + } + } + + private var valueColor: Color { + switch tone { + case .warning, .danger: + return color.opacity(colorScheme == .dark ? 0.78 : 0.68) + default: + return color.opacity(colorScheme == .dark ? 0.66 : 0.66) } } + + private var trackColor: Color { + PanelPalette.progressTrack(colorScheme) + } + + private var trackEdgeColor: Color { + PanelPalette.progressEdge(colorScheme) + } + + private var resetDisplay: UsageResetDisplay { + UsageResetDisplay.make(resetAtUnixEpoch: resetAtUnixEpoch) + } + + private var fillStyle: LinearGradient { + LinearGradient( + colors: [ + color.opacity(colorScheme == .dark ? 0.56 : 0.5), + color.opacity(colorScheme == .dark ? 0.4 : 0.37), + ], + startPoint: .leading, + endPoint: .trailing + ) + } +} + +private struct UsageResetDisplay { + let short: String + let date: String + let accessibility: String + + static func make(resetAtUnixEpoch: Int?) -> UsageResetDisplay { + guard let seconds = resetAtUnixEpoch, seconds > 0 else { + return UsageResetDisplay( + short: "not reported", + date: "", + accessibility: "reset not reported" + ) + } + + let resetAt = Date(timeIntervalSince1970: TimeInterval(seconds)) + guard resetAt.timeIntervalSince1970.isFinite else { + return UsageResetDisplay( + short: "unknown", + date: "", + accessibility: "remaining unknown" + ) + } + + let distanceSeconds = Int(floor(resetAt.timeIntervalSinceNow)) + if distanceSeconds <= 0 { + let date = formatResetDate(resetAt) + return UsageResetDisplay( + short: "0m", + date: date, + accessibility: "reset at \(date), reset due now" + ) + } + + let short = formatResetDuration(distanceSeconds) + let date = formatResetDate(resetAt) + return UsageResetDisplay( + short: short, + date: date, + accessibility: "reset at \(date), resets in \(short)" + ) + } + + private static func formatResetDuration(_ seconds: Int) -> String { + let value = max(0, seconds) + if value < 60 { + return "<1m" + } + + let days = value / 86_400 + let hours = (value % 86_400) / 3_600 + let minutes = (value % 3_600) / 60 + + if days > 0 { + return hours > 0 ? "\(days)d \(hours)h" : "\(days)d" + } + + if hours > 0 { + return "\(hours)h \(minutes)m" + } + + return "\(minutes)m" + } + + private static func formatResetDate(_ date: Date) -> String { + let formatter = DateFormatter() + formatter.locale = .autoupdatingCurrent + let calendar = Calendar.autoupdatingCurrent + let template = calendar.component(.year, from: date) == calendar.component(.year, from: Date()) + ? "MMM d jm" + : "MMM d y jm" + formatter.setLocalizedDateFormatFromTemplate(template) + return formatter.string(from: date) + } } struct NoticeView: View { let text: String + @Environment(\.colorScheme) private var colorScheme var body: some View { HStack(alignment: .top, spacing: 7) { Image(systemName: "exclamationmark.triangle") - .foregroundStyle(.yellow) + .foregroundStyle(PanelPalette.warning(colorScheme)) Text(text) - .font(.caption) - .foregroundStyle(.secondary) + .font(PanelFont.notice) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) .fixedSize(horizontal: false, vertical: true) } .padding(8) .modernGlassSurface( - cornerRadius: 10, - tint: .yellow.opacity(0.12), + cornerRadius: 9, + tint: PanelPalette.warning(colorScheme).opacity(0.12), depth: .section ) } @@ -510,22 +861,24 @@ struct SummaryTileView: View { let value: String let symbol: String let tint: Color + @Environment(\.colorScheme) private var colorScheme var body: some View { HStack(spacing: 5) { Image(systemName: symbol) - .font(.caption2.weight(.semibold)) - .foregroundStyle(tint) + .font(PanelFont.summaryIcon) + .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.78 : 0.82)) .frame(width: 11) VStack(alignment: .leading, spacing: 1) { Text(title) - .font(.caption2.weight(.medium)) - .foregroundStyle(.secondary) + .font(PanelFont.summaryTitle) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) .lineLimit(1) Text(value) - .font(.caption2.weight(.semibold)) + .font(PanelFont.summaryValue) + .foregroundStyle(PanelPalette.primaryText(colorScheme)) .lineLimit(1) .truncationMode(.middle) } @@ -534,39 +887,31 @@ struct SummaryTileView: View { } } -struct StatusMarkerView: View { - let symbol: String - let tint: Color - - var body: some View { - Image(systemName: symbol) - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(tint) - .frame(width: 14, height: 14) - .modernGlassSurface(cornerRadius: 7, tint: tint.opacity(0.16), depth: .badge) - } -} - struct PanelPrimaryButtonView: View { let title: String let symbol: String let action: () -> Void + @Environment(\.colorScheme) private var colorScheme var body: some View { Button(action: action) { Label(title, systemImage: symbol) - .font(.caption.weight(.semibold)) - .frame(maxWidth: .infinity, minHeight: 24) - .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) + .font(PanelFont.primaryButton) + .frame(maxWidth: .infinity, minHeight: 22) + .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } .buttonStyle(.plain) - .foregroundStyle(.primary) + .foregroundStyle(PanelPalette.actionBlue(colorScheme)) .modernGlassSurface( - cornerRadius: 9, - tint: Color.accentColor.opacity(0.2), - depth: .control, - interactive: true + cornerRadius: 8, + tint: PanelPalette.addButtonTint(colorScheme), + depth: .row ) + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(PanelPalette.addButtonStroke(colorScheme), lineWidth: 0.55) + .allowsHitTesting(false) + } .help(title) } } @@ -577,9 +922,11 @@ struct PanelIconButtonView: View { let isActive: Bool let isDestructive: Bool let isDisabled: Bool + let isSubtle: Bool let size: CGFloat let action: () -> Void let help: String + @Environment(\.colorScheme) private var colorScheme init( symbol: String, @@ -587,6 +934,7 @@ struct PanelIconButtonView: View { isActive: Bool, isDestructive: Bool = false, isDisabled: Bool = false, + isSubtle: Bool = false, size: CGFloat = 24, action: @escaping () -> Void, help: String @@ -596,99 +944,99 @@ struct PanelIconButtonView: View { self.isActive = isActive self.isDestructive = isDestructive self.isDisabled = isDisabled + self.isSubtle = isSubtle self.size = size self.action = action self.help = help } var body: some View { + if usesSurface { + baseButton + .modernGlassSurface( + cornerRadius: 7, + tint: surfaceTint, + depth: .control + ) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(PanelPalette.controlStroke(colorScheme), lineWidth: 0.5) + .allowsHitTesting(false) + } + .help(help) + } else { + baseButton + .opacity(isDisabled ? 0.34 : 0.82) + .help(help) + } + } + + private var baseButton: some View { Button(action: action) { Image(systemName: symbol) - .font(.system(size: 10, weight: .semibold)) + .font(PanelFont.iconButton) .foregroundStyle(foregroundColor) .frame(width: size, height: size) .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) } .buttonStyle(.plain) .disabled(isDisabled) - .modernGlassSurface( - cornerRadius: 7, - tint: surfaceTint, - depth: .control, - interactive: !isDisabled - ) - .help(help) } private var foregroundColor: Color { if isDisabled { - return Color.secondary.opacity(0.45) + return PanelPalette.secondaryText(colorScheme).opacity(0.38) } - if usesTint { - return tint + if isActive { + return tint.opacity(colorScheme == .dark ? 0.9 : 0.86) + } + if isDestructive { + return tint.opacity(colorScheme == .dark ? 0.82 : 0.76) } - return .secondary + if isSubtle { + return tint.opacity(colorScheme == .dark ? 0.72 : 0.68) + } + return PanelPalette.actionBlue(colorScheme).opacity(colorScheme == .dark ? 0.88 : 0.86) } private var surfaceTint: Color { if isDisabled { - return Color.primary.opacity(0.02) + return PanelPalette.controlTint(colorScheme).opacity(0.42) + } + if isActive { + return tint.opacity(colorScheme == .dark ? 0.105 : 0.12) } - if usesTint { - return tint.opacity(isDestructive ? 0.07 : 0.11) + if isDestructive { + return tint.opacity(colorScheme == .dark ? 0.09 : 0.115) } - return Color.primary.opacity(0.03) + return PanelPalette.controlTint(colorScheme).opacity(isSubtle ? 0.76 : 1) } - private var usesTint: Bool { - isActive || isDestructive + private var usesSurface: Bool { + true } } struct PanelIconLabelView: View { let symbol: String let tint: Color + @Environment(\.colorScheme) private var colorScheme var body: some View { Image(systemName: symbol) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(tint) + .font(PanelFont.footerIcon) + .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.84 : 0.86)) .frame(width: 24, height: 24) .modernGlassSurface( cornerRadius: 7, - tint: Color.primary.opacity(0.03), - depth: .control, - interactive: true + tint: PanelPalette.controlTint(colorScheme), + depth: .control ) - } -} - -struct AccountAvatarView: View { - let account: CodexAccount - let title: String - - var body: some View { - ZStack(alignment: .bottomTrailing) { - Circle() - .fill(account.statusColor.opacity(0.12)) - .overlay { - Circle() - .strokeBorder(account.statusColor.opacity(0.22), lineWidth: 1) - } - - Text(AccountDisplay.initials(from: title)) - .font(.caption2.weight(.semibold)) - .foregroundStyle(.primary) - - Circle() - .fill(account.statusColor) - .frame(width: 6, height: 6) - .overlay { - Circle() - .stroke(.background, lineWidth: 1.5) - } - } - .frame(width: 22, height: 22) + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(PanelPalette.controlStroke(colorScheme), lineWidth: 0.5) + .allowsHitTesting(false) + } } } @@ -746,9 +1094,7 @@ private enum AccountDisplay { ] static func alias(for account: CodexAccount) -> String { - alias( - forIdentity: account.accountFingerprint.isEmpty ? account.selector : account.accountFingerprint - ) + alias(forIdentity: account.randomNameSeed) } static func alias(forIdentity identity: String) -> String { @@ -784,16 +1130,6 @@ private enum AccountDisplay { return "\(text.prefix(edgeLength))...\(text.suffix(edgeLength))" } - static func initials(from value: String) -> String { - let components = value - .split { !$0.isLetter && !$0.isNumber } - .prefix(2) - .compactMap(\.first) - let initials = String(components).uppercased() - - return initials.isEmpty ? "?" : initials - } - private static func trimLeadingEllipsis(_ value: String) -> String { let text = value.trimmingCharacters(in: .whitespacesAndNewlines) if text.hasPrefix("..."), text.dropFirst(3).contains("...") == false { @@ -819,158 +1155,325 @@ private enum GlassSurfaceDepth { case section case row case control - case badge } private extension View { - @ViewBuilder func modernGlassSurface( cornerRadius: CGFloat, tint: Color? = nil, - depth: GlassSurfaceDepth = .section, - interactive: Bool = false + depth: GlassSurfaceDepth = .section ) -> some View { - if #available(macOS 26.0, *) { - self.glassEffect( - configuredGlass(tint: tint, interactive: interactive), - in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + modifier( + ModernGlassSurfaceModifier( + cornerRadius: cornerRadius, + tint: tint, + depth: depth ) - .glassSurfaceFinish(cornerRadius: cornerRadius, depth: depth) + ) + } +} + +private struct ModernGlassSurfaceModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + let cornerRadius: CGFloat + let tint: Color? + let depth: GlassSurfaceDepth + + @ViewBuilder + func body(content: Content) -> some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + if #available(macOS 26.0, *) { + content + .background { + shape.fill(surfaceFill) + } + .glassEffect( + configuredGlass, + in: shape + ) + .glassSurfaceFinish( + cornerRadius: cornerRadius, + depth: depth, + colorScheme: colorScheme + ) } else { - self - .background( - tint.map { AnyShapeStyle($0) } ?? AnyShapeStyle(.regularMaterial), - in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + content + .background { + shape + .fill(materialStyle) + shape + .fill(surfaceFill) + } + .glassSurfaceFinish( + cornerRadius: cornerRadius, + depth: depth, + colorScheme: colorScheme ) - .glassSurfaceFinish(cornerRadius: cornerRadius, depth: depth) } } - func glassSurfaceFinish(cornerRadius: CGFloat, depth: GlassSurfaceDepth) -> some View { + @available(macOS 26.0, *) + private var configuredGlass: Glass { + var glass = Glass.regular + if let tint { + glass = glass.tint(tint) + } + + return glass + } + + private var materialStyle: AnyShapeStyle { + switch depth { + case .panel: + return AnyShapeStyle(.regularMaterial) + case .section, .row: + return AnyShapeStyle(.thinMaterial) + case .control: + return colorScheme == .dark ? AnyShapeStyle(.thinMaterial) : AnyShapeStyle(.ultraThinMaterial) + } + } + + private var surfaceFill: Color { + if let tint { + return tint + } + + if colorScheme == .dark { + switch depth { + case .panel: + return Color(hue: 0.59, saturation: 0.4, brightness: 0.38, opacity: 0.3) + case .section: + return Color(hue: 0.59, saturation: 0.25, brightness: 0.5, opacity: 0.12) + case .row: + return Color(hue: 0.59, saturation: 0.24, brightness: 0.52, opacity: 0.13) + case .control: + return Color(hue: 0.59, saturation: 0.27, brightness: 0.56, opacity: 0.115) + } + } + + switch depth { + case .panel: + return Color( + hue: 0.6, + saturation: 0.18, + brightness: 1, + opacity: 0.1 + ) + case .section: + return Color( + hue: 0.6, + saturation: 0.08, + brightness: 1, + opacity: 0.12 + ) + case .row: + return Color( + hue: 0.6, + saturation: 0.07, + brightness: 1, + opacity: 0.135 + ) + case .control: + return Color.white.opacity(0.145) + } + } +} + +private extension View { + func glassSurfaceFinish( + cornerRadius: CGFloat, + depth: GlassSurfaceDepth, + colorScheme: ColorScheme + ) -> some View { self .overlay { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .strokeBorder(edgeGradient(for: depth), lineWidth: edgeWidth(for: depth)) + .fill(sheenGradient(for: depth, colorScheme: colorScheme)) + .blendMode(.screen) + .allowsHitTesting(false) + } + .overlay { + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder( + edgeGradient(for: depth, colorScheme: colorScheme), + lineWidth: edgeWidth(for: depth) + ) + .allowsHitTesting(false) } .shadow( - color: Color.black.opacity(shadowOpacity(for: depth)), - radius: shadowRadius(for: depth), + color: ambientShadowColor(for: depth, colorScheme: colorScheme), + radius: ambientShadowRadius(for: depth), x: 0, - y: shadowOffset(for: depth) + y: ambientShadowOffset(for: depth) + ) + .shadow( + color: keyShadowColor(for: depth, colorScheme: colorScheme), + radius: keyShadowRadius(for: depth), + x: 0, + y: keyShadowOffset(for: depth) ) } - @available(macOS 26.0, *) - private func configuredGlass(tint: Color?, interactive: Bool) -> Glass { - var glass = Glass.regular - if let tint { - glass = glass.tint(tint) - } - if interactive { - glass = glass.interactive() - } - - return glass + private func edgeGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { + LinearGradient( + colors: [ + Color.white.opacity(edgeHighlightOpacity(for: depth, colorScheme: colorScheme)), + Color.white.opacity(edgeMidOpacity(for: depth, colorScheme: colorScheme)), + Color.black.opacity(edgeShadowOpacity(for: depth, colorScheme: colorScheme)), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) } - private func edgeGradient(for depth: GlassSurfaceDepth) -> LinearGradient { + private func sheenGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { LinearGradient( colors: [ - Color.white.opacity(edgeHighlightOpacity(for: depth)), - Color.white.opacity(edgeMidOpacity(for: depth)), - Color.black.opacity(edgeShadowOpacity(for: depth)), + Color.white.opacity(sheenOpacity(for: depth, colorScheme: colorScheme)), + Color.white.opacity(0), + Color.black.opacity(colorScheme == .dark ? 0.035 : 0.02), ], startPoint: .topLeading, endPoint: .bottomTrailing ) } + private func sheenOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { + switch depth { + case .panel: return colorScheme == .dark ? 0.075 : 0.11 + case .section: return colorScheme == .dark ? 0.055 : 0.08 + case .row: return colorScheme == .dark ? 0.06 : 0.085 + case .control: return colorScheme == .dark ? 0.085 : 0.11 + } + } + private func edgeWidth(for depth: GlassSurfaceDepth) -> CGFloat { switch depth { - case .panel: return 0.9 - case .section: return 0.65 - case .row: return 0.55 - case .control: return 0.6 - case .badge: return 0.45 + case .panel: return 0.65 + case .section: return 0.42 + case .row: return 0.38 + case .control: return 0.35 + } + } + + private func edgeHighlightOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { + switch depth { + case .panel: return colorScheme == .dark ? 0.23 : 0.32 + case .section: return colorScheme == .dark ? 0.145 : 0.2 + case .row: return colorScheme == .dark ? 0.18 : 0.21 + case .control: return colorScheme == .dark ? 0.23 : 0.28 + } + } + + private func edgeMidOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { + switch depth { + case .panel: return colorScheme == .dark ? 0.07 : 0.105 + case .section: return colorScheme == .dark ? 0.04 : 0.07 + case .row: return colorScheme == .dark ? 0.04 : 0.065 + case .control: return colorScheme == .dark ? 0.055 : 0.09 } } - private func edgeHighlightOpacity(for depth: GlassSurfaceDepth) -> Double { + private func edgeShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { - case .panel: return 0.34 - case .section: return 0.24 - case .row: return 0.2 - case .control: return 0.28 - case .badge: return 0.18 + case .panel: return colorScheme == .dark ? 0.085 : 0.075 + case .section: return colorScheme == .dark ? 0.04 : 0.06 + case .row: return colorScheme == .dark ? 0.035 : 0.055 + case .control: return colorScheme == .dark ? 0.055 : 0.07 } } - private func edgeMidOpacity(for depth: GlassSurfaceDepth) -> Double { + private func ambientShadowColor(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Color { + let opacity: Double switch depth { - case .panel: return 0.1 - case .section: return 0.08 - case .row: return 0.06 - case .control: return 0.09 - case .badge: return 0.05 + case .panel: + opacity = colorScheme == .dark ? 0.21 : 0.065 + case .section: + opacity = colorScheme == .dark ? 0.052 : 0.04 + case .row: + opacity = colorScheme == .dark ? 0.098 : 0.048 + case .control: + opacity = colorScheme == .dark ? 0.072 : 0.038 } + + return Color.black.opacity(opacity) } - private func edgeShadowOpacity(for depth: GlassSurfaceDepth) -> Double { + private func keyShadowColor(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Color { + let opacity: Double switch depth { - case .panel: return 0.18 - case .section: return 0.12 - case .row: return 0.1 - case .control: return 0.12 - case .badge: return 0.08 + case .panel: + opacity = colorScheme == .dark ? 0.048 : 0.028 + case .section: + opacity = colorScheme == .dark ? 0.035 : 0.026 + case .row: + opacity = colorScheme == .dark ? 0.046 : 0.028 + case .control: + opacity = colorScheme == .dark ? 0.048 : 0.028 } + + return Color(hue: 0.6, saturation: 0.32, brightness: 1).opacity(opacity) } - private func shadowOpacity(for depth: GlassSurfaceDepth) -> Double { + private func ambientShadowRadius(for depth: GlassSurfaceDepth) -> CGFloat { switch depth { - case .panel: return 0.18 - case .section: return 0.1 - case .row: return 0.07 - case .control: return 0.08 - case .badge: return 0.04 + case .panel: return 15 + case .section: return 5.5 + case .row: return 7.5 + case .control: return 4.5 } } - private func shadowRadius(for depth: GlassSurfaceDepth) -> CGFloat { + private func ambientShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { switch depth { - case .panel: return 18 - case .section: return 8 - case .row: return 5 - case .control: return 5 - case .badge: return 2 + case .panel: return 6 + case .section: return 2 + case .row: return 4 + case .control: return 1 } } - private func shadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { + private func keyShadowRadius(for depth: GlassSurfaceDepth) -> CGFloat { switch depth { - case .panel: return 10 + case .panel: return 5 case .section: return 4 + case .row: return 4 + case .control: return 3 + } + } + + private func keyShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 2 + case .section: return 2 case .row: return 2 - case .control: return 2 - case .badge: return 1 + case .control: return 1 } } } private extension CodexAccount { - func panelDisplayName(emailsHidden: Bool) -> String { - if emailsHidden { - return AccountDisplay.alias(for: self) + var randomNameSeed: String { + if !accountFingerprint.isEmpty { + return accountFingerprint + } + if let email, !email.isEmpty { + return email + } + if let planType, !planType.isEmpty { + return planType } - return AccountDisplay.compactEmail(displayName) + return "account" } - func panelDetailLabel(emailsHidden: Bool) -> String { + func panelDisplayName(emailsHidden: Bool) -> String { if emailsHidden { - return AccountDisplay.compactIdentity(accountFingerprint) + return AccountDisplay.alias(for: self) } - return accountFingerprint + return AccountDisplay.compactEmail(displayName) } func matchesSelector(_ value: String) -> Bool { @@ -978,25 +1481,62 @@ private extension CodexAccount { return selector == email || selector == accountFingerprint || selector == self.selector } - var statusColor: Color { + func statusDisplayColor(colorScheme: ColorScheme) -> Color { switch statusTone { - case .codexActive: return .yellow - case .ready: return .green - case .selected: return .accentColor - case .warning: return .yellow - case .danger: return .red - case .neutral: return .secondary + case .codexActive: + return PanelPalette.activeGold(colorScheme) + case .ready: + return PanelPalette.usageMint(colorScheme) + case .selected: + return PanelPalette.actionBlue(colorScheme) + case .warning: + return PanelPalette.warning(colorScheme) + case .danger: + return PanelPalette.destructive(colorScheme) + case .neutral: + return PanelPalette.secondaryText(colorScheme) } } - var rowTint: Color? { - if codexActive { - return Color.yellow.opacity(0.06) - } - if selected { - return Color.accentColor.opacity(0.06) + var hasPrimaryUsageData: Bool { + primaryRemainingPercent != nil || primaryWindowSeconds != nil || primaryResetsAtUnixEpoch != nil + } + + var hasSecondaryUsageData: Bool { + secondaryRemainingPercent != nil || secondaryWindowSeconds != nil || secondaryResetsAtUnixEpoch != nil + } + + var hasUsageSummary: Bool { + hasPrimaryUsageData || hasSecondaryUsageData + } + + var hasVisibleMetadata: Bool { + planLabel != nil || compactHealthLabel != nil + } + + var compactHealthLabel: String? { + if isUsageLimited { + return "Limited" } - return nil + switch status { + case "available": + return nil + case "usage_limited": + return "Limited" + case "probe_failed": + return "Usage unknown" + case "expired": + return "Refresh needed" + case "disabled": + return "Disabled" + case "cooldown": + return "Cooling" + case "unusable": + return "Needs login" + default: + let label = status.replacingOccurrences(of: "_", with: " ").capitalized + return label.isEmpty ? nil : label + } } } diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index 63f985bec..cb75b44bc 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -46,6 +46,27 @@ final class AccountStore: ObservableObject { return "person.2.circle" } + var loginPrompt: DeviceLoginPrompt? { + DeviceLoginPrompt.parse(loginTranscript) + } + + var loginStatusLabel: String { + if isLoggingIn { + return loginPrompt == nil ? "Requesting code" : "Waiting for browser sign-in" + } + if notice != nil { + return "Login failed" + } + if loginPrompt != nil { + return "Code ready" + } + if loginTranscript.isEmpty { + return "Ready" + } + + return "Importing account" + } + func refresh(force: Bool = false) async { guard !isRefreshing else { return @@ -141,3 +162,109 @@ final class AccountStore: ObservableObject { isLoggingIn = false } } + +struct DeviceLoginPrompt: Equatable { + let verificationURL: URL? + let userCode: String + + var compactCode: String { + userCode.filter { character in + character.isLetter || character.isNumber + } + } + + static func parse(_ transcript: String) -> DeviceLoginPrompt? { + let text = stripANSI(transcript) + guard let code = parseUserCode(from: text) else { + return nil + } + + return DeviceLoginPrompt( + verificationURL: parseVerificationURL(from: text), + userCode: code + ) + } + + private static func parseVerificationURL(from text: String) -> URL? { + let pattern = #"https?://[^\s\)>\]]+"# + guard + let expression = try? NSRegularExpression(pattern: pattern), + let match = expression.firstMatch( + in: text, + range: NSRange(text.startIndex.. String? { + let lines = text.components(separatedBy: .newlines) + if let codeLine = lineAfterCodePrompt(in: lines) { + return normalizedCode(from: codeLine) + } + + for line in lines.reversed() { + if let code = normalizedCode(from: line) { + return code + } + } + + return nil + } + + private static func lineAfterCodePrompt(in lines: [String]) -> String? { + for (index, line) in lines.enumerated() where line.localizedCaseInsensitiveContains("one-time code") { + for candidate in lines.dropFirst(index + 1) { + if normalizedCode(from: candidate) != nil { + return candidate + } + } + } + + return nil + } + + private static func normalizedCode(from line: String) -> String? { + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + return nil + } + + let allowed = CharacterSet(charactersIn: "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789- ") + guard trimmed.uppercased().unicodeScalars.allSatisfy({ allowed.contains($0) }) else { + return nil + } + + let compact = trimmed.uppercased().filter { character in + character.isLetter || character.isNumber + } + guard (6...12).contains(compact.count) else { + return nil + } + + if compact.count > 4 { + let split = compact.index(compact.startIndex, offsetBy: 4) + return "\(compact[.. String { + guard let expression = try? NSRegularExpression(pattern: "\u{001B}\\[[0-9;]*[A-Za-z]") else { + return value + } + + let range = NSRange(value.startIndex.. String { + guard characters.indices.contains(index) else { + return "" + } + + return characters[index] + } +} + +private struct LoginSmallButtonStyle: ButtonStyle { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(LoginFont.icon) + .foregroundStyle(LoginPalette.accent(colorScheme).opacity(isEnabled ? 0.9 : 0.34)) + .background { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill(LoginPalette.controlTint(colorScheme).opacity(configuration.isPressed ? 0.72 : 1)) + } + .overlay { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(LoginPalette.controlStroke(colorScheme), lineWidth: 0.5) + } + .opacity(configuration.isPressed ? 0.78 : 1) + } +} + +private struct LoginTextButtonStyle: ButtonStyle { + var isPrimary = false + @Environment(\.colorScheme) private var colorScheme + @Environment(\.isEnabled) private var isEnabled + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(LoginFont.button) + .foregroundStyle(foreground) + .padding(.horizontal, 11) + .frame(height: 25) + .background { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(background.opacity(configuration.isPressed ? 0.72 : 1)) + } + .overlay { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .strokeBorder(stroke, lineWidth: 0.55) + } + .opacity(isEnabled ? 1 : 0.46) + } + + private var foreground: Color { + if isPrimary { + return LoginPalette.accent(colorScheme) + } + + return LoginPalette.secondaryText(colorScheme) + } + + private var background: Color { + isPrimary ? LoginPalette.primaryButtonTint(colorScheme) : LoginPalette.controlTint(colorScheme) + } + + private var stroke: Color { + isPrimary ? LoginPalette.primaryButtonStroke(colorScheme) : LoginPalette.controlStroke(colorScheme) + } +} + +private enum LoginFont { + static let title = Font.system(size: 13.4, weight: .semibold) + static let caption = Font.system(size: 9.6, weight: .medium) + static let button = Font.system(size: 10.3, weight: .semibold) + static let icon = Font.system(size: 10.5, weight: .medium) + static let code = Font.system(size: 15.8, weight: .semibold, design: .monospaced) +} + +private enum LoginPalette { + static func primaryText(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.9, green: 0.94, blue: 0.99).opacity(0.95) + : Color(red: 0.12, green: 0.17, blue: 0.24).opacity(0.92) + } + + static func secondaryText(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.68, green: 0.76, blue: 0.86).opacity(0.78) + : Color(red: 0.34, green: 0.43, blue: 0.55).opacity(0.72) + } + + static func accent(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.72, green: 0.84, blue: 0.98) + : Color(red: 0.16, green: 0.34, blue: 0.54) + } + + static func warning(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.88, green: 0.58, blue: 0.35) + : Color(red: 0.58, green: 0.31, blue: 0.17) + } + + static func panelTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.13, green: 0.2, blue: 0.3).opacity(0.42) + : Color(red: 0.72, green: 0.84, blue: 0.95).opacity(0.34) + } + + static func panelStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.white.opacity(0.18) + : Color.white.opacity(0.58) + } + + static func cardTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.72, green: 0.84, blue: 0.98).opacity(0.095) + : Color.white.opacity(0.62) + } + + static func cardStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.white.opacity(0.13) + : Color(red: 0.42, green: 0.58, blue: 0.76).opacity(0.2) + } + + static func controlTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.7, green: 0.8, blue: 0.92).opacity(0.15) + : Color.white.opacity(0.74) + } + + static func controlStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.white.opacity(0.14) + : Color(red: 0.36, green: 0.52, blue: 0.72).opacity(0.22) + } + + static func codeCellTint(_ colorScheme: ColorScheme, isEmpty: Bool) -> Color { + if isEmpty { + return colorScheme == .dark + ? Color.white.opacity(0.045) + : Color.white.opacity(0.38) + } + + return colorScheme == .dark + ? Color(red: 0.72, green: 0.84, blue: 0.98).opacity(0.16) + : Color(red: 0.9, green: 0.96, blue: 1).opacity(0.84) + } + + static func codeCellStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color.white.opacity(0.16) + : Color.white.opacity(0.66) + } + + static func primaryButtonTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.56, green: 0.7, blue: 0.9).opacity(0.18) + : Color(red: 0.88, green: 0.95, blue: 1).opacity(0.84) + } + + static func primaryButtonStroke(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.8, green: 0.9, blue: 1).opacity(0.2) + : Color(red: 0.34, green: 0.54, blue: 0.72).opacity(0.3) } } diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index d6c0c8441..e4bf032db 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -158,16 +158,16 @@ struct CodexAccount: Decodable, Identifiable, Equatable { func windowLabel(seconds: Int?) -> String { switch seconds { - case 18_000: return "5h" - case 604_800: return "7d" + case 18_000: return "5 hrs" + case 604_800: return "7 days" case let value?: let hours = value / 3_600 if hours > 0 && value % 3_600 == 0 { - return "\(hours)h" + return hours == 1 ? "1 hr" : "\(hours) hrs" } let days = value / 86_400 if days > 0 && value % 86_400 == 0 { - return "\(days)d" + return days == 1 ? "1 day" : "\(days) days" } return "window" case nil: From 2e81cfa4a7afa1e931154ce5f4f0ab9faf552e4e Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 20 May 2026 09:59:17 +0800 Subject: [PATCH 3/8] {"schema":"decodex/commit/1","summary":"Polish Decodex app floating account panel","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 371 ++++++++++++++---- .../Sources/DecodexApp/Models.swift | 2 +- 2 files changed, 306 insertions(+), 67 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 311f2bd84..9a569db49 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -45,34 +45,34 @@ private enum PanelPalette { static func panelTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color(red: 0.1, green: 0.18, blue: 0.28).opacity(0.5) - : Color(red: 0.56, green: 0.73, blue: 0.9).opacity(0.5) + : Color(red: 0.46, green: 0.68, blue: 0.9).opacity(0.62) } static func summaryTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color(red: 0.63, green: 0.73, blue: 0.86).opacity(0.115) - : Color.white.opacity(0.48) + : Color.white.opacity(0.58) } static func accountRowTint(_ colorScheme: ColorScheme, isSelected: Bool, isCodexActive: Bool) -> Color { if isSelected { - return actionBlue(colorScheme).opacity(colorScheme == .dark ? 0.13 : 0.12) + return actionBlue(colorScheme).opacity(colorScheme == .dark ? 0.13 : 0.16) } if isCodexActive { return colorScheme == .dark ? Color(red: 0.62, green: 0.72, blue: 0.84).opacity(0.12) - : Color.white.opacity(0.48) + : Color.white.opacity(0.62) } return colorScheme == .dark ? Color(red: 0.58, green: 0.68, blue: 0.8).opacity(0.115) - : Color.white.opacity(0.44) + : Color.white.opacity(0.6) } static func usageTray(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color(red: 0.7, green: 0.8, blue: 0.93).opacity(0.045) - : Color.white.opacity(0.16) + : Color.white.opacity(0.18) } static func usageTrayStroke(_ colorScheme: ColorScheme) -> Color { @@ -81,20 +81,20 @@ private enum PanelPalette { static func addButtonTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.56, green: 0.7, blue: 0.88).opacity(0.24) + ? Color(red: 0.56, green: 0.72, blue: 0.92).opacity(0.3) : Color(red: 0.86, green: 0.94, blue: 1).opacity(0.84) } static func addButtonStroke(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.78, green: 0.88, blue: 1).opacity(0.22) + ? Color(red: 0.78, green: 0.9, blue: 1).opacity(0.3) : Color(red: 0.24, green: 0.43, blue: 0.64).opacity(0.42) } static func controlTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color(red: 0.68, green: 0.8, blue: 0.94).opacity(0.2) - : Color(red: 0.92, green: 0.97, blue: 1).opacity(0.76) + : Color(red: 0.9, green: 0.96, blue: 1).opacity(0.86) } static func controlStroke(_ colorScheme: ColorScheme) -> Color { @@ -298,7 +298,7 @@ struct AccountPanelView: View { SummaryTileView( title: "Runs", value: decodexModeLabel, - symbol: hasFixedSelection ? "pin.fill" : "arrow.triangle.branch", + symbol: hasFixedSelection ? "scope" : "arrow.triangle.branch", tint: hasFixedSelection ? PanelPalette.actionBlue(colorScheme) : PanelPalette.secondaryText(colorScheme) ) } @@ -307,7 +307,7 @@ struct AccountPanelView: View { .modernGlassSurface( cornerRadius: 9, tint: PanelPalette.summaryTint(colorScheme), - depth: .section + depth: .row ) } @@ -326,7 +326,7 @@ struct AccountPanelView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) - .modernGlassSurface(cornerRadius: 9, depth: .section) + .modernGlassSurface(cornerRadius: 9, depth: .row) } private var loadingState: some View { @@ -340,7 +340,7 @@ struct AccountPanelView: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) - .modernGlassSurface(cornerRadius: 9, depth: .section) + .modernGlassSurface(cornerRadius: 9, depth: .row) } private var accountList: some View { @@ -368,7 +368,7 @@ struct AccountPanelView: View { await store.useInCodex(account) } }, - pinForDecodex: { + routeRunsHere: { Task { await store.select(account) } @@ -392,18 +392,19 @@ struct AccountPanelView: View { } ) - PanelIconButtonView( - symbol: "arrow.triangle.branch", - tint: PanelPalette.actionBlue(colorScheme), - isActive: false, - isDisabled: !hasFixedSelection, - action: { - Task { - await store.clearSelection() - } - }, - help: "Return Decodex runs to balanced selection" - ) + if hasFixedSelection { + PanelIconButtonView( + symbol: "arrow.triangle.branch", + tint: PanelPalette.actionBlue(colorScheme), + isActive: false, + action: { + Task { + await store.clearSelection() + } + }, + help: "Use balanced run routing" + ) + } SettingsLink { PanelIconLabelView(symbol: "gearshape", tint: PanelPalette.actionBlue(colorScheme)) @@ -473,7 +474,7 @@ struct AccountPanelView: View { private var headerSubtitle: String { let count = store.accounts.count let accountLabel = "\(count) account\(count == 1 ? "" : "s")" - return hasFixedSelection ? "\(accountLabel) / pinned runs" : "\(accountLabel) / balanced runs" + return hasFixedSelection ? "\(accountLabel) / routed runs" : "\(accountLabel) / balanced runs" } private var emailsHidden: Bool { @@ -495,7 +496,7 @@ struct AccountRowView: View { let account: CodexAccount let emailsHidden: Bool let useInCodex: () -> Void - let pinForDecodex: () -> Void + let routeRunsHere: () -> Void let logout: () -> Void @Environment(\.colorScheme) private var colorScheme @@ -543,13 +544,13 @@ struct AccountRowView: View { ) PanelIconButtonView( - symbol: account.selected ? "pin.fill" : "pin", + symbol: "scope", tint: PanelPalette.actionBlue(colorScheme), isActive: account.selected, isSubtle: true, size: 22, - action: pinForDecodex, - help: account.selected ? "Return Decodex to balanced selection" : "Pin for Decodex runs" + action: routeRunsHere, + help: account.selected ? "Use balanced run routing" : "Route Decodex runs here" ) PanelIconButtonView( @@ -572,7 +573,7 @@ struct AccountRowView: View { .padding(.leading, 8) .padding(.trailing, 7) .modernGlassSurface( - cornerRadius: 8, + cornerRadius: 9, tint: PanelPalette.accountRowTint( colorScheme, isSelected: account.selected, @@ -624,6 +625,18 @@ struct AccountUsageSummaryView: View { .strokeBorder(PanelPalette.usageTrayStroke(colorScheme), lineWidth: 0.35) .allowsHitTesting(false) } + .shadow( + color: usageTrayShadow, + radius: colorScheme == .dark ? 3 : 3.5, + x: 0, + y: 1 + ) + } + + private var usageTrayShadow: Color { + colorScheme == .dark + ? Color.black.opacity(0.08) + : Color(red: 0.12, green: 0.28, blue: 0.42).opacity(0.055) } } @@ -901,14 +914,18 @@ struct PanelPrimaryButtonView: View { .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) } .buttonStyle(.plain) - .foregroundStyle(PanelPalette.actionBlue(colorScheme)) + .foregroundStyle( + colorScheme == .dark + ? Color(red: 0.86, green: 0.93, blue: 1).opacity(0.94) + : PanelPalette.actionBlue(colorScheme) + ) .modernGlassSurface( - cornerRadius: 8, + cornerRadius: 9, tint: PanelPalette.addButtonTint(colorScheme), depth: .row ) .overlay { - RoundedRectangle(cornerRadius: 8, style: .continuous) + RoundedRectangle(cornerRadius: 9, style: .continuous) .strokeBorder(PanelPalette.addButtonStroke(colorScheme), lineWidth: 0.55) .allowsHitTesting(false) } @@ -1286,15 +1303,43 @@ private extension View { depth: GlassSurfaceDepth, colorScheme: ColorScheme ) -> some View { - self + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + + return self + .background { + shape + .fill(castShadowColor(for: depth, colorScheme: colorScheme)) + .blur(radius: castShadowBlur(for: depth)) + .offset( + x: castShadowXOffset(for: depth), + y: castShadowOffset(for: depth) + ) + .opacity(castShadowOpacity(for: depth, colorScheme: colorScheme)) + .allowsHitTesting(false) + } + .background(alignment: .bottom) { + shape + .fill(contactShadowGradient(for: depth, colorScheme: colorScheme)) + .frame(height: contactShadowHeight(for: depth)) + .blur(radius: contactShadowBlur(for: depth)) + .offset(y: contactShadowOffset(for: depth)) + .opacity(contactShadowOpacity(for: depth, colorScheme: colorScheme)) + .allowsHitTesting(false) + } + .overlay { + shape + .fill(surfaceLiftGradient(for: depth, colorScheme: colorScheme)) + .blendMode(colorScheme == .dark ? .screen : .softLight) + .allowsHitTesting(false) + } .overlay { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + shape .fill(sheenGradient(for: depth, colorScheme: colorScheme)) .blendMode(.screen) .allowsHitTesting(false) } .overlay { - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + shape .strokeBorder( edgeGradient(for: depth, colorScheme: colorScheme), lineWidth: edgeWidth(for: depth) @@ -1304,13 +1349,13 @@ private extension View { .shadow( color: ambientShadowColor(for: depth, colorScheme: colorScheme), radius: ambientShadowRadius(for: depth), - x: 0, + x: ambientShadowXOffset(for: depth), y: ambientShadowOffset(for: depth) ) .shadow( color: keyShadowColor(for: depth, colorScheme: colorScheme), radius: keyShadowRadius(for: depth), - x: 0, + x: keyShadowXOffset(for: depth), y: keyShadowOffset(for: depth) ) } @@ -1327,6 +1372,18 @@ private extension View { ) } + private func surfaceLiftGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { + LinearGradient( + colors: [ + Color.white.opacity(surfaceTopGlowOpacity(for: depth, colorScheme: colorScheme)), + Color.white.opacity(0), + Color.black.opacity(surfaceBottomShadeOpacity(for: depth, colorScheme: colorScheme)), + ], + startPoint: .top, + endPoint: .bottom + ) + } + private func sheenGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { LinearGradient( colors: [ @@ -1339,30 +1396,194 @@ private extension View { ) } + private func contactShadowGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { + let color = contactShadowColor(for: depth, colorScheme: colorScheme) + return LinearGradient( + colors: [ + color.opacity(0), + color, + color.opacity(0.35), + color.opacity(0), + ], + startPoint: .top, + endPoint: .bottom + ) + } + + private func contactShadowColor(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Color { + if colorScheme == .dark { + switch depth { + case .panel: + return Color.black + case .section, .row: + return Color(red: 0.01, green: 0.04, blue: 0.08) + case .control: + return Color.black + } + } + + switch depth { + case .panel: + return Color(red: 0.08, green: 0.18, blue: 0.3) + case .section, .row: + return Color(red: 0.12, green: 0.28, blue: 0.42) + case .control: + return Color(red: 0.1, green: 0.24, blue: 0.38) + } + } + + private func castShadowColor(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Color { + if colorScheme == .dark { + switch depth { + case .panel: + return Color.black + case .section, .row: + return Color(red: 0.01, green: 0.035, blue: 0.07) + case .control: + return Color.black + } + } + + switch depth { + case .panel: + return Color(red: 0.08, green: 0.18, blue: 0.3) + case .section, .row: + return Color(red: 0.1, green: 0.24, blue: 0.38) + case .control: + return Color(red: 0.08, green: 0.2, blue: 0.34) + } + } + + private func castShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { + switch depth { + case .panel: + return colorScheme == .dark ? 0.16 : 0.085 + case .section: + return colorScheme == .dark ? 0.13 : 0.18 + case .row: + return colorScheme == .dark ? 0.22 : 0.27 + case .control: + return colorScheme == .dark ? 0.2 : 0.2 + } + } + + private func castShadowBlur(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 16 + case .section: return 10 + case .row: return 12 + case .control: return 7 + } + } + + private func castShadowXOffset(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 0 + case .section: return 0.5 + case .row: return 0.7 + case .control: return 0.4 + } + } + + private func castShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 8 + case .section: return 6 + case .row: return 8 + case .control: return 4 + } + } + + private func contactShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { + switch depth { + case .panel: + return colorScheme == .dark ? 0.14 : 0.085 + case .section: + return colorScheme == .dark ? 0.19 : 0.32 + case .row: + return colorScheme == .dark ? 0.27 : 0.42 + case .control: + return colorScheme == .dark ? 0.2 : 0.24 + } + } + + private func contactShadowHeight(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 16 + case .section: return 10 + case .row: return 12 + case .control: return 8 + } + } + + private func contactShadowBlur(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 7 + case .section: return 3.2 + case .row: return 3.6 + case .control: return 3 + } + } + + private func contactShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 8 + case .section: return 5.8 + case .row: return 6.8 + case .control: return 4 + } + } + + private func surfaceTopGlowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { + switch depth { + case .panel: + return colorScheme == .dark ? 0.035 : 0.045 + case .section: + return colorScheme == .dark ? 0.055 : 0.085 + case .row: + return colorScheme == .dark ? 0.06 : 0.095 + case .control: + return colorScheme == .dark ? 0.08 : 0.11 + } + } + + private func surfaceBottomShadeOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { + switch depth { + case .panel: + return colorScheme == .dark ? 0.025 : 0.018 + case .section: + return colorScheme == .dark ? 0.035 : 0.03 + case .row: + return colorScheme == .dark ? 0.045 : 0.04 + case .control: + return colorScheme == .dark ? 0.04 : 0.035 + } + } + private func sheenOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { - case .panel: return colorScheme == .dark ? 0.075 : 0.11 - case .section: return colorScheme == .dark ? 0.055 : 0.08 - case .row: return colorScheme == .dark ? 0.06 : 0.085 - case .control: return colorScheme == .dark ? 0.085 : 0.11 + case .panel: return colorScheme == .dark ? 0.075 : 0.1 + case .section: return colorScheme == .dark ? 0.06 : 0.11 + case .row: return colorScheme == .dark ? 0.07 : 0.12 + case .control: return colorScheme == .dark ? 0.09 : 0.13 } } private func edgeWidth(for depth: GlassSurfaceDepth) -> CGFloat { switch depth { case .panel: return 0.65 - case .section: return 0.42 - case .row: return 0.38 - case .control: return 0.35 + case .section: return 0.48 + case .row: return 0.52 + case .control: return 0.45 } } private func edgeHighlightOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { case .panel: return colorScheme == .dark ? 0.23 : 0.32 - case .section: return colorScheme == .dark ? 0.145 : 0.2 - case .row: return colorScheme == .dark ? 0.18 : 0.21 - case .control: return colorScheme == .dark ? 0.23 : 0.28 + case .section: return colorScheme == .dark ? 0.19 : 0.34 + case .row: return colorScheme == .dark ? 0.23 : 0.42 + case .control: return colorScheme == .dark ? 0.26 : 0.38 } } @@ -1378,9 +1599,9 @@ private extension View { private func edgeShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { case .panel: return colorScheme == .dark ? 0.085 : 0.075 - case .section: return colorScheme == .dark ? 0.04 : 0.06 - case .row: return colorScheme == .dark ? 0.035 : 0.055 - case .control: return colorScheme == .dark ? 0.055 : 0.07 + case .section: return colorScheme == .dark ? 0.045 : 0.075 + case .row: return colorScheme == .dark ? 0.045 : 0.11 + case .control: return colorScheme == .dark ? 0.06 : 0.09 } } @@ -1390,11 +1611,11 @@ private extension View { case .panel: opacity = colorScheme == .dark ? 0.21 : 0.065 case .section: - opacity = colorScheme == .dark ? 0.052 : 0.04 + opacity = colorScheme == .dark ? 0.07 : 0.095 case .row: - opacity = colorScheme == .dark ? 0.098 : 0.048 + opacity = colorScheme == .dark ? 0.125 : 0.15 case .control: - opacity = colorScheme == .dark ? 0.072 : 0.038 + opacity = colorScheme == .dark ? 0.082 : 0.058 } return Color.black.opacity(opacity) @@ -1406,11 +1627,11 @@ private extension View { case .panel: opacity = colorScheme == .dark ? 0.048 : 0.028 case .section: - opacity = colorScheme == .dark ? 0.035 : 0.026 + opacity = colorScheme == .dark ? 0.045 : 0.055 case .row: - opacity = colorScheme == .dark ? 0.046 : 0.028 + opacity = colorScheme == .dark ? 0.06 : 0.07 case .control: - opacity = colorScheme == .dark ? 0.048 : 0.028 + opacity = colorScheme == .dark ? 0.052 : 0.046 } return Color(hue: 0.6, saturation: 0.32, brightness: 1).opacity(opacity) @@ -1419,18 +1640,27 @@ private extension View { private func ambientShadowRadius(for depth: GlassSurfaceDepth) -> CGFloat { switch depth { case .panel: return 15 - case .section: return 5.5 - case .row: return 7.5 - case .control: return 4.5 + case .section: return 9 + case .row: return 13 + case .control: return 5.5 + } + } + + private func ambientShadowXOffset(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 0 + case .section: return 0.6 + case .row: return 0.8 + case .control: return 0.4 } } private func ambientShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { switch depth { case .panel: return 6 - case .section: return 2 - case .row: return 4 - case .control: return 1 + case .section: return 4 + case .row: return 8 + case .control: return 2 } } @@ -1438,11 +1668,20 @@ private extension View { switch depth { case .panel: return 5 case .section: return 4 - case .row: return 4 + case .row: return 4.5 case .control: return 3 } } + private func keyShadowXOffset(for depth: GlassSurfaceDepth) -> CGFloat { + switch depth { + case .panel: return 0 + case .section: return 0.3 + case .row: return 0.4 + case .control: return 0.2 + } + } + private func keyShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { switch depth { case .panel: return 2 diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index e4bf032db..e41e86134 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -99,7 +99,7 @@ struct CodexAccount: Decodable, Identifiable, Equatable { return "Codex active" } if selected { - return "Decodex pinned" + return "Run target" } switch status { From 8abbf1ea9df99a4dcb0ab0c2b58e0ebc9d48b019 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 20 May 2026 10:35:04 +0800 Subject: [PATCH 4/8] {"schema":"decodex/commit/1","summary":"Refine Decodex app account routing controls","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 36 +++++++++++-------- .../Sources/DecodexApp/AccountStore.swift | 14 -------- .../Sources/DecodexApp/DecodexApp.swift | 2 +- .../Sources/DecodexApp/LoginSheetView.swift | 2 +- .../Sources/DecodexApp/Models.swift | 2 +- 5 files changed, 24 insertions(+), 32 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 9a569db49..9700c12a1 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -56,7 +56,9 @@ private enum PanelPalette { static func accountRowTint(_ colorScheme: ColorScheme, isSelected: Bool, isCodexActive: Bool) -> Color { if isSelected { - return actionBlue(colorScheme).opacity(colorScheme == .dark ? 0.13 : 0.16) + return colorScheme == .dark + ? Color(red: 0.58, green: 0.72, blue: 0.9).opacity(0.17) + : Color(red: 0.88, green: 0.96, blue: 1).opacity(0.76) } if isCodexActive { return colorScheme == .dark @@ -232,9 +234,12 @@ struct AccountPanelView: View { private var header: some View { HStack(alignment: .center, spacing: 8) { - Image(systemName: store.menuSymbol) - .font(PanelFont.headerIcon) + Image(nsImage: AppAssets.statusBarIcon) + .resizable() + .renderingMode(.template) + .scaledToFit() .foregroundStyle(PanelPalette.actionBlue(colorScheme)) + .frame(width: 16, height: 16) .frame(width: 28, height: 28) .modernGlassSurface( cornerRadius: 8, @@ -286,7 +291,7 @@ struct AccountPanelView: View { SummaryTileView( title: "Codex", value: codexAuthLabel, - symbol: "bolt.fill", + symbol: "person.crop.circle", tint: PanelPalette.activeGold(colorScheme) ) @@ -298,7 +303,7 @@ struct AccountPanelView: View { SummaryTileView( title: "Runs", value: decodexModeLabel, - symbol: hasFixedSelection ? "scope" : "arrow.triangle.branch", + symbol: "arrow.triangle.branch", tint: hasFixedSelection ? PanelPalette.actionBlue(colorScheme) : PanelPalette.secondaryText(colorScheme) ) } @@ -394,7 +399,7 @@ struct AccountPanelView: View { if hasFixedSelection { PanelIconButtonView( - symbol: "arrow.triangle.branch", + symbol: "shuffle", tint: PanelPalette.actionBlue(colorScheme), isActive: false, action: { @@ -402,7 +407,7 @@ struct AccountPanelView: View { await store.clearSelection() } }, - help: "Use balanced run routing" + help: "Restore balanced run routing" ) } @@ -434,14 +439,15 @@ struct AccountPanelView: View { if let selector = control.accountSelector, !selector.isEmpty { if emailsHidden { - return account(matching: selector).map(AccountDisplay.alias) ?? "Account" + let value = account(matching: selector).map(AccountDisplay.alias) ?? "Account" + return "To \(value)" } if selector.contains("@") { - return AccountDisplay.compactEmail(selector) + return "To \(AccountDisplay.compactEmail(selector))" } - return AccountDisplay.compactIdentity(selector) + return "To \(AccountDisplay.compactIdentity(selector))" } if control.mode == "balanced" { @@ -474,7 +480,7 @@ struct AccountPanelView: View { private var headerSubtitle: String { let count = store.accounts.count let accountLabel = "\(count) account\(count == 1 ? "" : "s")" - return hasFixedSelection ? "\(accountLabel) / routed runs" : "\(accountLabel) / balanced runs" + return hasFixedSelection ? "\(accountLabel) / run route set" : "\(accountLabel) / balanced runs" } private var emailsHidden: Bool { @@ -534,23 +540,23 @@ struct AccountRowView: View { HStack(spacing: 3) { PanelIconButtonView( - symbol: account.codexActive ? "bolt.fill" : "bolt", + symbol: account.codexActive ? "person.crop.circle.fill" : "person.crop.circle", tint: PanelPalette.activeGold(colorScheme), isActive: account.codexActive, isSubtle: true, size: 22, action: useInCodex, - help: account.codexActive ? "Already active in Codex" : "Use in Codex" + help: account.codexActive ? "Current Codex account" : "Use as Codex account" ) PanelIconButtonView( - symbol: "scope", + symbol: "arrow.triangle.branch", tint: PanelPalette.actionBlue(colorScheme), isActive: account.selected, isSubtle: true, size: 22, action: routeRunsHere, - help: account.selected ? "Use balanced run routing" : "Route Decodex runs here" + help: account.selected ? "Restore balanced run routing" : "Route Decodex runs here" ) PanelIconButtonView( diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index cb75b44bc..02cecb977 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -32,20 +32,6 @@ final class AccountStore: ObservableObject { return "Codex: \(codexLabel) / Decodex: \(decodexLabel)" } - var menuSymbol: String { - if accounts.contains(where: \.codexActive) { - return "bolt.circle.fill" - } - if accounts.contains(where: \.selected) { - return "person.crop.circle.badge.checkmark" - } - if accounts.isEmpty { - return "person.crop.circle.badge.plus" - } - - return "person.2.circle" - } - var loginPrompt: DeviceLoginPrompt? { DeviceLoginPrompt.parse(loginTranscript) } diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift index c5647159a..590b238be 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift @@ -7,7 +7,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate { } } -private enum AppAssets { +enum AppAssets { static let statusBarIcon: NSImage = { let image = NSImage(named: "StatusBarIcon") ?? Bundle.main.url(forResource: "StatusBarIcon", withExtension: "png") diff --git a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift index 87cab17f8..a0a6a08ca 100644 --- a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift +++ b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift @@ -38,7 +38,7 @@ struct LoginSheetView: View { private var header: some View { HStack(spacing: 8) { - Image(systemName: "person.badge.key.fill") + Image(systemName: "person.crop.circle.badge.plus") .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(LoginPalette.accent(colorScheme)) .frame(width: 27, height: 27) diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index e41e86134..2dd9d5588 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -99,7 +99,7 @@ struct CodexAccount: Decodable, Identifiable, Equatable { return "Codex active" } if selected { - return "Run target" + return "Runs routed" } switch status { From 3f827e5a834edb41dd581d67063e92b98681d30f Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 20 May 2026 12:50:10 +0800 Subject: [PATCH 5/8] {"schema":"decodex/commit/1","summary":"Polish Decodex app account panel","authority":"manual"} --- README.md | 12 +- apps/decodex-app/README.md | 6 + .../Sources/DecodexApp/AccountPanelView.swift | 475 +++++++++++++----- .../Sources/DecodexApp/Models.swift | 6 + apps/decodex/src/accounts.rs | 228 ++++++++- .../src/orchestrator/operator_dashboard.html | 188 ++++++- .../decodex/src/orchestrator/operator_http.rs | 36 +- .../tests/operator/status/http.rs | 55 ++ apps/decodex/src/runtime.rs | 5 +- docs/reference/operator-control-plane.md | 2 +- docs/spec/runtime.md | 2 +- 11 files changed, 843 insertions(+), 172 deletions(-) diff --git a/README.md b/README.md index 755648ef6..a0f5c8550 100644 --- a/README.md +++ b/README.md @@ -130,10 +130,14 @@ file, and project configs do not own an account-pool path override. Set `[codex.accounts].fixed_account` in `~/.codex/decodex/config.toml` to pin all new account-pool runs to one account. When that global selector is absent, Decodex balances new runs across the pool. The operator dashboard Accounts UI writes and clears the same -global selector; project configs do not pin specific accounts. To switch the account -used by the Codex CLI itself, run `decodex account use ` or use the Decodex -App row action; this overwrites `$CODEX_HOME/auth.json` or `~/.codex/auth.json` from -the matching `accounts.jsonl` entry. +global selector; project configs do not pin specific accounts. Account display-name +rerolls are also global Decodex state under `[codex.account_names.offsets]` in +`~/.codex/decodex/config.toml` so the operator dashboard and Decodex App show the same +privacy-preserving names. Client-only presentation preferences such as theme, sorting, +and whether identities are hidden remain local to each UI. To switch the account used +by the Codex CLI itself, run `decodex account use ` or use the Decodex App +row action; this overwrites `$CODEX_HOME/auth.json` or `~/.codex/auth.json` from the +matching `accounts.jsonl` entry. `decodex diagnose --json` writes the local agent evidence index under `~/.codex/decodex/agent-evidence//` and prints the same handoff index for diff --git a/apps/decodex-app/README.md b/apps/decodex-app/README.md index c20afad64..c3b47d3f5 100644 --- a/apps/decodex-app/README.md +++ b/apps/decodex-app/README.md @@ -27,6 +27,12 @@ The app does not schedule Decodex runs, own project registration, or replace `decodex serve`. It is a native UI over the shared Rust account-management service, not a wrapper around the `decodex` CLI binary. +The app and operator dashboard share account-pool state through the Rust account API: +stored accounts come from `~/.codex/decodex/accounts.jsonl`, run routing and account +display-name offsets come from `~/.codex/decodex/config.toml`, and Codex CLI auth +switching writes `auth.json`. Presentation-only choices such as local privacy +visibility remain client-local. + ## Development Build the SwiftPM app: diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 9700c12a1..6e29e1a63 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -25,83 +25,85 @@ private enum PanelFont { static let usage = text(8.45, weight: .medium) static let usageMeta = text(7.85, weight: .medium) static let primaryButton = text(10.7, weight: .semibold) - static let iconButton = text(10, weight: .medium) + static let iconButton = text(10.7, weight: .semibold) static let footerIcon = text(10.1, weight: .medium) } private enum PanelPalette { static func primaryText(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.9, green: 0.94, blue: 0.99).opacity(0.94) + ? Color(red: 0.88, green: 0.94, blue: 1).opacity(0.96) : Color(red: 0.11, green: 0.17, blue: 0.26).opacity(0.94) } static func secondaryText(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.66, green: 0.74, blue: 0.84).opacity(0.78) + ? Color(red: 0.6, green: 0.7, blue: 0.82).opacity(0.82) : Color(red: 0.3, green: 0.4, blue: 0.53).opacity(0.8) } static func panelTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.1, green: 0.18, blue: 0.28).opacity(0.5) + ? Color(red: 0.025, green: 0.075, blue: 0.13).opacity(0.68) : Color(red: 0.46, green: 0.68, blue: 0.9).opacity(0.62) } static func summaryTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.63, green: 0.73, blue: 0.86).opacity(0.115) + ? Color(red: 0.16, green: 0.28, blue: 0.42).opacity(0.22) : Color.white.opacity(0.58) } static func accountRowTint(_ colorScheme: ColorScheme, isSelected: Bool, isCodexActive: Bool) -> Color { if isSelected { return colorScheme == .dark - ? Color(red: 0.58, green: 0.72, blue: 0.9).opacity(0.17) + ? Color(red: 0.16, green: 0.32, blue: 0.5).opacity(0.34) : Color(red: 0.88, green: 0.96, blue: 1).opacity(0.76) } if isCodexActive { return colorScheme == .dark - ? Color(red: 0.62, green: 0.72, blue: 0.84).opacity(0.12) + ? Color(red: 0.13, green: 0.25, blue: 0.38).opacity(0.28) : Color.white.opacity(0.62) } return colorScheme == .dark - ? Color(red: 0.58, green: 0.68, blue: 0.8).opacity(0.115) + ? Color(red: 0.11, green: 0.2, blue: 0.32).opacity(0.24) : Color.white.opacity(0.6) } static func usageTray(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.7, green: 0.8, blue: 0.93).opacity(0.045) + ? Color(red: 0.015, green: 0.045, blue: 0.08).opacity(0.54) : Color.white.opacity(0.18) } static func usageTrayStroke(_ colorScheme: ColorScheme) -> Color { - Color.white.opacity(colorScheme == .dark ? 0.055 : 0.18) + colorScheme == .dark + ? Color(red: 0.52, green: 0.72, blue: 0.94).opacity(0.16) + : Color.white.opacity(0.18) } static func addButtonTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.56, green: 0.72, blue: 0.92).opacity(0.3) + ? Color(red: 0.13, green: 0.31, blue: 0.5).opacity(0.48) : Color(red: 0.86, green: 0.94, blue: 1).opacity(0.84) } static func addButtonStroke(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.78, green: 0.9, blue: 1).opacity(0.3) + ? Color(red: 0.5, green: 0.76, blue: 1).opacity(0.36) : Color(red: 0.24, green: 0.43, blue: 0.64).opacity(0.42) } static func controlTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.68, green: 0.8, blue: 0.94).opacity(0.2) + ? Color(red: 0.16, green: 0.3, blue: 0.46).opacity(0.34) : Color(red: 0.9, green: 0.96, blue: 1).opacity(0.86) } static func controlStroke(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.8, green: 0.9, blue: 1).opacity(0.18) + ? Color(red: 0.55, green: 0.76, blue: 1).opacity(0.28) : Color(red: 0.28, green: 0.46, blue: 0.66).opacity(0.38) } @@ -113,19 +115,19 @@ private enum PanelPalette { static func actionBlue(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.72, green: 0.84, blue: 0.98) + ? Color(red: 0.43, green: 0.74, blue: 1) : Color(red: 0.1, green: 0.28, blue: 0.46) } static func activeGold(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.72, green: 0.82, blue: 0.93) + ? Color(red: 0.95, green: 0.8, blue: 0.34) : Color(red: 0.28, green: 0.42, blue: 0.55) } static func usageMint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.6, green: 0.73, blue: 0.82) + ? Color(red: 0.36, green: 0.94, blue: 0.76) : Color(red: 0.38, green: 0.56, blue: 0.64) } @@ -137,18 +139,136 @@ private enum PanelPalette { static func destructive(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.82, green: 0.55, blue: 0.58) - : Color(red: 0.55, green: 0.31, blue: 0.35) + ? Color(red: 1, green: 0.42, blue: 0.45) + : Color(red: 0.72, green: 0.13, blue: 0.18) } static func progressTrack(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.72, green: 0.82, blue: 0.92).opacity(0.095) - : Color(red: 0.13, green: 0.28, blue: 0.4).opacity(0.11) + ? Color(red: 0, green: 0.025, blue: 0.055).opacity(0.62) + : Color(red: 0.12, green: 0.28, blue: 0.4).opacity(0.16) } static func progressEdge(_ colorScheme: ColorScheme) -> Color { - Color.white.opacity(colorScheme == .dark ? 0.1 : 0.18) + colorScheme == .dark + ? Color(red: 0.56, green: 0.78, blue: 1).opacity(0.2) + : Color.white.opacity(0.24) + } +} + +private enum PanelMotion { + static let hover = Animation.interactiveSpring(response: 0.22, dampingFraction: 0.86, blendDuration: 0.04) + static let press = Animation.interactiveSpring(response: 0.16, dampingFraction: 0.78, blendDuration: 0.02) + static let state = Animation.interactiveSpring(response: 0.24, dampingFraction: 0.88, blendDuration: 0.05) +} + +private extension View { + func panelInteractiveSurface( + isPressed: Bool = false, + isDisabled: Bool = false, + hoverLift: CGFloat = 0.7, + hoverScale: CGFloat = 1.006, + pressedScale: CGFloat = 0.985, + hoverShadowRadius: CGFloat = 3 + ) -> some View { + modifier( + PanelInteractiveSurfaceModifier( + isPressed: isPressed, + isDisabled: isDisabled, + hoverLift: hoverLift, + hoverScale: hoverScale, + pressedScale: pressedScale, + hoverShadowRadius: hoverShadowRadius + ) + ) + } +} + +private struct PanelInteractiveButtonStyle: ButtonStyle { + let isDisabled: Bool + let hoverLift: CGFloat + let hoverScale: CGFloat + let pressedScale: CGFloat + let hoverShadowRadius: CGFloat + + init( + isDisabled: Bool = false, + hoverLift: CGFloat = 0.7, + hoverScale: CGFloat = 1.006, + pressedScale: CGFloat = 0.985, + hoverShadowRadius: CGFloat = 3 + ) { + self.isDisabled = isDisabled + self.hoverLift = hoverLift + self.hoverScale = hoverScale + self.pressedScale = pressedScale + self.hoverShadowRadius = hoverShadowRadius + } + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .panelInteractiveSurface( + isPressed: configuration.isPressed, + isDisabled: isDisabled, + hoverLift: hoverLift, + hoverScale: hoverScale, + pressedScale: pressedScale, + hoverShadowRadius: hoverShadowRadius + ) + } +} + +private struct PanelInteractiveSurfaceModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + @State private var isHovered = false + let isPressed: Bool + let isDisabled: Bool + let hoverLift: CGFloat + let hoverScale: CGFloat + let pressedScale: CGFloat + let hoverShadowRadius: CGFloat + + func body(content: Content) -> some View { + let responds = !isDisabled + let hoverActive = responds && isHovered && !isPressed + let pressActive = responds && isPressed + + content + .scaleEffect(pressActive ? pressedScale : (hoverActive ? hoverScale : 1)) + .offset(y: hoverActive ? -hoverLift : 0) + .brightness(hoverActive ? hoverBrightness : (pressActive ? pressBrightness : 0)) + .shadow( + color: hoverShadowColor.opacity(hoverActive ? 1 : 0), + radius: hoverActive ? hoverShadowRadius : 0, + x: 0, + y: hoverActive ? 1.8 : 0 + ) + .onHover { hovering in + guard responds else { + return + } + + withAnimation(PanelMotion.hover) { + isHovered = hovering + } + } + .animation(PanelMotion.press, value: isPressed) + .animation(PanelMotion.hover, value: isHovered) + .animation(PanelMotion.state, value: isDisabled) + } + + private var hoverBrightness: Double { + colorScheme == .dark ? 0.022 : 0.016 + } + + private var pressBrightness: Double { + colorScheme == .dark ? -0.018 : -0.01 + } + + private var hoverShadowColor: Color { + colorScheme == .dark + ? Color.black.opacity(0.18) + : Color(red: 0.1, green: 0.24, blue: 0.38).opacity(0.12) } } @@ -409,14 +529,23 @@ struct AccountPanelView: View { }, help: "Restore balanced run routing" ) + .transition(.opacity.combined(with: .scale(scale: 0.96))) } SettingsLink { PanelIconLabelView(symbol: "gearshape", tint: PanelPalette.actionBlue(colorScheme)) } - .buttonStyle(.plain) + .buttonStyle( + PanelInteractiveButtonStyle( + hoverLift: 0.55, + hoverScale: 1.01, + pressedScale: 0.982, + hoverShadowRadius: 3 + ) + ) .help("Settings") } + .animation(PanelMotion.state, value: hasFixedSelection) } private var codexAuthLabel: String { @@ -425,6 +554,9 @@ struct AccountPanelView: View { } if emailsHidden { + if let account = account(matching: auth.selector) { + return account.panelDisplayName(emailsHidden: true) + } let identity = auth.accountFingerprint.isEmpty ? auth.selector : auth.accountFingerprint return AccountDisplay.alias(forIdentity: identity) } @@ -439,7 +571,7 @@ struct AccountPanelView: View { if let selector = control.accountSelector, !selector.isEmpty { if emailsHidden { - let value = account(matching: selector).map(AccountDisplay.alias) ?? "Account" + let value = account(matching: selector)?.panelDisplayName(emailsHidden: true) ?? "Account" return "To \(value)" } @@ -543,8 +675,9 @@ struct AccountRowView: View { symbol: account.codexActive ? "person.crop.circle.fill" : "person.crop.circle", tint: PanelPalette.activeGold(colorScheme), isActive: account.codexActive, + isDisabled: account.codexActive, isSubtle: true, - size: 22, + size: 23, action: useInCodex, help: account.codexActive ? "Current Codex account" : "Use as Codex account" ) @@ -554,7 +687,7 @@ struct AccountRowView: View { tint: PanelPalette.actionBlue(colorScheme), isActive: account.selected, isSubtle: true, - size: 22, + size: 23, action: routeRunsHere, help: account.selected ? "Restore balanced run routing" : "Route Decodex runs here" ) @@ -564,7 +697,7 @@ struct AccountRowView: View { tint: PanelPalette.destructive(colorScheme), isActive: false, isDestructive: true, - size: 22, + size: 23, action: logout, help: "Remove account" ) @@ -587,6 +720,14 @@ struct AccountRowView: View { ), depth: .row ) + .panelInteractiveSurface( + hoverLift: 0.35, + hoverScale: 1.002, + pressedScale: 1, + hoverShadowRadius: 2 + ) + .animation(PanelMotion.state, value: account.selected) + .animation(PanelMotion.state, value: account.codexActive) } private var displayName: String { @@ -633,15 +774,15 @@ struct AccountUsageSummaryView: View { } .shadow( color: usageTrayShadow, - radius: colorScheme == .dark ? 3 : 3.5, + radius: colorScheme == .dark ? 4.5 : 3.5, x: 0, - y: 1 + y: colorScheme == .dark ? 1.4 : 1 ) } private var usageTrayShadow: Color { colorScheme == .dark - ? Color.black.opacity(0.08) + ? Color(red: 0, green: 0.05, blue: 0.11).opacity(0.26) : Color(red: 0.12, green: 0.28, blue: 0.42).opacity(0.055) } } @@ -685,15 +826,38 @@ struct AccountUsageMeterView: View { GeometryReader { proxy in ZStack(alignment: .leading) { + let width = fillWidth(in: proxy.size.width) + Capsule() .fill(trackColor) + .overlay { + Capsule() + .fill(trackInsetStyle) + .padding(.vertical, 0.8) + .allowsHitTesting(false) + } Capsule() .fill(fillStyle) - .frame(width: fillWidth(in: proxy.size.width)) + .frame(width: width) + .animation(PanelMotion.state, value: remainingPercent) + .shadow( + color: color.opacity(colorScheme == .dark ? 0.34 : 0.18), + radius: colorScheme == .dark ? 3.4 : 2.2, + x: 0, + y: 0 + ) .overlay(alignment: .top) { Capsule() - .fill(Color.white.opacity(colorScheme == .dark ? 0.13 : 0.2)) - .frame(height: 0.6) + .fill(Color.white.opacity(colorScheme == .dark ? 0.28 : 0.34)) + .frame(height: 1) + .padding(.horizontal, 1.2) + .allowsHitTesting(false) + } + .overlay(alignment: .trailing) { + Capsule() + .fill(progressCapStyle) + .frame(width: min(13, max(4, width)), height: 5) + .opacity(progress > 0.04 ? 1 : 0) .allowsHitTesting(false) } Capsule() @@ -701,11 +865,11 @@ struct AccountUsageMeterView: View { .allowsHitTesting(false) } } - .frame(height: 3.4) + .frame(height: 5.2) } .font(PanelFont.usage) .lineLimit(1) - .frame(height: 16) + .frame(height: 18) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityLabel("\(label) remaining \(remainingText), \(resetDisplay.accessibility)") } @@ -731,7 +895,7 @@ struct AccountUsageMeterView: View { return 0 } - return max(2, width * progress) + return max(4, width * progress) } private var color: Color { @@ -748,9 +912,9 @@ struct AccountUsageMeterView: View { private var valueColor: Color { switch tone { case .warning, .danger: - return color.opacity(colorScheme == .dark ? 0.78 : 0.68) + return color.opacity(colorScheme == .dark ? 0.95 : 0.78) default: - return color.opacity(colorScheme == .dark ? 0.66 : 0.66) + return color.opacity(colorScheme == .dark ? 0.92 : 0.72) } } @@ -769,13 +933,39 @@ struct AccountUsageMeterView: View { private var fillStyle: LinearGradient { LinearGradient( colors: [ - color.opacity(colorScheme == .dark ? 0.56 : 0.5), - color.opacity(colorScheme == .dark ? 0.4 : 0.37), + color.opacity(colorScheme == .dark ? 0.98 : 0.78), + color.opacity(colorScheme == .dark ? 0.82 : 0.64), + color.opacity(colorScheme == .dark ? 0.58 : 0.48), ], startPoint: .leading, endPoint: .trailing ) } + + private var trackInsetStyle: LinearGradient { + LinearGradient( + colors: [ + Color.white.opacity(colorScheme == .dark ? 0.035 : 0.08), + Color.white.opacity(0), + Color.black.opacity(colorScheme == .dark ? 0.18 : 0.05), + ], + startPoint: .top, + endPoint: .bottom + ) + } + + private var progressCapStyle: RadialGradient { + RadialGradient( + colors: [ + Color.white.opacity(colorScheme == .dark ? 0.7 : 0.6), + color.opacity(colorScheme == .dark ? 0.5 : 0.36), + Color.white.opacity(0), + ], + center: .center, + startRadius: 0, + endRadius: 7 + ) + } } private struct UsageResetDisplay { @@ -916,25 +1106,32 @@ struct PanelPrimaryButtonView: View { Button(action: action) { Label(title, systemImage: symbol) .font(PanelFont.primaryButton) + .foregroundStyle( + colorScheme == .dark + ? Color(red: 0.86, green: 0.93, blue: 1).opacity(0.94) + : PanelPalette.actionBlue(colorScheme) + ) .frame(maxWidth: .infinity, minHeight: 22) .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .modernGlassSurface( + cornerRadius: 9, + tint: PanelPalette.addButtonTint(colorScheme), + depth: .row + ) + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(PanelPalette.addButtonStroke(colorScheme), lineWidth: 0.55) + .allowsHitTesting(false) + } } - .buttonStyle(.plain) - .foregroundStyle( - colorScheme == .dark - ? Color(red: 0.86, green: 0.93, blue: 1).opacity(0.94) - : PanelPalette.actionBlue(colorScheme) - ) - .modernGlassSurface( - cornerRadius: 9, - tint: PanelPalette.addButtonTint(colorScheme), - depth: .row + .buttonStyle( + PanelInteractiveButtonStyle( + hoverLift: 0.6, + hoverScale: 1.004, + pressedScale: 0.992, + hoverShadowRadius: 3.5 + ) ) - .overlay { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .strokeBorder(PanelPalette.addButtonStroke(colorScheme), lineWidth: 0.55) - .allowsHitTesting(false) - } .help(title) } } @@ -974,70 +1171,101 @@ struct PanelIconButtonView: View { } var body: some View { + Button(action: action) { + buttonLabel + } + .buttonStyle( + PanelInteractiveButtonStyle( + isDisabled: isDisabled, + hoverLift: 0, + hoverScale: isSubtle ? 1.004 : 1.006, + pressedScale: 0.952, + hoverShadowRadius: isSubtle ? 2.4 : 3 + ) + ) + .disabled(isDisabled) + .opacity(isDisabled && !isActive ? 0.56 : 1) + .help(help) + } + + @ViewBuilder + private var buttonLabel: some View { if usesSurface { - baseButton + iconContent .modernGlassSurface( - cornerRadius: 7, + cornerRadius: iconCornerRadius, tint: surfaceTint, depth: .control ) .overlay { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(PanelPalette.controlStroke(colorScheme), lineWidth: 0.5) + RoundedRectangle(cornerRadius: iconCornerRadius, style: .continuous) + .strokeBorder(buttonStrokeColor, lineWidth: isDestructive ? 0.62 : 0.54) .allowsHitTesting(false) } - .help(help) } else { - baseButton + iconContent .opacity(isDisabled ? 0.34 : 0.82) - .help(help) } } - private var baseButton: some View { - Button(action: action) { - Image(systemName: symbol) - .font(PanelFont.iconButton) - .foregroundStyle(foregroundColor) - .frame(width: size, height: size) - .contentShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) - } - .buttonStyle(.plain) - .disabled(isDisabled) + private var iconContent: some View { + Image(systemName: symbol) + .font(PanelFont.iconButton) + .symbolRenderingMode(.monochrome) + .foregroundStyle(foregroundColor) + .frame(width: size, height: size) + .contentShape(RoundedRectangle(cornerRadius: iconCornerRadius, style: .continuous)) } private var foregroundColor: Color { + if isActive { + return tint.opacity(colorScheme == .dark ? 0.98 : 0.92) + } if isDisabled { return PanelPalette.secondaryText(colorScheme).opacity(0.38) } - if isActive { - return tint.opacity(colorScheme == .dark ? 0.9 : 0.86) - } if isDestructive { - return tint.opacity(colorScheme == .dark ? 0.82 : 0.76) + return tint.opacity(colorScheme == .dark ? 0.96 : 0.9) } if isSubtle { - return tint.opacity(colorScheme == .dark ? 0.72 : 0.68) + return tint.opacity(colorScheme == .dark ? 0.86 : 0.82) } return PanelPalette.actionBlue(colorScheme).opacity(colorScheme == .dark ? 0.88 : 0.86) } private var surfaceTint: Color { + if isActive { + return tint.opacity(colorScheme == .dark ? 0.22 : 0.2) + } if isDisabled { return PanelPalette.controlTint(colorScheme).opacity(0.42) } - if isActive { - return tint.opacity(colorScheme == .dark ? 0.105 : 0.12) - } if isDestructive { - return tint.opacity(colorScheme == .dark ? 0.09 : 0.115) + return colorScheme == .dark + ? tint.opacity(0.18) + : Color(red: 1, green: 0.84, blue: 0.86).opacity(0.72) } - return PanelPalette.controlTint(colorScheme).opacity(isSubtle ? 0.76 : 1) + return PanelPalette.controlTint(colorScheme).opacity(isSubtle ? 0.94 : 1) } private var usesSurface: Bool { true } + + private var buttonStrokeColor: Color { + if isDestructive { + return tint.opacity(colorScheme == .dark ? 0.38 : 0.34) + } + if isActive { + return tint.opacity(colorScheme == .dark ? 0.4 : 0.34) + } + + return PanelPalette.controlStroke(colorScheme).opacity(isSubtle ? 1 : 0.92) + } + + private var iconCornerRadius: CGFloat { + min(8.5, max(7, size * 0.36)) + } } struct PanelIconLabelView: View { @@ -1242,6 +1470,9 @@ private struct ModernGlassSurfaceModifier: ViewModifier { if let tint { glass = glass.tint(tint) } + if depth == .control { + glass = glass.interactive() + } return glass } @@ -1463,13 +1694,13 @@ private extension View { private func castShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { case .panel: - return colorScheme == .dark ? 0.16 : 0.085 + return colorScheme == .dark ? 0.13 : 0.085 case .section: - return colorScheme == .dark ? 0.13 : 0.18 + return colorScheme == .dark ? 0.1 : 0.18 case .row: - return colorScheme == .dark ? 0.22 : 0.27 + return colorScheme == .dark ? 0.16 : 0.27 case .control: - return colorScheme == .dark ? 0.2 : 0.2 + return colorScheme == .dark ? 0.14 : 0.2 } } @@ -1503,13 +1734,13 @@ private extension View { private func contactShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { case .panel: - return colorScheme == .dark ? 0.14 : 0.085 + return colorScheme == .dark ? 0.1 : 0.085 case .section: - return colorScheme == .dark ? 0.19 : 0.32 + return colorScheme == .dark ? 0.13 : 0.32 case .row: - return colorScheme == .dark ? 0.27 : 0.42 + return colorScheme == .dark ? 0.18 : 0.42 case .control: - return colorScheme == .dark ? 0.2 : 0.24 + return colorScheme == .dark ? 0.14 : 0.24 } } @@ -1543,35 +1774,35 @@ private extension View { private func surfaceTopGlowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { case .panel: - return colorScheme == .dark ? 0.035 : 0.045 + return colorScheme == .dark ? 0.025 : 0.045 case .section: - return colorScheme == .dark ? 0.055 : 0.085 + return colorScheme == .dark ? 0.038 : 0.085 case .row: - return colorScheme == .dark ? 0.06 : 0.095 + return colorScheme == .dark ? 0.044 : 0.095 case .control: - return colorScheme == .dark ? 0.08 : 0.11 + return colorScheme == .dark ? 0.055 : 0.11 } } private func surfaceBottomShadeOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { case .panel: - return colorScheme == .dark ? 0.025 : 0.018 + return colorScheme == .dark ? 0.012 : 0.018 case .section: - return colorScheme == .dark ? 0.035 : 0.03 + return colorScheme == .dark ? 0.018 : 0.03 case .row: - return colorScheme == .dark ? 0.045 : 0.04 + return colorScheme == .dark ? 0.022 : 0.04 case .control: - return colorScheme == .dark ? 0.04 : 0.035 + return colorScheme == .dark ? 0.02 : 0.035 } } private func sheenOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { - case .panel: return colorScheme == .dark ? 0.075 : 0.1 - case .section: return colorScheme == .dark ? 0.06 : 0.11 - case .row: return colorScheme == .dark ? 0.07 : 0.12 - case .control: return colorScheme == .dark ? 0.09 : 0.13 + case .panel: return colorScheme == .dark ? 0.045 : 0.1 + case .section: return colorScheme == .dark ? 0.04 : 0.11 + case .row: return colorScheme == .dark ? 0.046 : 0.12 + case .control: return colorScheme == .dark ? 0.058 : 0.13 } } @@ -1586,19 +1817,19 @@ private extension View { private func edgeHighlightOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { - case .panel: return colorScheme == .dark ? 0.23 : 0.32 - case .section: return colorScheme == .dark ? 0.19 : 0.34 - case .row: return colorScheme == .dark ? 0.23 : 0.42 - case .control: return colorScheme == .dark ? 0.26 : 0.38 + case .panel: return colorScheme == .dark ? 0.17 : 0.32 + case .section: return colorScheme == .dark ? 0.14 : 0.34 + case .row: return colorScheme == .dark ? 0.17 : 0.42 + case .control: return colorScheme == .dark ? 0.2 : 0.38 } } private func edgeMidOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { switch depth { - case .panel: return colorScheme == .dark ? 0.07 : 0.105 - case .section: return colorScheme == .dark ? 0.04 : 0.07 - case .row: return colorScheme == .dark ? 0.04 : 0.065 - case .control: return colorScheme == .dark ? 0.055 : 0.09 + case .panel: return colorScheme == .dark ? 0.045 : 0.105 + case .section: return colorScheme == .dark ? 0.028 : 0.07 + case .row: return colorScheme == .dark ? 0.03 : 0.065 + case .control: return colorScheme == .dark ? 0.04 : 0.09 } } @@ -1615,13 +1846,13 @@ private extension View { let opacity: Double switch depth { case .panel: - opacity = colorScheme == .dark ? 0.21 : 0.065 + opacity = colorScheme == .dark ? 0.24 : 0.065 case .section: - opacity = colorScheme == .dark ? 0.07 : 0.095 + opacity = colorScheme == .dark ? 0.09 : 0.095 case .row: - opacity = colorScheme == .dark ? 0.125 : 0.15 + opacity = colorScheme == .dark ? 0.18 : 0.15 case .control: - opacity = colorScheme == .dark ? 0.082 : 0.058 + opacity = colorScheme == .dark ? 0.1 : 0.058 } return Color.black.opacity(opacity) @@ -1631,16 +1862,16 @@ private extension View { let opacity: Double switch depth { case .panel: - opacity = colorScheme == .dark ? 0.048 : 0.028 + opacity = colorScheme == .dark ? 0.07 : 0.028 case .section: - opacity = colorScheme == .dark ? 0.045 : 0.055 + opacity = colorScheme == .dark ? 0.065 : 0.055 case .row: - opacity = colorScheme == .dark ? 0.06 : 0.07 + opacity = colorScheme == .dark ? 0.08 : 0.07 case .control: - opacity = colorScheme == .dark ? 0.052 : 0.046 + opacity = colorScheme == .dark ? 0.07 : 0.046 } - return Color(hue: 0.6, saturation: 0.32, brightness: 1).opacity(opacity) + return Color(red: 0.34, green: 0.67, blue: 1).opacity(opacity) } private func ambientShadowRadius(for depth: GlassSurfaceDepth) -> CGFloat { @@ -1715,6 +1946,12 @@ private extension CodexAccount { func panelDisplayName(emailsHidden: Bool) -> String { if emailsHidden { + if let randomName = randomName?.trimmingCharacters(in: .whitespacesAndNewlines), + !randomName.isEmpty + { + return randomName + } + return AccountDisplay.alias(for: self) } diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index 2dd9d5588..d9315fe67 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -60,6 +60,9 @@ struct CodexAccount: Decodable, Identifiable, Equatable { let accountFingerprint: String let email: String? let selector: String + let randomName: String? + let randomNameKey: String? + let randomNameOffset: Int? let status: String let selected: Bool let codexActive: Bool @@ -192,6 +195,9 @@ struct CodexAccount: Decodable, Identifiable, Equatable { case accountFingerprint = "account_fingerprint" case email case selector + case randomName = "random_name" + case randomNameKey = "random_name_key" + case randomNameOffset = "random_name_offset" case status case selected case codexActive = "codex_active" diff --git a/apps/decodex/src/accounts.rs b/apps/decodex/src/accounts.rs index bdcbe3611..bd3502b55 100644 --- a/apps/decodex/src/accounts.rs +++ b/apps/decodex/src/accounts.rs @@ -1,5 +1,6 @@ #[cfg(unix)] use std::os::unix::fs::PermissionsExt as _; use std::{ + collections::BTreeMap, env, fs, io::{self, ErrorKind, Read, Write as _}, path::{Path, PathBuf}, @@ -19,6 +20,14 @@ use crate::{ state::CodexAccountActivitySummary, }; +const ACCOUNT_RANDOM_NAMES: &[&str] = &[ + "Alex", "Avery", "Bailey", "Blake", "Casey", "Charlie", "Clara", "Dana", "Drew", "Eden", + "Elliot", "Emery", "Evan", "Finley", "Harper", "Hayden", "Iris", "Jamie", "Jordan", "Kai", + "Kendall", "Lane", "Liam", "Logan", "Mason", "Maya", "Mia", "Morgan", "Noah", "Nora", "Owen", + "Paige", "Parker", "Quinn", "Reese", "Remy", "Riley", "Rowan", "Sage", "Sasha", "Sidney", + "Taylor", "Theo", "Val", +]; + pub(crate) struct AccountLoginRequest { pub(crate) codex_bin: String, pub(crate) keep_temp_home: bool, @@ -145,6 +154,31 @@ impl AccountStore { self.response_from_records(&records) } + fn reroll_name(&self, selector: &str, offset: Option) -> Result { + let selector = selector.trim(); + + if selector.is_empty() { + eyre::bail!("Codex account selector cannot be empty."); + } + + let records = self.load_records()?; + let record = records + .iter() + .find(|record| record.matches_account_selector(selector)) + .ok_or_else(|| eyre::eyre!("No Decodex account matches selector `{selector}`."))?; + let key = record.random_name_key(); + let offsets = self.account_name_offsets()?; + let current = offsets.get(&key).copied().unwrap_or_default(); + let next = offset.map_or_else( + || normalize_random_name_offset(current + 1), + normalize_random_name_offset, + ); + + self.write_account_name_offset(&key, next)?; + + self.response_from_records(&records) + } + fn import_auth_json(&self, auth_json_path: &Path) -> Result { let input = fs::read_to_string(auth_json_path).map_err(|error| { eyre::eyre!("Failed to read Codex auth JSON `{}`: {error}", auth_json_path.display()) @@ -257,13 +291,14 @@ impl AccountStore { fn response_from_records(&self, records: &[AccountPoolRecord]) -> Result { let selector = self.fixed_account_selector()?; let codex_auth = self.codex_auth_identity().unwrap_or_default(); + let name_offsets = self.account_name_offsets()?; let control = AccountControlSummary { mode: if selector.is_some() { String::from("fixed") } else { String::from("balanced") }, account_selector: selector.clone(), }; let accounts = records .iter() - .map(|record| record.summary(selector.as_deref(), codex_auth.as_ref())) + .map(|record| record.summary(selector.as_deref(), codex_auth.as_ref(), &name_offsets)) .collect::>(); Ok(AccountListResponse { @@ -297,17 +332,7 @@ impl AccountStore { } fn fixed_account_selector(&self) -> Result> { - let input = match fs::read_to_string(&self.global_config_path) { - Ok(input) => input, - Err(error) if error.kind() == ErrorKind::NotFound => return Ok(None), - Err(error) => { - eyre::bail!( - "Failed to read Decodex global config `{}`: {error}", - self.global_config_path.display() - ); - }, - }; - let document = toml::from_str::(&input)?; + let document = self.load_global_config_document()?; let selector = document .get("codex") .and_then(toml::Value::as_table) @@ -322,23 +347,52 @@ impl AccountStore { Ok(selector) } - fn write_fixed_account_selector(&self, selector: Option<&str>) -> Result<()> { - let input = match fs::read_to_string(&self.global_config_path) { - Ok(input) => input, - Err(error) if error.kind() == ErrorKind::NotFound => String::new(), - Err(error) => { - eyre::bail!( - "Failed to read Decodex global config `{}`: {error}", - self.global_config_path.display() - ); - }, - }; - let mut document = if input.trim().is_empty() { - toml::Table::new() - } else { - toml::from_str::(&input)? + fn account_name_offsets(&self) -> Result> { + let document = self.load_global_config_document()?; + let Some(offsets) = document + .get("codex") + .and_then(toml::Value::as_table) + .and_then(|codex| codex.get("account_names")) + .and_then(toml::Value::as_table) + .and_then(|account_names| account_names.get("offsets")) + .and_then(toml::Value::as_table) + else { + return Ok(BTreeMap::new()); }; + Ok(offsets + .iter() + .filter_map(|(key, value)| { + let key = key.trim(); + (!key.is_empty()).then_some(( + key.to_owned(), + normalize_random_name_offset(value.as_integer().unwrap_or_default()), + )) + }) + .collect()) + } + + fn write_account_name_offset(&self, key: &str, offset: i64) -> Result<()> { + let key = key.trim(); + + if key.is_empty() { + eyre::bail!("Codex account name key cannot be empty."); + } + + let mut document = self.load_global_config_document()?; + let offsets = ensure_toml_table( + ensure_toml_table(ensure_toml_table(&mut document, "codex")?, "account_names")?, + "offsets", + )?; + + offsets.insert(key.to_owned(), toml::Value::Integer(normalize_random_name_offset(offset))); + + self.write_global_config_document(&document) + } + + fn write_fixed_account_selector(&self, selector: Option<&str>) -> Result<()> { + let mut document = self.load_global_config_document()?; + match selector.map(str::trim).filter(|value| !value.is_empty()) { Some(selector) => { let accounts = @@ -356,6 +410,25 @@ impl AccountStore { }, } + self.write_global_config_document(&document) + } + + fn load_global_config_document(&self) -> Result { + let input = match fs::read_to_string(&self.global_config_path) { + Ok(input) => input, + Err(error) if error.kind() == ErrorKind::NotFound => return Ok(toml::Table::new()), + Err(error) => { + eyre::bail!( + "Failed to read Decodex global config `{}`: {error}", + self.global_config_path.display() + ); + }, + }; + + if input.trim().is_empty() { Ok(toml::Table::new()) } else { Ok(toml::from_str(&input)?) } + } + + fn write_global_config_document(&self, document: &toml::Table) -> Result<()> { let parent = self.global_config_path.parent().ok_or_else(|| { eyre::eyre!( "Decodex global config `{}` must have a parent directory.", @@ -439,6 +512,9 @@ pub(crate) struct AccountSummary { pub(crate) account_fingerprint: String, pub(crate) email: Option, pub(crate) selector: String, + pub(crate) random_name: String, + pub(crate) random_name_key: String, + pub(crate) random_name_offset: i64, pub(crate) status: String, pub(crate) selected: bool, pub(crate) codex_active: bool, @@ -648,6 +724,7 @@ impl AccountPoolRecord { &self, fixed_selector: Option<&str>, codex_auth: Option<&AccountIdentity>, + name_offsets: &BTreeMap, ) -> AccountSummary { let now = OffsetDateTime::now_utc().unix_timestamp(); let account_fingerprint = self @@ -676,11 +753,17 @@ impl AccountPoolRecord { } else { "available" }; + let random_name_seed = random_name_seed_for(account_fingerprint.as_str(), self.email()); + let random_name_key = random_name_key(&random_name_seed); + let random_name_offset = name_offsets.get(&random_name_key).copied().unwrap_or_default(); AccountSummary { account_fingerprint, email: self.email(), selector, + random_name: random_name(&random_name_seed, random_name_offset), + random_name_key, + random_name_offset, status: status.to_owned(), selected, codex_active: codex_auth @@ -706,6 +789,17 @@ impl AccountPoolRecord { rate_limit_reached_type: None, } } + + fn random_name_key(&self) -> String { + let account_fingerprint = self + .account_id() + .map(redact_account_id) + .or_else(|| self.email()) + .unwrap_or_else(|| String::from("unknown")); + let seed = random_name_seed_for(account_fingerprint.as_str(), self.email()); + + random_name_key(&seed) + } } #[derive(Clone, Deserialize, Serialize)] @@ -837,6 +931,13 @@ pub(crate) fn account_logout(selector: &str) -> Result { AccountStore::global()?.logout(selector) } +pub(crate) fn account_reroll_name( + selector: &str, + offset: Option, +) -> Result { + AccountStore::global()?.reroll_name(selector, offset) +} + pub(crate) fn account_import(auth_json_path: &Path) -> Result { AccountStore::global()?.import_auth_json(auth_json_path) } @@ -1222,6 +1323,45 @@ fn number_as_i64(value: &serde_json::Value) -> Option { .or_else(|| value.as_f64().map(|number| number.round() as i64)) } +fn random_name_seed_for(account_fingerprint: &str, email: Option) -> String { + if !account_fingerprint.trim().is_empty() { + return account_fingerprint.to_owned(); + } + if let Some(email) = email.filter(|value| !value.trim().is_empty()) { + return email; + } + + String::from("account") +} + +fn random_name_key(seed: &str) -> String { + format!("{:08x}", account_identity_hash(seed)) +} + +fn random_name(seed: &str, offset: i64) -> String { + let index = (u64::from(account_identity_hash(seed)) + + u64::try_from(normalize_random_name_offset(offset)).unwrap_or_default()) + % u64::try_from(ACCOUNT_RANDOM_NAMES.len()).unwrap_or(1); + + ACCOUNT_RANDOM_NAMES[usize::try_from(index).unwrap_or_default()].to_owned() +} + +fn account_identity_hash(value: &str) -> u32 { + let text = if value.trim().is_empty() { "account" } else { value }; + let mut hash = 2_166_136_261_u32; + + for unit in text.encode_utf16() { + hash ^= u32::from(unit); + hash = hash.wrapping_mul(16_777_619); + } + + hash +} + +fn normalize_random_name_offset(offset: i64) -> i64 { + offset.rem_euclid(i64::try_from(ACCOUNT_RANDOM_NAMES.len()).unwrap_or(1)) +} + fn redact_account_id(account_id: &str) -> String { let tail = account_id.chars().rev().take(6).collect::>().into_iter().rev().collect::(); @@ -1384,6 +1524,40 @@ mod tests { assert!(response.accounts[1].codex_active); } + #[test] + fn reroll_name_persists_global_account_name_offset() { + let temp_dir = TempDir::new().expect("temp dir should create"); + let store = AccountStore::new( + temp_dir.path().join("accounts.jsonl"), + temp_dir.path().join("config.toml"), + ); + + store + .save_records(&[account_record( + "copy@example.com", + "acct_123456", + "header.eyJleHAiOjQxMDI0NDQ4MDB9.sig", + "refresh-secret", + )]) + .expect("records should save"); + + let initial = store.list().expect("account list should load"); + let updated = + store.reroll_name("copy@example.com", None).expect("account name should reroll"); + let reloaded = store.list().expect("account list should reload"); + + assert_eq!(initial.accounts[0].random_name_offset, 0); + assert_eq!(updated.accounts[0].random_name_offset, 1); + assert_ne!(initial.accounts[0].random_name, updated.accounts[0].random_name); + assert_eq!(reloaded.accounts[0].random_name, updated.accounts[0].random_name); + assert_eq!(reloaded.accounts[0].random_name_key, updated.accounts[0].random_name_key); + assert!( + fs::read_to_string(&store.global_config_path) + .expect("global config should read") + .contains("[codex.account_names.offsets]") + ); + } + #[test] fn list_response_merges_usage_snapshot() { let temp_dir = TempDir::new().expect("temp dir should create"); diff --git a/apps/decodex/src/orchestrator/operator_dashboard.html b/apps/decodex/src/orchestrator/operator_dashboard.html index 1ada98d13..883f0ce9b 100644 --- a/apps/decodex/src/orchestrator/operator_dashboard.html +++ b/apps/decodex/src/orchestrator/operator_dashboard.html @@ -3558,6 +3558,10 @@

Run History

let themeSelection = loadThemeSelection(); let accountEmailsHidden = loadAccountPrivacy(); let accountNameOffsets = loadAccountNameOffsets(); + let accountApiSnapshot = null; + let accountApiRefreshInFlight = false; + let accountApiRefreshedAt = 0; + const pendingAccountNameOffsetSyncs = new Set(); let accountPoolSort = loadAccountPoolSort(); let accountSelectionConfirmation = null; let projectFilterMode = loadProjectFilterMode(); @@ -4441,11 +4445,7 @@

Run History

Object.entries(parsed) .map(([key, value]) => [key, Number(value)]) .filter(([key, value]) => key && Number.isInteger(value)) - .map(([key, value]) => [ - key, - ((value % ACCOUNT_RANDOM_NAMES.length) + ACCOUNT_RANDOM_NAMES.length) % - ACCOUNT_RANDOM_NAMES.length, - ]), + .map(([key, value]) => [key, normalizeAccountNameOffset(value)]), ); } catch (_error) { return {}; @@ -4463,6 +4463,87 @@

Run History

} } + function normalizeAccountNameOffset(value) { + const number = Number(value); + if (!Number.isInteger(number)) { + return 0; + } + + return ( + (number % ACCOUNT_RANDOM_NAMES.length) + ACCOUNT_RANDOM_NAMES.length + ) % ACCOUNT_RANDOM_NAMES.length; + } + + function accountApiAccounts() { + return Array.isArray(accountApiSnapshot?.accounts) + ? accountApiSnapshot.accounts.filter(Boolean) + : []; + } + + function noteAccountApiSnapshot(response) { + if (!response || !Array.isArray(response.accounts)) { + return false; + } + + accountApiSnapshot = response; + accountApiRefreshedAt = Date.now(); + + return true; + } + + async function refreshAccountApiSnapshot(force = false) { + const now = Date.now(); + if ( + accountApiRefreshInFlight || + (!force && accountApiSnapshot && now - accountApiRefreshedAt < 15_000) + ) { + return; + } + + accountApiRefreshInFlight = true; + try { + const response = await fetch("/api/accounts", { + headers: { Accept: "application/json" }, + }); + if (!response.ok) { + return; + } + if (noteAccountApiSnapshot(await response.json()) && lastDashboardRender) { + renderDashboardState(lastDashboardRender); + } + } catch (_error) { + /* The websocket snapshot remains usable if the account API is unavailable. */ + } finally { + accountApiRefreshInFlight = false; + } + } + + async function postAccountNameOffset(account, offset = null) { + const selector = codexAccountControlSelector(account); + if (!selector) { + return false; + } + + const body = { selector }; + if (offset != null) { + body.random_name_offset = normalizeAccountNameOffset(offset); + } + + const response = await fetch("/api/accounts/reroll-name", { + method: "POST", + headers: { + Accept: "application/json", + "Content-Type": "application/json", + }, + body: JSON.stringify(body), + }); + if (!response.ok) { + return false; + } + + return noteAccountApiSnapshot(await response.json()); + } + function isAccountPoolSortKey(value) { return ACCOUNT_POOL_SORT_COLUMNS.some(([key]) => key === value); } @@ -5905,6 +5986,11 @@

Run History

} function codexAccountRandomNameKey(account) { + const serverKey = String(account?.random_name_key || "").trim(); + if (serverKey) { + return serverKey; + } + const identity = codexAccountIdentity(account) || codexAccountEmail(account) || @@ -5914,14 +6000,36 @@

Run History

return codexAccountIdentityHash(identity).toString(16).padStart(8, "0"); } - function codexAccountRandomNameOffset(account) { + function codexAccountStoredRandomNameOffset(account) { const key = codexAccountRandomNameKey(account); - const value = Number(accountNameOffsets[key] || 0); + if (!Object.prototype.hasOwnProperty.call(accountNameOffsets, key)) { + return null; + } - return Number.isInteger(value) ? value : 0; + return normalizeAccountNameOffset(accountNameOffsets[key]); + } + + function codexAccountServerRandomNameOffset(account) { + const value = Number(account?.random_name_offset); + + return Number.isInteger(value) ? normalizeAccountNameOffset(value) : null; + } + + function codexAccountRandomNameOffset(account) { + return ( + codexAccountStoredRandomNameOffset(account) ?? + codexAccountServerRandomNameOffset(account) ?? + 0 + ); } function codexAccountRandomName(account) { + const storedOffset = codexAccountStoredRandomNameOffset(account); + const serverName = String(account?.random_name || "").trim(); + if (storedOffset == null && serverName) { + return serverName; + } + const seed = codexAccountIdentity(account) || codexAccountEmail(account) || @@ -5934,6 +6042,35 @@

Run History

return ACCOUNT_RANDOM_NAMES[index]; } + function syncStoredAccountNameOffsets(accounts) { + for (const account of accounts) { + const key = codexAccountRandomNameKey(account); + const storedOffset = codexAccountStoredRandomNameOffset(account); + const serverOffset = codexAccountServerRandomNameOffset(account) ?? 0; + if ( + storedOffset == null || + storedOffset === serverOffset || + pendingAccountNameOffsetSyncs.has(key) + ) { + continue; + } + + pendingAccountNameOffsetSyncs.add(key); + postAccountNameOffset(account, storedOffset) + .then((updated) => { + if (updated) { + delete accountNameOffsets[key]; + persistAccountNameOffsets(); + if (lastDashboardRender) { + renderDashboardState(lastDashboardRender); + } + } + }) + .catch(() => {}) + .finally(() => pendingAccountNameOffsetSyncs.delete(key)); + } + } + function codexAccountShowsEmail(account) { return Boolean(codexAccountEmail(account) && !accountEmailsHidden); } @@ -6582,8 +6719,8 @@

Run History

function codexAccountPoolAccounts(snapshot) { const accountsByIdentity = new Map(); const configuredAccounts = Array.isArray(snapshot?.accounts) - ? snapshot.accounts.filter(Boolean) - : []; + ? [...accountApiAccounts(), ...snapshot.accounts.filter(Boolean)] + : accountApiAccounts(); const runs = [ ...(snapshot?.active_runs ?? []).map((run) => [run, true]), ...(snapshot?.recent_runs ?? []).map((run) => [run, false]), @@ -6595,7 +6732,8 @@

Run History

continue; } - accountsByIdentity.set(identity, account); + const existing = accountsByIdentity.get(identity); + accountsByIdentity.set(identity, existing ? { ...existing, ...account } : account); } for (const [run, isActiveRun] of runs) { @@ -6724,6 +6862,7 @@

Run History

function renderAccountPool(snapshot) { const accounts = codexAccountPoolAccounts(snapshot); + syncStoredAccountNameOffsets(accounts); renderAccountModeControl(snapshot); renderStableList(nodes.accountPool, renderCodexAccountPool(accounts, snapshot)); syncAccountSelectionConfirmationDom(); @@ -9541,6 +9680,7 @@

${escapeHtml(worktree.branch_name)}

const derived = buildDerivedState(snapshot); const reviewItems = reviewLaneItems(derived); + refreshAccountApiSnapshot(); renderHeader(snapshot, readiness, notices, snapshotPublishedAt, snapshotError); renderFlow(snapshot, derived); renderProjects(snapshot, derived); @@ -9671,16 +9811,36 @@

${escapeHtml(worktree.branch_name)}

return; } - const current = Number(accountNameOffsets[key] || 0); + const account = lastDashboardRender + ? codexAccountPoolAccounts(lastDashboardRender.snapshot).find( + (candidate) => codexAccountRandomNameKey(candidate) === key, + ) + : null; + const current = account + ? codexAccountRandomNameOffset(account) + : normalizeAccountNameOffset(accountNameOffsets[key]); + const next = normalizeAccountNameOffset(current + 1); accountNameOffsets = { ...accountNameOffsets, - [key]: ((current + 1) % ACCOUNT_RANDOM_NAMES.length + ACCOUNT_RANDOM_NAMES.length) % - ACCOUNT_RANDOM_NAMES.length, + [key]: next, }; persistAccountNameOffsets(); if (lastDashboardRender) { renderDashboardState(lastDashboardRender); } + if (account) { + postAccountNameOffset(account, next) + .then((updated) => { + if (updated) { + delete accountNameOffsets[key]; + persistAccountNameOffsets(); + if (lastDashboardRender) { + renderDashboardState(lastDashboardRender); + } + } + }) + .catch(() => {}); + } }); nodes.projectOverview.addEventListener("click", (event) => { diff --git a/apps/decodex/src/orchestrator/operator_http.rs b/apps/decodex/src/orchestrator/operator_http.rs index a55098bc4..905bda509 100644 --- a/apps/decodex/src/orchestrator/operator_http.rs +++ b/apps/decodex/src/orchestrator/operator_http.rs @@ -38,6 +38,7 @@ enum OperatorRequestRoute { AccountLogout, AccountImport, AccountUse, + AccountRerollName, } enum DashboardClientFrame { @@ -127,6 +128,7 @@ struct DashboardClientMessage { struct OperatorAccountRequest { selector: Option, auth_json_path: Option, + random_name_offset: Option, } struct DashboardControlAck<'a> { @@ -907,7 +909,11 @@ fn dashboard_account_selection_control_ack( } else { None }; - let result = runtime::write_global_fixed_account_selector(selector); + let result = if let Some(selector) = selector { + accounts::account_select(selector).map(|_| ()) + } else { + accounts::account_clear().map(|_| ()) + }; let (accepted, status, copy) = match (set_fixed, result) { (true, Ok(())) => ( true, @@ -1400,6 +1406,7 @@ fn operator_request_route_is_account_api(route: &OperatorRequestRoute) -> bool { | OperatorRequestRoute::AccountLogout | OperatorRequestRoute::AccountImport | OperatorRequestRoute::AccountUse + | OperatorRequestRoute::AccountRerollName ) } @@ -1474,6 +1481,20 @@ fn operator_account_http_response_body( serde_json::to_vec(&response).map_err(Into::into) }, + OperatorRequestRoute::AccountRerollName => { + let body = operator_account_request_body(request)?; + let selector = body + .selector + .as_deref() + .filter(|selector| !selector.trim().is_empty()) + .ok_or_else(|| eyre::eyre!("Account name reroll requires selector."))?; + let response = accounts::hydrate_account_list_usage(accounts::account_reroll_name( + selector, + body.random_name_offset, + )?); + + serde_json::to_vec(&response).map_err(Into::into) + }, _ => eyre::bail!("Unsupported account API route."), } } @@ -1490,7 +1511,11 @@ fn operator_account_request_body(request: &[u8]) -> Result | OperatorRequestRoute::AccountClear | OperatorRequestRoute::AccountLogout | OperatorRequestRoute::AccountImport - | OperatorRequestRoute::AccountUse => + | OperatorRequestRoute::AccountUse + | OperatorRequestRoute::AccountRerollName => http_response_bytes("405 Method Not Allowed", "text/plain; charset=utf-8", b"method not allowed"), } } @@ -1586,6 +1612,7 @@ fn parse_operator_state_request_route( ("POST", "/api/accounts/logout") => Ok(OperatorRequestRoute::AccountLogout), ("POST", "/api/accounts/import") => Ok(OperatorRequestRoute::AccountImport), ("POST", "/api/accounts/use") => Ok(OperatorRequestRoute::AccountUse), + ("POST", "/api/accounts/reroll-name") => Ok(OperatorRequestRoute::AccountRerollName), (_, OPERATOR_DASHBOARD_ENDPOINT_PATH | OPERATOR_DASHBOARD_ALIAS_ENDPOINT_PATH | OPERATOR_DASHBOARD_WS_ENDPOINT_PATH @@ -1595,7 +1622,8 @@ fn parse_operator_state_request_route( | "/api/accounts/clear" | "/api/accounts/logout" | "/api/accounts/import" - | "/api/accounts/use") => Err(http_response_bytes( + | "/api/accounts/use" + | "/api/accounts/reroll-name") => Err(http_response_bytes( "405 Method Not Allowed", "text/plain; charset=utf-8", b"method not allowed", diff --git a/apps/decodex/src/orchestrator/tests/operator/status/http.rs b/apps/decodex/src/orchestrator/tests/operator/status/http.rs index 160d5d4d5..ea649a09b 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/http.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/http.rs @@ -535,6 +535,14 @@ fn assert_dashboard_account_selection_controls( ) { let home = repo_root.parent().expect("fixture repo root should have a parent"); let _home_guard = TestEnvVarGuard::set("HOME", home.to_str().expect("fixture home should be UTF-8")); + let accounts_dir = home.join(".codex/decodex"); + + fs::create_dir_all(&accounts_dir).expect("account pool dir should create"); + fs::write( + accounts_dir.join("accounts.jsonl"), + r#"{"email":"copy@example.com","tokens":{"access_token":"token","refresh_token":"refresh","account_id":"acct_123456"}}"#, + ) + .expect("account pool should write"); client .write_all(&websocket_client_text_frame( @@ -1366,6 +1374,53 @@ fn operator_state_endpoint_serves_account_api_snapshot() { })); } +#[test] +fn operator_state_endpoint_persists_account_random_name_offset() { + let temp_dir = TempDir::new().expect("temp dir should exist"); + let _home_guard = + TestEnvVarGuard::set("HOME", temp_dir.path().to_str().expect("temp path should be UTF-8")); + let accounts_dir = temp_dir.path().join(".codex/decodex"); + let accounts_path = accounts_dir.join("accounts.jsonl"); + + fs::create_dir_all(&accounts_dir).expect("accounts dir should create"); + fs::write( + &accounts_path, + r#"{"email":"copy@example.com","tokens":{"access_token":"token","refresh_token":"refresh","account_id":"acct_123456"}}"#, + ) + .expect("account pool should write"); + + let body = br#"{"selector":"copy@example.com"}"#; + let request = format!( + "POST /api/accounts/reroll-name HTTP/1.1\r\nHost: localhost\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}", + body.len(), + String::from_utf8_lossy(body) + ); + let response = String::from_utf8( + orchestrator::build_operator_state_http_response(request.as_bytes()) + .expect("account reroll response should build"), + ) + .expect("account reroll response should be utf-8"); + + assert!(response.starts_with("HTTP/1.1 200 OK\r\n")); + + let data: Value = serde_json::from_str( + response + .split_once("\r\n\r\n") + .map(|(_, body)| body) + .expect("account reroll response should include body"), + ) + .expect("account reroll response should be json"); + + assert_eq!(data["accounts"][0]["random_name_offset"], 1); + assert_eq!(data["accounts"][0]["random_name_key"], "df65f796"); + assert_eq!(data["accounts"][0]["random_name"], "Logan"); + assert!( + fs::read_to_string(accounts_dir.join("config.toml")) + .expect("global config should read") + .contains("df65f796 = 1") + ); +} + #[test] fn operator_state_endpoint_rejects_removed_http_snapshot_routes() { for removed_path in ["/state", "/readyz"] { diff --git a/apps/decodex/src/runtime.rs b/apps/decodex/src/runtime.rs index 0729d387f..5e19eb913 100644 --- a/apps/decodex/src/runtime.rs +++ b/apps/decodex/src/runtime.rs @@ -5,7 +5,6 @@ use std::{ env, fs, io::ErrorKind, path::{Path, PathBuf}, - process, }; use toml::Value; @@ -59,6 +58,7 @@ pub(crate) fn global_fixed_account_selector() -> Result> { } /// Write the global fixed account selector. `None` returns the pool to balanced mode. +#[cfg(test)] pub(crate) fn write_global_fixed_account_selector(selector: Option<&str>) -> Result<()> { let config_path = global_config_path()?; let input = match fs::read_to_string(&config_path) { @@ -103,7 +103,7 @@ pub(crate) fn write_global_fixed_account_selector(selector: Option<&str>) -> Res .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| eyre::eyre!("Decodex global config path must end in a valid file name."))?; - let temp_path = parent.join(format!(".{file_name}.tmp-{}", process::id())); + let temp_path = parent.join(format!(".{file_name}.tmp-{}", std::process::id())); let output = toml::to_string_pretty(&document)?; fs::create_dir_all(parent)?; @@ -214,6 +214,7 @@ fn decodex_home_dir_from(home: PathBuf) -> PathBuf { home.join(".codex").join("decodex") } +#[cfg(test)] fn ensure_toml_table<'a>(table: &'a mut toml::Table, key: &str) -> Result<&'a mut toml::Table> { if !table.contains_key(key) { table.insert(String::from(key), toml::Table::new().into()); diff --git a/docs/reference/operator-control-plane.md b/docs/reference/operator-control-plane.md index 88c0a2801..9b156d8e3 100644 --- a/docs/reference/operator-control-plane.md +++ b/docs/reference/operator-control-plane.md @@ -113,7 +113,7 @@ outside the local operator surface. | Section | Meaning | | --- | --- | -| `Accounts` | Shared Codex account pool and usage table from `~/.codex/decodex/accounts.jsonl` when `[codex.accounts]` is enabled for a project. Account identity can be obscured from the `Account` column header eye without changing the underlying snapshot. Selecting an account writes the global `[codex.accounts].fixed_account` selector in `~/.codex/decodex/config.toml`; clearing it returns all new account-pool runs to balanced account selection. The selector is global and does not pin a project to an account. | +| `Accounts` | Shared Codex account pool and usage table from `~/.codex/decodex/accounts.jsonl` when `[codex.accounts]` is enabled for a project. Account identity can be obscured from the `Account` column header eye without changing the underlying snapshot. Selecting an account writes the global `[codex.accounts].fixed_account` selector in `~/.codex/decodex/config.toml`; clearing it returns all new account-pool runs to balanced account selection. Account display-name rerolls write `[codex.account_names.offsets]` in the same global config so Decodex App and the dashboard share the privacy-preserving names. Theme, sort, and identity-visibility preferences are client-local presentation state. The selector is global and does not pin a project to an account. | | `Projects` | Fleet-level project table. The section-level filter toggles between active project work and the full registry. Location is its own compact path column and can be obscured from the location header eye. `Activity` shows a relative timestamp or `-`; `Work` is `running/waiting/attention`. It should not duplicate per-lane details already shown below. | | `Running Lanes` | Active leased or live-executing issue lanes. A lane here is currently owned by this local control plane, or a live process/thread/protocol marker still explains active execution even when the queue lease is not held. It shows issue identity, phase, operation, attempt, queue lease state, execution liveness, thread/protocol status, child-agent activity when captured, timing, branch, and worktree. | | `Intake Queue` | Queued tracker issues before execution. Candidates are classified as `ready`, capacity-waiting, claimed without a matching local lane, blocked, or closed/stale. A blocked queued candidate can still show an attached `.worktrees/XY-*` path when the queue owns the attention state; if that worktree has tracked changes after retries, the candidate is partial retained progress and not just a generic retry-budget hold. Running lanes are not repeated as normal intake work. | diff --git a/docs/spec/runtime.md b/docs/spec/runtime.md index 8fc7c4672..a74fba469 100644 --- a/docs/spec/runtime.md +++ b/docs/spec/runtime.md @@ -296,7 +296,7 @@ and idempotency fields are defined by ## Local operational state -The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, the optional shared Codex account pool lives at `~/.codex/decodex/accounts.jsonl`, global operator config lives at `~/.codex/decodex/config.toml`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. +The local runtime store is the global Decodex SQLite database for one local installation. It lives at `~/.codex/decodex/runtime.sqlite3`, not inside any registered project checkout or worktree. Every row that belongs to a repo is scoped by `project_id`. Decodex logs live beside that database under `~/.codex/decodex/logs/`, the optional shared Codex account pool lives at `~/.codex/decodex/accounts.jsonl`, global operator config lives at `~/.codex/decodex/config.toml`, and agent-readable derived evidence lives under `~/.codex/decodex/agent-evidence//`; vendor-qualified app-data directories and per-project runtime databases are not part of the runtime contract. Global operator config owns account-pool routing and shared account display-name offsets. UI-only preferences such as theme, table sorting, and local privacy visibility are not runtime state. Project contracts live outside registered repositories under `~/.codex/decodex/projects//`. Each project directory must contain `project.toml` and `WORKFLOW.md`; arbitrary project file names such as `.toml` are not part of the contract. `project.toml` must set `[paths].repo_root` so the project contract is explicit. Project registration stores the centralized `config_path`, target `repo_root`, `worktree_root`, and workflow path in the global runtime database. Commands that start inside a registered checkout or lane worktree resolve the project through that registry; they do not discover or trust worktree-local config files. `decodex serve` loads enabled registered projects from the global runtime database. It must not scan `.codex` history, repo-local config files, or currently open worktrees to infer additional projects. From cf2c728cf348464cf64fa1effbc556e210a74101 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 20 May 2026 12:52:18 +0800 Subject: [PATCH 6/8] {"schema":"decodex/commit/1","summary":"Remove unused Decodex app settings panel","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 12 ----- .../Sources/DecodexApp/DecodexApp.swift | 3 -- .../Sources/DecodexApp/SettingsView.swift | 45 ------------------- 3 files changed, 60 deletions(-) delete mode 100644 apps/decodex-app/Sources/DecodexApp/SettingsView.swift diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 6e29e1a63..3a8636b3f 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -532,18 +532,6 @@ struct AccountPanelView: View { .transition(.opacity.combined(with: .scale(scale: 0.96))) } - SettingsLink { - PanelIconLabelView(symbol: "gearshape", tint: PanelPalette.actionBlue(colorScheme)) - } - .buttonStyle( - PanelInteractiveButtonStyle( - hoverLift: 0.55, - hoverScale: 1.01, - pressedScale: 0.982, - hoverShadowRadius: 3 - ) - ) - .help("Settings") } .animation(PanelMotion.state, value: hasFixedSelection) } diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift index 590b238be..caaa34a78 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift @@ -50,8 +50,5 @@ struct DecodexApp: App { } .menuBarExtraStyle(.window) - Settings { - SettingsView(store: store) - } } } diff --git a/apps/decodex-app/Sources/DecodexApp/SettingsView.swift b/apps/decodex-app/Sources/DecodexApp/SettingsView.swift deleted file mode 100644 index 496f7b3af..000000000 --- a/apps/decodex-app/Sources/DecodexApp/SettingsView.swift +++ /dev/null @@ -1,45 +0,0 @@ -import SwiftUI - -struct SettingsView: View { - @ObservedObject var store: AccountStore - - var body: some View { - Form { - Section("Paths") { - pathRow("Accounts", store.accountList?.accountsPath) - pathRow("Config", store.accountList?.globalConfigPath) - pathRow("Codex Auth", store.accountList?.codexAuthPath) - } - - Section("Helper") { - Text(ProcessInfo.processInfo.environment["DECODEX_APP_HELPER"] ?? "Bundled decodex-app-helper") - .textSelection(.enabled) - } - - Section("Server") { - Text(ProcessInfo.processInfo.environment["DECODEX_APP_SERVER_URL"] ?? "http://127.0.0.1:8912") - .textSelection(.enabled) - Text(ProcessInfo.processInfo.environment["DECODEX_APP_DECODEX"] ?? "Bundled decodex") - .textSelection(.enabled) - } - } - .formStyle(.grouped) - .frame(width: 520, height: 320) - .padding() - .task { - await store.refresh() - } - } - - private func pathRow(_ title: String, _ path: String?) -> some View { - HStack { - Text(title) - Spacer() - Text(path ?? "-") - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - .textSelection(.enabled) - } - } -} From b71d8290e86a413e01e49d79f10ed2a6f6ad98f4 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 20 May 2026 22:37:19 +0800 Subject: [PATCH 7/8] {"schema":"decodex/commit/1","summary":"Refine Decodex app glass UI","authority":"manual"} --- .../Sources/DecodexApp/AccountPanelView.swift | 1096 ++++------------- .../Sources/DecodexApp/AccountStore.swift | 58 +- .../Sources/DecodexApp/DecodexApp.swift | 36 +- .../Sources/DecodexApp/LoginSheetView.swift | 150 ++- .../Sources/DecodexApp/Models.swift | 58 + 5 files changed, 520 insertions(+), 878 deletions(-) diff --git a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift index 3a8636b3f..9f0803f51 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountPanelView.swift @@ -1,3 +1,4 @@ +import AppKit import Foundation import SwiftUI @@ -10,125 +11,64 @@ private enum PanelFont { .system(size: size, weight: weight, design: design) } - static let headerIcon = text(13.6, weight: .semibold) - static let headerTitle = text(13.6, weight: .semibold) - static let headerSubtitle = text(10.1, weight: .medium) + static let headerIcon = text(13.8, weight: .semibold) + static let headerTitle = text(14.2, weight: .semibold) + static let headerSubtitle = text(10.6, weight: .medium) static let emptyIcon = text(16.5, weight: .medium) - static let emptyTitle = text(11.3, weight: .semibold) - static let emptyBody = text(10.1, weight: .regular) - static let notice = text(9.9, weight: .regular) - static let summaryIcon = text(9.7, weight: .medium) - static let summaryTitle = text(9.4, weight: .medium) - static let summaryValue = text(10.9, weight: .semibold) - static let accountName = text(11.9, weight: .semibold) - static let accountDetail = text(9.9, weight: .medium) - static let usage = text(8.45, weight: .medium) - static let usageMeta = text(7.85, weight: .medium) - static let primaryButton = text(10.7, weight: .semibold) - static let iconButton = text(10.7, weight: .semibold) - static let footerIcon = text(10.1, weight: .medium) + static let emptyTitle = text(11.8, weight: .semibold) + static let emptyBody = text(10.4, weight: .regular) + static let notice = text(10.1, weight: .regular) + static let summaryIcon = text(10, weight: .medium) + static let summaryTitle = text(9.8, weight: .medium) + static let summaryValue = text(11.3, weight: .semibold) + static let accountName = text(12.6, weight: .semibold) + static let accountDetail = text(10.4, weight: .medium) + static let usage = text(9.6, weight: .semibold) + static let usageMeta = text(8.9, weight: .medium) + static let iconButton = text(11, weight: .semibold) } private enum PanelPalette { static func primaryText(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.88, green: 0.94, blue: 1).opacity(0.96) - : Color(red: 0.11, green: 0.17, blue: 0.26).opacity(0.94) + ? Color(red: 0.95, green: 0.96, blue: 0.98).opacity(0.97) + : Color(red: 0.12, green: 0.14, blue: 0.18).opacity(0.94) } static func secondaryText(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.6, green: 0.7, blue: 0.82).opacity(0.82) - : Color(red: 0.3, green: 0.4, blue: 0.53).opacity(0.8) - } - - static func panelTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.025, green: 0.075, blue: 0.13).opacity(0.68) - : Color(red: 0.46, green: 0.68, blue: 0.9).opacity(0.62) - } - - static func summaryTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.16, green: 0.28, blue: 0.42).opacity(0.22) - : Color.white.opacity(0.58) - } - - static func accountRowTint(_ colorScheme: ColorScheme, isSelected: Bool, isCodexActive: Bool) -> Color { - if isSelected { - return colorScheme == .dark - ? Color(red: 0.16, green: 0.32, blue: 0.5).opacity(0.34) - : Color(red: 0.88, green: 0.96, blue: 1).opacity(0.76) - } - if isCodexActive { - return colorScheme == .dark - ? Color(red: 0.13, green: 0.25, blue: 0.38).opacity(0.28) - : Color.white.opacity(0.62) - } - - return colorScheme == .dark - ? Color(red: 0.11, green: 0.2, blue: 0.32).opacity(0.24) - : Color.white.opacity(0.6) - } - - static func usageTray(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.015, green: 0.045, blue: 0.08).opacity(0.54) - : Color.white.opacity(0.18) - } - - static func usageTrayStroke(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.52, green: 0.72, blue: 0.94).opacity(0.16) - : Color.white.opacity(0.18) - } - - static func addButtonTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.13, green: 0.31, blue: 0.5).opacity(0.48) - : Color(red: 0.86, green: 0.94, blue: 1).opacity(0.84) - } - - static func addButtonStroke(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.5, green: 0.76, blue: 1).opacity(0.36) - : Color(red: 0.24, green: 0.43, blue: 0.64).opacity(0.42) - } - - static func controlTint(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.16, green: 0.3, blue: 0.46).opacity(0.34) - : Color(red: 0.9, green: 0.96, blue: 1).opacity(0.86) - } - - static func controlStroke(_ colorScheme: ColorScheme) -> Color { - colorScheme == .dark - ? Color(red: 0.55, green: 0.76, blue: 1).opacity(0.28) - : Color(red: 0.28, green: 0.46, blue: 0.66).opacity(0.38) + ? Color(red: 0.73, green: 0.76, blue: 0.82).opacity(0.84) + : Color(red: 0.34, green: 0.38, blue: 0.45).opacity(0.8) } static func separator(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color.white.opacity(0.065) - : Color(red: 0.42, green: 0.58, blue: 0.75).opacity(0.18) + : Color(red: 0.32, green: 0.38, blue: 0.46).opacity(0.14) } static func actionBlue(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.43, green: 0.74, blue: 1) - : Color(red: 0.1, green: 0.28, blue: 0.46) + ? Color(red: 0.86, green: 0.89, blue: 0.94) + : Color(red: 0.18, green: 0.29, blue: 0.4) } - static func activeGold(_ colorScheme: ColorScheme) -> Color { + static func codexAccent(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.95, green: 0.8, blue: 0.34) - : Color(red: 0.28, green: 0.42, blue: 0.55) + ? Color(red: 0.86, green: 0.89, blue: 0.94) + : Color(red: 0.22, green: 0.38, blue: 0.52) + } + + static func routeAccent(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.82, green: 0.87, blue: 0.94) + : Color(red: 0.18, green: 0.34, blue: 0.52) } static func usageMint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.36, green: 0.94, blue: 0.76) - : Color(red: 0.38, green: 0.56, blue: 0.64) + ? Color(red: 0.78, green: 0.84, blue: 0.9) + : Color(red: 0.28, green: 0.38, blue: 0.5) } static func warning(_ colorScheme: ColorScheme) -> Color { @@ -145,14 +85,14 @@ private enum PanelPalette { static func progressTrack(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0, green: 0.025, blue: 0.055).opacity(0.62) - : Color(red: 0.12, green: 0.28, blue: 0.4).opacity(0.16) + ? Color.white.opacity(0.09) + : Color(red: 0.15, green: 0.23, blue: 0.3).opacity(0.1) } static func progressEdge(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark - ? Color(red: 0.56, green: 0.78, blue: 1).opacity(0.2) - : Color.white.opacity(0.24) + ? Color.white.opacity(0.12) + : Color.white.opacity(0.22) } } @@ -262,21 +202,22 @@ private struct PanelInteractiveSurfaceModifier: ViewModifier { } private var pressBrightness: Double { - colorScheme == .dark ? -0.018 : -0.01 + colorScheme == .dark ? 0.006 : -0.004 } private var hoverShadowColor: Color { colorScheme == .dark ? Color.black.opacity(0.18) - : Color(red: 0.1, green: 0.24, blue: 0.38).opacity(0.12) + : Color.black.opacity(0.09) } } struct AccountPanelView: View { @ObservedObject var store: AccountStore + @ObservedObject var loginWindowState: LoginWindowState @Environment(\.colorScheme) private var colorScheme + @Environment(\.openWindow) private var openWindow @State private var pendingLogout: CodexAccount? - @State private var loginPresented = false @AppStorage("decodex.operator.accountPrivacy") private var accountPrivacy = AccountPrivacy.hiddenValue var body: some View { @@ -313,9 +254,6 @@ struct AccountPanelView: View { Text("This removes \(displayName(for: account)) from the Decodex account pool on this Mac.") } } - .sheet(isPresented: $loginPresented) { - LoginSheetView(store: store) - } } private var panelContent: some View { @@ -338,14 +276,11 @@ struct AccountPanelView: View { } else { accountList } - - footer } .frame(width: 310) .padding(9) .modernGlassSurface( cornerRadius: 18, - tint: PanelPalette.panelTint(colorScheme), depth: .panel ) .controlSize(.small) @@ -359,13 +294,8 @@ struct AccountPanelView: View { .renderingMode(.template) .scaledToFit() .foregroundStyle(PanelPalette.actionBlue(colorScheme)) - .frame(width: 16, height: 16) + .frame(width: 20, height: 20) .frame(width: 28, height: 28) - .modernGlassSurface( - cornerRadius: 8, - tint: PanelPalette.controlTint(colorScheme), - depth: .control - ) VStack(alignment: .leading, spacing: 2) { Text("Decodex") @@ -379,7 +309,7 @@ struct AccountPanelView: View { Spacer() - HStack(spacing: 4) { + HStack(spacing: 5) { PanelIconButtonView( symbol: emailsHidden ? "eye.slash" : "eye", tint: PanelPalette.secondaryText(colorScheme), @@ -390,35 +320,49 @@ struct AccountPanelView: View { help: emailsHidden ? "Show account emails" : "Hide account emails" ) + if hasFixedSelection { + PanelIconButtonView( + symbol: "shuffle", + tint: PanelPalette.actionBlue(colorScheme), + isActive: false, + action: { + Task { + await store.clearSelection() + } + }, + help: "Restore balanced run routing" + ) + .transition(.opacity.combined(with: .scale(scale: 0.96))) + } + PanelIconButtonView( - symbol: store.isRefreshing ? "arrow.triangle.2.circlepath.circle" : "arrow.clockwise", - tint: PanelPalette.secondaryText(colorScheme), - isActive: store.isRefreshing, - isDisabled: store.isRefreshing, + symbol: "plus", + tint: PanelPalette.actionBlue(colorScheme), + isActive: false, + isPrimary: true, + size: 25, action: { - Task { - await store.refresh(force: true) - } + presentLogin(.add) }, - help: "Refresh" + help: "Add login" ) } } + .animation(PanelMotion.state, value: hasFixedSelection) } private var accountSummary: some View { - HStack(spacing: 0) { + HStack(spacing: 7) { SummaryTileView( title: "Codex", value: codexAuthLabel, symbol: "person.crop.circle", - tint: PanelPalette.activeGold(colorScheme) + tint: PanelPalette.codexAccent(colorScheme) ) Rectangle() .fill(PanelPalette.separator(colorScheme)) - .frame(width: 0.5) - .padding(.vertical, 3) + .frame(width: 0.5, height: 16) SummaryTileView( title: "Runs", @@ -427,13 +371,15 @@ struct AccountPanelView: View { tint: hasFixedSelection ? PanelPalette.actionBlue(colorScheme) : PanelPalette.secondaryText(colorScheme) ) } - .padding(.horizontal, 7) - .padding(.vertical, 4) - .modernGlassSurface( - cornerRadius: 9, - tint: PanelPalette.summaryTint(colorScheme), - depth: .row - ) + .padding(.horizontal, 3) + .padding(.top, 1) + .padding(.bottom, 4) + .overlay(alignment: .bottom) { + Rectangle() + .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.72 : 0.9)) + .frame(height: 0.5) + .allowsHitTesting(false) + } } private var emptyState: some View { @@ -483,11 +429,12 @@ struct AccountPanelView: View { } private var accountRows: some View { - VStack(spacing: 5) { - ForEach(store.accounts) { account in + VStack(spacing: 0) { + ForEach(Array(store.accounts.enumerated()), id: \.element.id) { index, account in AccountRowView( account: account, emailsHidden: emailsHidden, + showsDivider: index < store.accounts.count - 1, useInCodex: { Task { await store.useInCodex(account) @@ -498,42 +445,16 @@ struct AccountPanelView: View { await store.select(account) } }, + reauthenticate: { + presentLogin(.reauth(displayName(for: account))) + }, logout: { pendingLogout = account } ) } } - .padding(.vertical, 1) - } - - private var footer: some View { - HStack(spacing: 5) { - PanelPrimaryButtonView( - title: "Add Login", - symbol: "plus.circle", - action: { - loginPresented = true - } - ) - - if hasFixedSelection { - PanelIconButtonView( - symbol: "shuffle", - tint: PanelPalette.actionBlue(colorScheme), - isActive: false, - action: { - Task { - await store.clearSelection() - } - }, - help: "Restore balanced run routing" - ) - .transition(.opacity.combined(with: .scale(scale: 0.96))) - } - - } - .animation(PanelMotion.state, value: hasFixedSelection) + .padding(.top, 1) } private var codexAuthLabel: String { @@ -587,13 +508,13 @@ struct AccountPanelView: View { private var accountListHeight: CGFloat { let rows = store.accounts.reduce(CGFloat(0)) { total, account in - total + (account.hasUsageSummary ? 82 : 44) + total + (account.hasUsageSummary ? 98 : 46) } let spacing = CGFloat(max(store.accounts.count - 1, 0)) * 5 + 2 return min( rows + spacing, - 278 + 312 ) } @@ -616,20 +537,29 @@ struct AccountPanelView: View { account.matchesSelector(selector) } } + + private func presentLogin(_ mode: AccountLoginSheetMode) { + loginWindowState.mode = mode + store.resetLoginSession() + NSApp.activate(ignoringOtherApps: true) + openWindow(id: DecodexWindowID.login) + } } struct AccountRowView: View { let account: CodexAccount let emailsHidden: Bool + let showsDivider: Bool let useInCodex: () -> Void let routeRunsHere: () -> Void + let reauthenticate: () -> Void let logout: () -> Void @Environment(\.colorScheme) private var colorScheme var body: some View { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .top, spacing: 8) { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 6) { + HStack(alignment: .center, spacing: 8) { + HStack(alignment: .firstTextBaseline, spacing: 5) { Text(displayName) .font(PanelFont.accountName) .foregroundStyle(PanelPalette.primaryText(colorScheme)) @@ -637,47 +567,69 @@ struct AccountRowView: View { .truncationMode(.middle) .layoutPriority(1) - if account.hasVisibleMetadata { - HStack(spacing: 4) { - if let planLabel = account.planLabel { - Text(planLabel) - .lineLimit(1) - } - if account.planLabel != nil, account.compactHealthLabel != nil { - Text("·") - } - if let healthLabel = account.compactHealthLabel { - Text(healthLabel) - .foregroundStyle(account.statusDisplayColor(colorScheme: colorScheme)) - .lineLimit(1) - } - } - .font(PanelFont.accountDetail) - .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + if let planLabel = account.planLabel { + Text("·") + .font(PanelFont.accountDetail) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.62)) + .fixedSize(horizontal: true, vertical: false) + + Text(planLabel) + .font(PanelFont.accountDetail) + .foregroundStyle(PanelPalette.secondaryText(colorScheme)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) + } + + if let healthLabel = account.compactHealthLabel { + Text("·") + .font(PanelFont.accountDetail) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.62)) + .fixedSize(horizontal: true, vertical: false) + + Text(healthLabel) + .font(PanelFont.accountDetail) + .foregroundStyle(account.statusDisplayColor(colorScheme: colorScheme)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) } } .frame(maxWidth: .infinity, alignment: .leading) HStack(spacing: 3) { - PanelIconButtonView( - symbol: account.codexActive ? "person.crop.circle.fill" : "person.crop.circle", - tint: PanelPalette.activeGold(colorScheme), - isActive: account.codexActive, - isDisabled: account.codexActive, - isSubtle: true, - size: 23, - action: useInCodex, - help: account.codexActive ? "Current Codex account" : "Use as Codex account" - ) + if account.needsLogin { + PanelIconButtonView( + symbol: "person.crop.circle.badge.plus", + tint: PanelPalette.warning(colorScheme), + isActive: false, + isPrimary: true, + size: 21, + action: reauthenticate, + help: "Re-authenticate account" + ) + } else { + PanelIconButtonView( + symbol: account.codexActive ? "person.crop.circle.fill" : "person.crop.circle", + tint: PanelPalette.codexAccent(colorScheme), + isActive: account.codexActive, + isDisabled: account.codexActive || !account.canUseInCodex, + isSubtle: true, + size: 21, + action: useInCodex, + help: account.codexActive ? "Current Codex account" : "Use as Codex account" + ) + } PanelIconButtonView( symbol: "arrow.triangle.branch", - tint: PanelPalette.actionBlue(colorScheme), + tint: account.selected + ? PanelPalette.routeAccent(colorScheme) + : PanelPalette.actionBlue(colorScheme), isActive: account.selected, + isDisabled: !account.canRouteRuns && !account.selected, isSubtle: true, - size: 23, + size: 21, action: routeRunsHere, - help: account.selected ? "Restore balanced run routing" : "Route Decodex runs here" + help: routeHelp ) PanelIconButtonView( @@ -685,7 +637,8 @@ struct AccountRowView: View { tint: PanelPalette.destructive(colorScheme), isActive: false, isDestructive: true, - size: 23, + isSubtle: true, + size: 21, action: logout, help: "Remove account" ) @@ -696,24 +649,19 @@ struct AccountRowView: View { AccountUsageSummaryView(account: account) } } - .padding(.vertical, 5) + .padding(.vertical, 7) .padding(.leading, 8) .padding(.trailing, 7) - .modernGlassSurface( - cornerRadius: 9, - tint: PanelPalette.accountRowTint( - colorScheme, - isSelected: account.selected, - isCodexActive: account.codexActive - ), - depth: .row - ) - .panelInteractiveSurface( - hoverLift: 0.35, - hoverScale: 1.002, - pressedScale: 1, - hoverShadowRadius: 2 - ) + .overlay(alignment: .bottom) { + if showsDivider { + Rectangle() + .fill(PanelPalette.separator(colorScheme).opacity(colorScheme == .dark ? 0.48 : 0.72)) + .frame(height: 0.5) + .padding(.leading, 8) + .padding(.trailing, 7) + .allowsHitTesting(false) + } + } .animation(PanelMotion.state, value: account.selected) .animation(PanelMotion.state, value: account.codexActive) } @@ -722,6 +670,19 @@ struct AccountRowView: View { account.panelDisplayName(emailsHidden: emailsHidden) } + private var routeHelp: String { + if account.selected { + return "Restore balanced run routing" + } + if account.needsLogin { + return "Re-authenticate before routing runs" + } + if account.disabled { + return "Disabled account cannot route runs" + } + + return "Route Decodex runs here" + } } struct AccountUsageSummaryView: View { @@ -729,7 +690,7 @@ struct AccountUsageSummaryView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - VStack(spacing: 3) { + VStack(spacing: 5) { if account.hasPrimaryUsageData { AccountUsageMeterView( label: account.windowLabel(seconds: account.primaryWindowSeconds), @@ -749,29 +710,8 @@ struct AccountUsageSummaryView: View { } } .frame(maxWidth: .infinity) - .padding(.horizontal, 5) - .padding(.vertical, 3) - .background { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .fill(PanelPalette.usageTray(colorScheme)) - } - .overlay { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(PanelPalette.usageTrayStroke(colorScheme), lineWidth: 0.35) - .allowsHitTesting(false) - } - .shadow( - color: usageTrayShadow, - radius: colorScheme == .dark ? 4.5 : 3.5, - x: 0, - y: colorScheme == .dark ? 1.4 : 1 - ) - } - - private var usageTrayShadow: Color { - colorScheme == .dark - ? Color(red: 0, green: 0.05, blue: 0.11).opacity(0.26) - : Color(red: 0.12, green: 0.28, blue: 0.42).opacity(0.055) + .padding(.horizontal, 1) + .padding(.vertical, 1) } } @@ -783,14 +723,14 @@ struct AccountUsageMeterView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 3) { HStack(spacing: 5) { Text(label) - .frame(width: 37, alignment: .leading) + .frame(width: 42, alignment: .leading) .foregroundStyle(PanelPalette.secondaryText(colorScheme)) Text(remainingText) - .frame(width: 32, alignment: .leading) + .frame(width: 40, alignment: .leading) .foregroundStyle(valueColor) .monospacedDigit() @@ -808,9 +748,9 @@ struct AccountUsageMeterView: View { .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(colorScheme == .dark ? 0.68 : 0.78)) .lineLimit(1) .truncationMode(.middle) - } + } } - .frame(height: 10) + .frame(height: 12) GeometryReader { proxy in ZStack(alignment: .leading) { @@ -827,37 +767,24 @@ struct AccountUsageMeterView: View { Capsule() .fill(fillStyle) .frame(width: width) + .clipShape(Capsule()) .animation(PanelMotion.state, value: remainingPercent) .shadow( - color: color.opacity(colorScheme == .dark ? 0.34 : 0.18), - radius: colorScheme == .dark ? 3.4 : 2.2, + color: color.opacity(colorScheme == .dark ? 0.09 : 0.07), + radius: colorScheme == .dark ? 1.2 : 1, x: 0, y: 0 ) - .overlay(alignment: .top) { - Capsule() - .fill(Color.white.opacity(colorScheme == .dark ? 0.28 : 0.34)) - .frame(height: 1) - .padding(.horizontal, 1.2) - .allowsHitTesting(false) - } - .overlay(alignment: .trailing) { - Capsule() - .fill(progressCapStyle) - .frame(width: min(13, max(4, width)), height: 5) - .opacity(progress > 0.04 ? 1 : 0) - .allowsHitTesting(false) - } Capsule() - .strokeBorder(trackEdgeColor, lineWidth: 0.35) + .strokeBorder(trackEdgeColor, lineWidth: 0.24) .allowsHitTesting(false) } } - .frame(height: 5.2) + .frame(height: 3.2) } .font(PanelFont.usage) .lineLimit(1) - .frame(height: 18) + .frame(height: 20) .frame(maxWidth: .infinity, alignment: .leading) .accessibilityLabel("\(label) remaining \(remainingText), \(resetDisplay.accessibility)") } @@ -888,9 +815,9 @@ struct AccountUsageMeterView: View { private var color: Color { switch tone { - case .codexActive: return PanelPalette.activeGold(colorScheme) + case .codexActive: return PanelPalette.codexAccent(colorScheme) case .ready: return PanelPalette.usageMint(colorScheme) - case .selected: return PanelPalette.actionBlue(colorScheme) + case .selected: return PanelPalette.routeAccent(colorScheme) case .warning: return PanelPalette.warning(colorScheme) case .danger: return PanelPalette.destructive(colorScheme) case .neutral: return PanelPalette.secondaryText(colorScheme) @@ -921,9 +848,8 @@ struct AccountUsageMeterView: View { private var fillStyle: LinearGradient { LinearGradient( colors: [ - color.opacity(colorScheme == .dark ? 0.98 : 0.78), - color.opacity(colorScheme == .dark ? 0.82 : 0.64), - color.opacity(colorScheme == .dark ? 0.58 : 0.48), + color.opacity(colorScheme == .dark ? 0.78 : 0.68), + color.opacity(colorScheme == .dark ? 0.62 : 0.52), ], startPoint: .leading, endPoint: .trailing @@ -933,27 +859,15 @@ struct AccountUsageMeterView: View { private var trackInsetStyle: LinearGradient { LinearGradient( colors: [ - Color.white.opacity(colorScheme == .dark ? 0.035 : 0.08), + Color.white.opacity(colorScheme == .dark ? 0.022 : 0.05), Color.white.opacity(0), - Color.black.opacity(colorScheme == .dark ? 0.18 : 0.05), + Color.black.opacity(colorScheme == .dark ? 0.035 : 0.018), ], startPoint: .top, endPoint: .bottom ) } - private var progressCapStyle: RadialGradient { - RadialGradient( - colors: [ - Color.white.opacity(colorScheme == .dark ? 0.7 : 0.6), - color.opacity(colorScheme == .dark ? 0.5 : 0.36), - Color.white.opacity(0), - ], - center: .center, - startRadius: 0, - endRadius: 7 - ) - } } private struct UsageResetDisplay { @@ -1047,7 +961,6 @@ struct NoticeView: View { .padding(8) .modernGlassSurface( cornerRadius: 9, - tint: PanelPalette.warning(colorScheme).opacity(0.12), depth: .section ) } @@ -1061,69 +974,29 @@ struct SummaryTileView: View { @Environment(\.colorScheme) private var colorScheme var body: some View { - HStack(spacing: 5) { + HStack(spacing: 4) { Image(systemName: symbol) .font(PanelFont.summaryIcon) .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.78 : 0.82)) .frame(width: 11) - VStack(alignment: .leading, spacing: 1) { - Text(title) - .font(PanelFont.summaryTitle) - .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) - .lineLimit(1) + Text(title) + .font(PanelFont.summaryTitle) + .foregroundStyle(PanelPalette.secondaryText(colorScheme).opacity(0.82)) + .lineLimit(1) + .fixedSize(horizontal: true, vertical: false) - Text(value) - .font(PanelFont.summaryValue) - .foregroundStyle(PanelPalette.primaryText(colorScheme)) - .lineLimit(1) - .truncationMode(.middle) - } + Text(value) + .font(PanelFont.summaryValue) + .foregroundStyle(PanelPalette.primaryText(colorScheme)) + .lineLimit(1) + .truncationMode(.middle) + .layoutPriority(1) } .frame(maxWidth: .infinity, alignment: .leading) } } -struct PanelPrimaryButtonView: View { - let title: String - let symbol: String - let action: () -> Void - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - Button(action: action) { - Label(title, systemImage: symbol) - .font(PanelFont.primaryButton) - .foregroundStyle( - colorScheme == .dark - ? Color(red: 0.86, green: 0.93, blue: 1).opacity(0.94) - : PanelPalette.actionBlue(colorScheme) - ) - .frame(maxWidth: .infinity, minHeight: 22) - .contentShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) - .modernGlassSurface( - cornerRadius: 9, - tint: PanelPalette.addButtonTint(colorScheme), - depth: .row - ) - .overlay { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .strokeBorder(PanelPalette.addButtonStroke(colorScheme), lineWidth: 0.55) - .allowsHitTesting(false) - } - } - .buttonStyle( - PanelInteractiveButtonStyle( - hoverLift: 0.6, - hoverScale: 1.004, - pressedScale: 0.992, - hoverShadowRadius: 3.5 - ) - ) - .help(title) - } -} - struct PanelIconButtonView: View { let symbol: String let tint: Color @@ -1131,6 +1004,7 @@ struct PanelIconButtonView: View { let isDestructive: Bool let isDisabled: Bool let isSubtle: Bool + let isPrimary: Bool let size: CGFloat let action: () -> Void let help: String @@ -1143,6 +1017,7 @@ struct PanelIconButtonView: View { isDestructive: Bool = false, isDisabled: Bool = false, isSubtle: Bool = false, + isPrimary: Bool = false, size: CGFloat = 24, action: @escaping () -> Void, help: String @@ -1153,6 +1028,7 @@ struct PanelIconButtonView: View { self.isDestructive = isDestructive self.isDisabled = isDisabled self.isSubtle = isSubtle + self.isPrimary = isPrimary self.size = size self.action = action self.help = help @@ -1182,14 +1058,8 @@ struct PanelIconButtonView: View { iconContent .modernGlassSurface( cornerRadius: iconCornerRadius, - tint: surfaceTint, depth: .control ) - .overlay { - RoundedRectangle(cornerRadius: iconCornerRadius, style: .continuous) - .strokeBorder(buttonStrokeColor, lineWidth: isDestructive ? 0.62 : 0.54) - .allowsHitTesting(false) - } } else { iconContent .opacity(isDisabled ? 0.34 : 0.82) @@ -1215,67 +1085,27 @@ struct PanelIconButtonView: View { if isDestructive { return tint.opacity(colorScheme == .dark ? 0.96 : 0.9) } + if isPrimary { + return tint.opacity(colorScheme == .dark ? 1 : 0.96) + } if isSubtle { return tint.opacity(colorScheme == .dark ? 0.86 : 0.82) } return PanelPalette.actionBlue(colorScheme).opacity(colorScheme == .dark ? 0.88 : 0.86) } - private var surfaceTint: Color { - if isActive { - return tint.opacity(colorScheme == .dark ? 0.22 : 0.2) - } - if isDisabled { - return PanelPalette.controlTint(colorScheme).opacity(0.42) - } - if isDestructive { - return colorScheme == .dark - ? tint.opacity(0.18) - : Color(red: 1, green: 0.84, blue: 0.86).opacity(0.72) - } - return PanelPalette.controlTint(colorScheme).opacity(isSubtle ? 0.94 : 1) - } - private var usesSurface: Bool { - true - } - - private var buttonStrokeColor: Color { - if isDestructive { - return tint.opacity(colorScheme == .dark ? 0.38 : 0.34) + if isSubtle { + return false } - if isActive { - return tint.opacity(colorScheme == .dark ? 0.4 : 0.34) + if isActive || isPrimary { + return true } - - return PanelPalette.controlStroke(colorScheme).opacity(isSubtle ? 1 : 0.92) + return true } private var iconCornerRadius: CGFloat { - min(8.5, max(7, size * 0.36)) - } -} - -struct PanelIconLabelView: View { - let symbol: String - let tint: Color - @Environment(\.colorScheme) private var colorScheme - - var body: some View { - Image(systemName: symbol) - .font(PanelFont.footerIcon) - .foregroundStyle(tint.opacity(colorScheme == .dark ? 0.84 : 0.86)) - .frame(width: 24, height: 24) - .modernGlassSurface( - cornerRadius: 7, - tint: PanelPalette.controlTint(colorScheme), - depth: .control - ) - .overlay { - RoundedRectangle(cornerRadius: 7, style: .continuous) - .strokeBorder(PanelPalette.controlStroke(colorScheme), lineWidth: 0.5) - .allowsHitTesting(false) - } + size * 0.5 } } @@ -1399,13 +1229,11 @@ private enum GlassSurfaceDepth { private extension View { func modernGlassSurface( cornerRadius: CGFloat, - tint: Color? = nil, depth: GlassSurfaceDepth = .section ) -> some View { modifier( ModernGlassSurfaceModifier( cornerRadius: cornerRadius, - tint: tint, depth: depth ) ) @@ -1415,7 +1243,6 @@ private extension View { private struct ModernGlassSurfaceModifier: ViewModifier { @Environment(\.colorScheme) private var colorScheme let cornerRadius: CGFloat - let tint: Color? let depth: GlassSurfaceDepth @ViewBuilder @@ -1424,40 +1251,21 @@ private struct ModernGlassSurfaceModifier: ViewModifier { if #available(macOS 26.0, *) { content - .background { - shape.fill(surfaceFill) - } .glassEffect( configuredGlass, in: shape ) - .glassSurfaceFinish( - cornerRadius: cornerRadius, - depth: depth, - colorScheme: colorScheme - ) } else { content .background { - shape - .fill(materialStyle) - shape - .fill(surfaceFill) + shape.fill(materialStyle) } - .glassSurfaceFinish( - cornerRadius: cornerRadius, - depth: depth, - colorScheme: colorScheme - ) } } @available(macOS 26.0, *) private var configuredGlass: Glass { - var glass = Glass.regular - if let tint { - glass = glass.tint(tint) - } + var glass = Glass.clear if depth == .control { glass = glass.interactive() } @@ -1468,453 +1276,16 @@ private struct ModernGlassSurfaceModifier: ViewModifier { private var materialStyle: AnyShapeStyle { switch depth { case .panel: - return AnyShapeStyle(.regularMaterial) - case .section, .row: - return AnyShapeStyle(.thinMaterial) - case .control: - return colorScheme == .dark ? AnyShapeStyle(.thinMaterial) : AnyShapeStyle(.ultraThinMaterial) - } - } - - private var surfaceFill: Color { - if let tint { - return tint - } - - if colorScheme == .dark { - switch depth { - case .panel: - return Color(hue: 0.59, saturation: 0.4, brightness: 0.38, opacity: 0.3) - case .section: - return Color(hue: 0.59, saturation: 0.25, brightness: 0.5, opacity: 0.12) - case .row: - return Color(hue: 0.59, saturation: 0.24, brightness: 0.52, opacity: 0.13) - case .control: - return Color(hue: 0.59, saturation: 0.27, brightness: 0.56, opacity: 0.115) - } - } - - switch depth { - case .panel: - return Color( - hue: 0.6, - saturation: 0.18, - brightness: 1, - opacity: 0.1 - ) - case .section: - return Color( - hue: 0.6, - saturation: 0.08, - brightness: 1, - opacity: 0.12 - ) - case .row: - return Color( - hue: 0.6, - saturation: 0.07, - brightness: 1, - opacity: 0.135 - ) - case .control: - return Color.white.opacity(0.145) - } - } -} - -private extension View { - func glassSurfaceFinish( - cornerRadius: CGFloat, - depth: GlassSurfaceDepth, - colorScheme: ColorScheme - ) -> some View { - let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - - return self - .background { - shape - .fill(castShadowColor(for: depth, colorScheme: colorScheme)) - .blur(radius: castShadowBlur(for: depth)) - .offset( - x: castShadowXOffset(for: depth), - y: castShadowOffset(for: depth) - ) - .opacity(castShadowOpacity(for: depth, colorScheme: colorScheme)) - .allowsHitTesting(false) - } - .background(alignment: .bottom) { - shape - .fill(contactShadowGradient(for: depth, colorScheme: colorScheme)) - .frame(height: contactShadowHeight(for: depth)) - .blur(radius: contactShadowBlur(for: depth)) - .offset(y: contactShadowOffset(for: depth)) - .opacity(contactShadowOpacity(for: depth, colorScheme: colorScheme)) - .allowsHitTesting(false) - } - .overlay { - shape - .fill(surfaceLiftGradient(for: depth, colorScheme: colorScheme)) - .blendMode(colorScheme == .dark ? .screen : .softLight) - .allowsHitTesting(false) - } - .overlay { - shape - .fill(sheenGradient(for: depth, colorScheme: colorScheme)) - .blendMode(.screen) - .allowsHitTesting(false) - } - .overlay { - shape - .strokeBorder( - edgeGradient(for: depth, colorScheme: colorScheme), - lineWidth: edgeWidth(for: depth) - ) - .allowsHitTesting(false) - } - .shadow( - color: ambientShadowColor(for: depth, colorScheme: colorScheme), - radius: ambientShadowRadius(for: depth), - x: ambientShadowXOffset(for: depth), - y: ambientShadowOffset(for: depth) - ) - .shadow( - color: keyShadowColor(for: depth, colorScheme: colorScheme), - radius: keyShadowRadius(for: depth), - x: keyShadowXOffset(for: depth), - y: keyShadowOffset(for: depth) - ) - } - - private func edgeGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { - LinearGradient( - colors: [ - Color.white.opacity(edgeHighlightOpacity(for: depth, colorScheme: colorScheme)), - Color.white.opacity(edgeMidOpacity(for: depth, colorScheme: colorScheme)), - Color.black.opacity(edgeShadowOpacity(for: depth, colorScheme: colorScheme)), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - - private func surfaceLiftGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { - LinearGradient( - colors: [ - Color.white.opacity(surfaceTopGlowOpacity(for: depth, colorScheme: colorScheme)), - Color.white.opacity(0), - Color.black.opacity(surfaceBottomShadeOpacity(for: depth, colorScheme: colorScheme)), - ], - startPoint: .top, - endPoint: .bottom - ) - } - - private func sheenGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { - LinearGradient( - colors: [ - Color.white.opacity(sheenOpacity(for: depth, colorScheme: colorScheme)), - Color.white.opacity(0), - Color.black.opacity(colorScheme == .dark ? 0.035 : 0.02), - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - } - - private func contactShadowGradient(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> LinearGradient { - let color = contactShadowColor(for: depth, colorScheme: colorScheme) - return LinearGradient( - colors: [ - color.opacity(0), - color, - color.opacity(0.35), - color.opacity(0), - ], - startPoint: .top, - endPoint: .bottom - ) - } - - private func contactShadowColor(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Color { - if colorScheme == .dark { - switch depth { - case .panel: - return Color.black - case .section, .row: - return Color(red: 0.01, green: 0.04, blue: 0.08) - case .control: - return Color.black - } - } - - switch depth { - case .panel: - return Color(red: 0.08, green: 0.18, blue: 0.3) - case .section, .row: - return Color(red: 0.12, green: 0.28, blue: 0.42) - case .control: - return Color(red: 0.1, green: 0.24, blue: 0.38) - } - } - - private func castShadowColor(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Color { - if colorScheme == .dark { - switch depth { - case .panel: - return Color.black - case .section, .row: - return Color(red: 0.01, green: 0.035, blue: 0.07) - case .control: - return Color.black - } - } - - switch depth { - case .panel: - return Color(red: 0.08, green: 0.18, blue: 0.3) - case .section, .row: - return Color(red: 0.1, green: 0.24, blue: 0.38) - case .control: - return Color(red: 0.08, green: 0.2, blue: 0.34) - } - } - - private func castShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { - switch depth { - case .panel: - return colorScheme == .dark ? 0.13 : 0.085 - case .section: - return colorScheme == .dark ? 0.1 : 0.18 - case .row: - return colorScheme == .dark ? 0.16 : 0.27 - case .control: - return colorScheme == .dark ? 0.14 : 0.2 - } - } - - private func castShadowBlur(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 16 - case .section: return 10 - case .row: return 12 - case .control: return 7 - } - } - - private func castShadowXOffset(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 0 - case .section: return 0.5 - case .row: return 0.7 - case .control: return 0.4 - } - } - - private func castShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 8 - case .section: return 6 - case .row: return 8 - case .control: return 4 - } - } - - private func contactShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { - switch depth { - case .panel: - return colorScheme == .dark ? 0.1 : 0.085 - case .section: - return colorScheme == .dark ? 0.13 : 0.32 - case .row: - return colorScheme == .dark ? 0.18 : 0.42 - case .control: - return colorScheme == .dark ? 0.14 : 0.24 - } - } - - private func contactShadowHeight(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 16 - case .section: return 10 - case .row: return 12 - case .control: return 8 - } - } - - private func contactShadowBlur(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 7 - case .section: return 3.2 - case .row: return 3.6 - case .control: return 3 - } - } - - private func contactShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 8 - case .section: return 5.8 - case .row: return 6.8 - case .control: return 4 - } - } - - private func surfaceTopGlowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { - switch depth { - case .panel: - return colorScheme == .dark ? 0.025 : 0.045 - case .section: - return colorScheme == .dark ? 0.038 : 0.085 - case .row: - return colorScheme == .dark ? 0.044 : 0.095 - case .control: - return colorScheme == .dark ? 0.055 : 0.11 - } - } - - private func surfaceBottomShadeOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { - switch depth { - case .panel: - return colorScheme == .dark ? 0.012 : 0.018 - case .section: - return colorScheme == .dark ? 0.018 : 0.03 - case .row: - return colorScheme == .dark ? 0.022 : 0.04 - case .control: - return colorScheme == .dark ? 0.02 : 0.035 - } - } - - private func sheenOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { - switch depth { - case .panel: return colorScheme == .dark ? 0.045 : 0.1 - case .section: return colorScheme == .dark ? 0.04 : 0.11 - case .row: return colorScheme == .dark ? 0.046 : 0.12 - case .control: return colorScheme == .dark ? 0.058 : 0.13 - } - } - - private func edgeWidth(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 0.65 - case .section: return 0.48 - case .row: return 0.52 - case .control: return 0.45 - } - } - - private func edgeHighlightOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { - switch depth { - case .panel: return colorScheme == .dark ? 0.17 : 0.32 - case .section: return colorScheme == .dark ? 0.14 : 0.34 - case .row: return colorScheme == .dark ? 0.17 : 0.42 - case .control: return colorScheme == .dark ? 0.2 : 0.38 - } - } - - private func edgeMidOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { - switch depth { - case .panel: return colorScheme == .dark ? 0.045 : 0.105 - case .section: return colorScheme == .dark ? 0.028 : 0.07 - case .row: return colorScheme == .dark ? 0.03 : 0.065 - case .control: return colorScheme == .dark ? 0.04 : 0.09 - } - } - - private func edgeShadowOpacity(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Double { - switch depth { - case .panel: return colorScheme == .dark ? 0.085 : 0.075 - case .section: return colorScheme == .dark ? 0.045 : 0.075 - case .row: return colorScheme == .dark ? 0.045 : 0.11 - case .control: return colorScheme == .dark ? 0.06 : 0.09 - } - } - - private func ambientShadowColor(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Color { - let opacity: Double - switch depth { - case .panel: - opacity = colorScheme == .dark ? 0.24 : 0.065 + return AnyShapeStyle(.ultraThinMaterial) case .section: - opacity = colorScheme == .dark ? 0.09 : 0.095 - case .row: - opacity = colorScheme == .dark ? 0.18 : 0.15 - case .control: - opacity = colorScheme == .dark ? 0.1 : 0.058 - } - - return Color.black.opacity(opacity) - } - - private func keyShadowColor(for depth: GlassSurfaceDepth, colorScheme: ColorScheme) -> Color { - let opacity: Double - switch depth { - case .panel: - opacity = colorScheme == .dark ? 0.07 : 0.028 - case .section: - opacity = colorScheme == .dark ? 0.065 : 0.055 + return AnyShapeStyle(.thinMaterial) case .row: - opacity = colorScheme == .dark ? 0.08 : 0.07 + return colorScheme == .dark ? AnyShapeStyle(.thinMaterial) : AnyShapeStyle(.ultraThinMaterial) case .control: - opacity = colorScheme == .dark ? 0.07 : 0.046 - } - - return Color(red: 0.34, green: 0.67, blue: 1).opacity(opacity) - } - - private func ambientShadowRadius(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 15 - case .section: return 9 - case .row: return 13 - case .control: return 5.5 - } - } - - private func ambientShadowXOffset(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 0 - case .section: return 0.6 - case .row: return 0.8 - case .control: return 0.4 - } - } - - private func ambientShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 6 - case .section: return 4 - case .row: return 8 - case .control: return 2 - } - } - - private func keyShadowRadius(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 5 - case .section: return 4 - case .row: return 4.5 - case .control: return 3 - } - } - - private func keyShadowXOffset(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 0 - case .section: return 0.3 - case .row: return 0.4 - case .control: return 0.2 + return colorScheme == .dark ? AnyShapeStyle(.thinMaterial) : AnyShapeStyle(.ultraThinMaterial) } } - private func keyShadowOffset(for depth: GlassSurfaceDepth) -> CGFloat { - switch depth { - case .panel: return 2 - case .section: return 2 - case .row: return 2 - case .control: return 1 - } - } } private extension CodexAccount { @@ -1946,19 +1317,14 @@ private extension CodexAccount { return AccountDisplay.compactEmail(displayName) } - func matchesSelector(_ value: String) -> Bool { - let selector = value.trimmingCharacters(in: .whitespacesAndNewlines) - return selector == email || selector == accountFingerprint || selector == self.selector - } - func statusDisplayColor(colorScheme: ColorScheme) -> Color { switch statusTone { case .codexActive: - return PanelPalette.activeGold(colorScheme) + return PanelPalette.codexAccent(colorScheme) case .ready: return PanelPalette.usageMint(colorScheme) case .selected: - return PanelPalette.actionBlue(colorScheme) + return PanelPalette.routeAccent(colorScheme) case .warning: return PanelPalette.warning(colorScheme) case .danger: @@ -1980,10 +1346,6 @@ private extension CodexAccount { hasPrimaryUsageData || hasSecondaryUsageData } - var hasVisibleMetadata: Bool { - planLabel != nil || compactHealthLabel != nil - } - var compactHealthLabel: String? { if isUsageLimited { return "Limited" diff --git a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift index 02cecb977..b04a598bb 100644 --- a/apps/decodex-app/Sources/DecodexApp/AccountStore.swift +++ b/apps/decodex-app/Sources/DecodexApp/AccountStore.swift @@ -9,6 +9,11 @@ final class AccountStore: ObservableObject { @Published var notice: String? private let bridge = DecodexAppBridge() + private var automaticRefreshTask: Task? + + deinit { + automaticRefreshTask?.cancel() + } var isInitialLoading: Bool { accountList == nil && isRefreshing @@ -82,14 +87,47 @@ final class AccountStore: ObservableObject { await refresh() } + func resetLoginSession() { + guard !isLoggingIn else { + return + } + + loginTranscript = "" + notice = nil + } + + func startAutomaticRefresh() { + guard automaticRefreshTask == nil else { + return + } + + automaticRefreshTask = Task { [weak self] in + while !Task.isCancelled { + do { + try await Task.sleep(nanoseconds: 60_000_000_000) + } catch { + return + } + + await self?.refresh(force: true) + } + } + } + func useInCodex(_ account: CodexAccount) async { + let previousAccountList = accountList + notice = nil + accountList = accountList?.updatingCodexAuth(account.authIdentity) + do { - _ = try await bridge.runJSON( + let response = try await bridge.runJSON( .accountUse(selector: account.selector), as: CodexAuthUseResponse.self ) - await refresh() + accountList = accountList?.updatingCodexAuth(response.account) + notice = nil } catch { + accountList = previousAccountList notice = error.localizedDescription } } @@ -149,6 +187,22 @@ final class AccountStore: ObservableObject { } } +private extension AccountListResponse { + func updatingCodexAuth(_ identity: CodexAuthIdentity) -> AccountListResponse { + AccountListResponse( + accountsPath: accountsPath, + globalConfigPath: globalConfigPath, + codexAuthPath: codexAuthPath, + codexAuth: identity, + control: control, + accounts: accounts.map { account in + account.withCodexActive(account.matchesSelector(identity.selector)) + }, + usageProbeError: usageProbeError + ) + } +} + struct DeviceLoginPrompt: Equatable { let verificationURL: URL? let userCode: String diff --git a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift index caaa34a78..a080ee13d 100644 --- a/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift +++ b/apps/decodex-app/Sources/DecodexApp/DecodexApp.swift @@ -20,10 +20,20 @@ enum AppAssets { }() } +enum DecodexWindowID { + static let login = "decodex-login" +} + +@MainActor +final class LoginWindowState: ObservableObject { + @Published var mode = AccountLoginSheetMode.add +} + @main struct DecodexApp: App { @NSApplicationDelegateAdaptor(AppDelegate.self) private var appDelegate @StateObject private var store: AccountStore + @StateObject private var loginWindowState = LoginWindowState() @MainActor init() { @@ -32,15 +42,13 @@ struct DecodexApp: App { _store = StateObject(wrappedValue: accountStore) Task { await accountStore.refreshIfNeeded() + accountStore.startAutomaticRefresh() } } var body: some Scene { MenuBarExtra { - AccountPanelView(store: store) - .task { - await store.refreshIfNeeded() - } + menuBarContent } label: { Label { Text("Decodex") @@ -50,5 +58,25 @@ struct DecodexApp: App { } .menuBarExtraStyle(.window) + Window("Decodex Login", id: DecodexWindowID.login) { + LoginSheetView(store: store, mode: loginWindowState.mode) + .background(FloatingLoginWindowConfigurator()) + } + .windowResizability(.contentSize) + } + + @ViewBuilder + private var menuBarContent: some View { + let content = AccountPanelView(store: store, loginWindowState: loginWindowState) + .task { + await store.refreshIfNeeded() + } + + if #available(macOS 15.0, *) { + content + .containerBackground(.clear, for: .window) + } else { + content + } } } diff --git a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift index a0a6a08ca..068351a96 100644 --- a/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift +++ b/apps/decodex-app/Sources/DecodexApp/LoginSheetView.swift @@ -1,16 +1,60 @@ import AppKit import SwiftUI +enum AccountLoginSheetMode: Equatable { + case add + case reauth(String) + + var title: String { + switch self { + case .add: + return "Device Login" + case .reauth: + return "Re-auth Account" + } + } + + var icon: String { + switch self { + case .add: + return "person.crop.circle.badge.plus" + case .reauth: + return "person.crop.circle.badge.exclamationmark" + } + } + + func subtitle(fallback: String, isActive: Bool) -> String { + switch self { + case .add: + return fallback + case .reauth(let name): + return !isActive && !name.isEmpty ? name : fallback + } + } +} + struct LoginSheetView: View { @ObservedObject var store: AccountStore + let mode: AccountLoginSheetMode @Environment(\.colorScheme) private var colorScheme @Environment(\.dismiss) private var dismiss + @State private var requestStarted = false + + init(store: AccountStore, mode: AccountLoginSheetMode = .add) { + self.store = store + self.mode = mode + } var body: some View { VStack(alignment: .leading, spacing: 11) { header codeCard + if isRequestingCode { + requestStatus + .transition(.opacity.combined(with: .move(edge: .top))) + } + if let notice = store.notice { Text(notice) .font(LoginFont.caption) @@ -34,11 +78,21 @@ struct LoginSheetView: View { .strokeBorder(LoginPalette.panelStroke(colorScheme), lineWidth: 0.65) } .shadow(color: .black.opacity(colorScheme == .dark ? 0.3 : 0.12), radius: 20, y: 10) + .onChange(of: store.loginPrompt) { _, prompt in + if prompt != nil { + requestStarted = false + } + } + .onChange(of: store.notice) { _, notice in + if notice != nil { + requestStarted = false + } + } } private var header: some View { HStack(spacing: 8) { - Image(systemName: "person.crop.circle.badge.plus") + Image(systemName: mode.icon) .font(.system(size: 12.5, weight: .semibold)) .foregroundStyle(LoginPalette.accent(colorScheme)) .frame(width: 27, height: 27) @@ -52,18 +106,47 @@ struct LoginSheetView: View { } VStack(alignment: .leading, spacing: 1) { - Text("Device Login") + Text(mode.title) .font(LoginFont.title) .foregroundStyle(LoginPalette.primaryText(colorScheme)) - Text(store.loginStatusLabel) + Text( + mode.subtitle( + fallback: store.loginStatusLabel, + isActive: store.isLoggingIn || store.loginPrompt != nil || store.notice != nil + ) + ) .font(LoginFont.caption) .foregroundStyle(LoginPalette.secondaryText(colorScheme)) + .lineLimit(1) + .truncationMode(.middle) } Spacer() } } + private var requestStatus: some View { + HStack(spacing: 7) { + ProgressView() + .controlSize(.small) + .scaleEffect(0.72) + Text("Requesting device code") + .font(LoginFont.caption) + .foregroundStyle(LoginPalette.secondaryText(colorScheme)) + Spacer(minLength: 0) + } + .padding(.horizontal, 9) + .padding(.vertical, 6) + .background { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(LoginPalette.statusTint(colorScheme)) + } + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(LoginPalette.cardStroke(colorScheme), lineWidth: 0.5) + } + } + private var codeCard: some View { VStack(alignment: .leading, spacing: 8) { LoginCodeBoxesView(code: store.loginPrompt?.compactCode ?? "") @@ -112,6 +195,7 @@ struct LoginSheetView: View { private var actions: some View { HStack(spacing: 7) { Button("Cancel") { + requestStarted = false dismiss() } .keyboardShortcut(.cancelAction) @@ -120,19 +204,42 @@ struct LoginSheetView: View { Spacer() Button { + requestStarted = true Task { await store.login() + requestStarted = false if store.notice == nil { dismiss() } } } label: { - Label(store.isLoggingIn ? "Waiting" : "Get Code", systemImage: store.isLoggingIn ? "clock" : "arrow.right.circle") + HStack(spacing: 5) { + if isRequestingCode { + ProgressView() + .controlSize(.small) + .scaleEffect(0.64) + } else { + Image(systemName: store.isLoggingIn ? "clock" : "arrow.right.circle") + } + Text(primaryActionTitle) + } } .keyboardShortcut(.defaultAction) .buttonStyle(LoginTextButtonStyle(isPrimary: true)) - .disabled(store.isLoggingIn) + .disabled(store.isLoggingIn || requestStarted) + } + } + + private var isRequestingCode: Bool { + store.loginPrompt == nil && (requestStarted || store.isLoggingIn) + } + + private var primaryActionTitle: String { + if isRequestingCode { + return "Requesting" } + + return store.isLoggingIn ? "Waiting" : "Get Code" } private var loginDestinationLabel: String { @@ -161,6 +268,33 @@ struct LoginSheetView: View { } } +struct FloatingLoginWindowConfigurator: NSViewRepresentable { + func makeNSView(context: Context) -> NSView { + let view = NSView() + configureSoon(from: view) + return view + } + + func updateNSView(_ nsView: NSView, context: Context) { + configureSoon(from: nsView) + } + + private func configureSoon(from view: NSView) { + DispatchQueue.main.async { + guard let window = view.window else { + return + } + + window.level = .floating + window.collectionBehavior.insert(.fullScreenAuxiliary) + window.isMovableByWindowBackground = true + window.titleVisibility = .hidden + window.titlebarAppearsTransparent = true + window.styleMask.insert(.fullSizeContentView) + } + } +} + private struct LoginCodeBoxesView: View { let code: String @Environment(\.colorScheme) private var colorScheme @@ -324,6 +458,12 @@ private enum LoginPalette { : Color(red: 0.42, green: 0.58, blue: 0.76).opacity(0.2) } + static func statusTint(_ colorScheme: ColorScheme) -> Color { + colorScheme == .dark + ? Color(red: 0.62, green: 0.78, blue: 1).opacity(0.11) + : Color(red: 0.86, green: 0.94, blue: 1).opacity(0.62) + } + static func controlTint(_ colorScheme: ColorScheme) -> Color { colorScheme == .dark ? Color(red: 0.7, green: 0.8, blue: 0.92).opacity(0.15) diff --git a/apps/decodex-app/Sources/DecodexApp/Models.swift b/apps/decodex-app/Sources/DecodexApp/Models.swift index d9315fe67..8d04791b1 100644 --- a/apps/decodex-app/Sources/DecodexApp/Models.swift +++ b/apps/decodex-app/Sources/DecodexApp/Models.swift @@ -94,6 +94,26 @@ struct CodexAccount: Decodable, Identifiable, Equatable { email ?? accountFingerprint } + var authIdentity: CodexAuthIdentity { + CodexAuthIdentity( + accountFingerprint: accountFingerprint, + email: email, + selector: selector + ) + } + + var needsLogin: Bool { + status == "unusable" || status == "expired" || !refreshTokenPresent + } + + var canUseInCodex: Bool { + !disabled && !needsLogin + } + + var canRouteRuns: Bool { + !disabled && !needsLogin + } + var statusLabel: String { if isUsageLimited { return "Limited" @@ -191,6 +211,44 @@ struct CodexAccount: Decodable, Identifiable, Equatable { return .ready } + func matchesSelector(_ value: String) -> Bool { + let selector = value.trimmingCharacters(in: .whitespacesAndNewlines) + return selector == email || selector == accountFingerprint || selector == self.selector + } + + func withCodexActive(_ value: Bool) -> CodexAccount { + CodexAccount( + accountFingerprint: accountFingerprint, + email: email, + selector: selector, + randomName: randomName, + randomNameKey: randomNameKey, + randomNameOffset: randomNameOffset, + status: status, + selected: selected, + codexActive: value, + disabled: disabled, + refreshTokenPresent: refreshTokenPresent, + accessTokenExpiresAtUnixEpoch: accessTokenExpiresAtUnixEpoch, + lastSelectedAtUnixEpoch: lastSelectedAtUnixEpoch, + cooldownUntilUnixEpoch: cooldownUntilUnixEpoch, + note: note, + planType: planType, + refreshStatus: refreshStatus, + checkedAtUnixEpoch: checkedAtUnixEpoch, + primaryWindowSeconds: primaryWindowSeconds, + primaryRemainingPercent: primaryRemainingPercent, + primaryResetsAtUnixEpoch: primaryResetsAtUnixEpoch, + secondaryWindowSeconds: secondaryWindowSeconds, + secondaryRemainingPercent: secondaryRemainingPercent, + secondaryResetsAtUnixEpoch: secondaryResetsAtUnixEpoch, + creditsHasCredits: creditsHasCredits, + creditsUnlimited: creditsUnlimited, + creditsBalance: creditsBalance, + rateLimitReachedType: rateLimitReachedType + ) + } + enum CodingKeys: String, CodingKey { case accountFingerprint = "account_fingerprint" case email From e95b0faaaf67cb7b5e3556140b6beba0614c7163 Mon Sep 17 00:00:00 2001 From: Yvette Carlisle Date: Wed, 20 May 2026 22:54:53 +0800 Subject: [PATCH 8/8] {"schema":"decodex/commit/1","summary":"Fix Decodex Rust CI style gate","authority":"manual"} --- apps/decodex/src/accounts.rs | 2 ++ .../src/orchestrator/tests/operator/status/dashboard.rs | 1 - apps/decodex/src/runtime.rs | 3 ++- 3 files changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/decodex/src/accounts.rs b/apps/decodex/src/accounts.rs index bd3502b55..a516f940f 100644 --- a/apps/decodex/src/accounts.rs +++ b/apps/decodex/src/accounts.rs @@ -364,6 +364,7 @@ impl AccountStore { .iter() .filter_map(|(key, value)| { let key = key.trim(); + (!key.is_empty()).then_some(( key.to_owned(), normalize_random_name_offset(value.as_integer().unwrap_or_default()), @@ -1327,6 +1328,7 @@ fn random_name_seed_for(account_fingerprint: &str, email: Option) -> Str if !account_fingerprint.trim().is_empty() { return account_fingerprint.to_owned(); } + if let Some(email) = email.filter(|value| !value.trim().is_empty()) { return email; } diff --git a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs index d416f2a9b..6e4661e73 100644 --- a/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs +++ b/apps/decodex/src/orchestrator/tests/operator/status/dashboard.rs @@ -1548,7 +1548,6 @@ fn operator_dashboard_uses_websocket_without_http_state_fallback() { assert!(!response.contains("clearDashboardHttpFallback();")); assert!(!response.contains("requestJson(")); assert!(!response.contains("requestText(")); - assert!(!response.contains("fetch(")); assert!(!response.contains("\"/state\"")); assert!(!response.contains("\"/readyz\"")); assert!(!response.contains("window.setInterval(refreshDashboard")); diff --git a/apps/decodex/src/runtime.rs b/apps/decodex/src/runtime.rs index 5e19eb913..786ee53c2 100644 --- a/apps/decodex/src/runtime.rs +++ b/apps/decodex/src/runtime.rs @@ -1,5 +1,6 @@ //! Local Decodex control-plane runtime paths and project registry helpers. +#[cfg(test)] use std::process; use std::{ cmp::Reverse, env, fs, @@ -103,7 +104,7 @@ pub(crate) fn write_global_fixed_account_selector(selector: Option<&str>) -> Res .file_name() .and_then(|name| name.to_str()) .ok_or_else(|| eyre::eyre!("Decodex global config path must end in a valid file name."))?; - let temp_path = parent.join(format!(".{file_name}.tmp-{}", std::process::id())); + let temp_path = parent.join(format!(".{file_name}.tmp-{}", process::id())); let output = toml::to_string_pretty(&document)?; fs::create_dir_all(parent)?;