diff --git a/swift/Sources/App/AppTheme.swift b/swift/Sources/App/AppTheme.swift index 70b421d..acf03bb 100644 --- a/swift/Sources/App/AppTheme.swift +++ b/swift/Sources/App/AppTheme.swift @@ -1,93 +1,20 @@ import SwiftUI -#if os(macOS) -import AppKit -#elseif canImport(UIKit) -import UIKit -#endif - -private extension Color { - static func adaptive( - light: (red: Double, green: Double, blue: Double, opacity: Double), - dark: (red: Double, green: Double, blue: Double, opacity: Double) - ) -> Color { - #if os(macOS) - Color( - nsColor: NSColor(name: nil) { appearance in - let matchedAppearance = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight]) - let components = matchedAppearance == .darkAqua || matchedAppearance == .vibrantDark ? dark : light - return NSColor( - red: components.red, - green: components.green, - blue: components.blue, - alpha: components.opacity - ) - } - ) - #elseif canImport(UIKit) && !os(watchOS) - Color( - uiColor: UIColor { traits in - let components = traits.userInterfaceStyle == .dark ? dark : light - return UIColor( - red: components.red, - green: components.green, - blue: components.blue, - alpha: components.opacity - ) - } - ) - #elseif os(watchOS) - Color( - red: dark.red, - green: dark.green, - blue: dark.blue, - opacity: dark.opacity - ) - #else - Color( - red: light.red, - green: light.green, - blue: light.blue, - opacity: light.opacity - ) - #endif - } -} +/// Legacy theme shim kept only so AppComponents.swift still compiles while +/// the codebase has finished migrating to `IdP` / `Color.idp*` tokens. +/// Do not reference these from new code — use `IdP.tint`, `Color.idpGroupedBackground`, +/// etc. from `Sources/Core/Design/` instead. enum AppTheme { - static let accent = Color(red: 0.12, green: 0.40, blue: 0.31) - static let warmAccent = Color(red: 0.84, green: 0.71, blue: 0.48) - static let border = Color.adaptive( - light: (0.00, 0.00, 0.00, 0.08), - dark: (1.00, 1.00, 1.00, 0.12) - ) - static let shadow = Color.adaptive( - light: (0.00, 0.00, 0.00, 0.05), - dark: (0.00, 0.00, 0.00, 0.32) - ) - static let cardFill = Color.adaptive( - light: (1.00, 1.00, 1.00, 0.96), - dark: (0.11, 0.12, 0.14, 0.96) - ) - static let mutedFill = Color.adaptive( - light: (0.972, 0.976, 0.970, 1.00), - dark: (0.16, 0.17, 0.19, 1.00) - ) - static let backgroundTop = Color.adaptive( - light: (0.975, 0.978, 0.972, 1.00), - dark: (0.08, 0.09, 0.10, 1.00) - ) - static let backgroundBottom = Color.adaptive( - light: (1.00, 1.00, 1.00, 1.00), - dark: (0.05, 0.06, 0.07, 1.00) - ) - static let backgroundGlow = Color.adaptive( - light: (0.00, 0.00, 0.00, 0.02), - dark: (1.00, 1.00, 1.00, 0.06) - ) - static let chromeFill = Color.adaptive( - light: (1.00, 1.00, 1.00, 0.98), - dark: (0.10, 0.11, 0.13, 0.98) - ) + static let accent: Color = IdP.tint + static let warmAccent: Color = .orange + static let border: Color = Color.idpSeparator + static let shadow: Color = Color.black.opacity(0.08) + static let cardFill: Color = Color.idpSecondaryGroupedBackground + static let mutedFill: Color = Color.idpSecondaryGroupedBackground + static let backgroundTop: Color = Color.idpGroupedBackground + static let backgroundBottom: Color = Color.idpGroupedBackground + static let backgroundGlow: Color = .clear + static let chromeFill: Color = Color.idpSecondaryGroupedBackground } enum AppLayout { @@ -97,8 +24,8 @@ enum AppLayout { static let regularVerticalPadding: CGFloat = 28 static let compactContentWidth: CGFloat = 720 static let regularContentWidth: CGFloat = 920 - static let cardRadius: CGFloat = 24 - static let largeCardRadius: CGFloat = 30 + static let cardRadius: CGFloat = IdP.cardRadius + static let largeCardRadius: CGFloat = 28 static let compactSectionPadding: CGFloat = 18 static let regularSectionPadding: CGFloat = 24 static let compactSectionSpacing: CGFloat = 18 @@ -135,30 +62,14 @@ extension View { ) .overlay( RoundedRectangle(cornerRadius: radius, style: .continuous) - .stroke(AppTheme.border, lineWidth: 1) + .stroke(AppTheme.border, lineWidth: 0.5) ) - .shadow(color: AppTheme.shadow, radius: 12, y: 3) } } struct AppBackground: View { var body: some View { - LinearGradient( - colors: [ - AppTheme.backgroundTop, - AppTheme.backgroundBottom - ], - startPoint: .top, - endPoint: .bottom - ) - .overlay(alignment: .top) { - Rectangle() - .fill(AppTheme.backgroundGlow) - .frame(height: 160) - .blur(radius: 60) - .offset(y: -90) - } - .ignoresSafeArea() + Color.idpGroupedBackground.ignoresSafeArea() } } @@ -226,8 +137,8 @@ struct AppBadge: View { .font(.caption.weight(.semibold)) .foregroundStyle(tone) .padding(.horizontal, 12) - .padding(.vertical, 8) - .background(tone.opacity(0.10), in: Capsule()) + .padding(.vertical, 6) + .background(tone.opacity(0.14), in: Capsule()) } } diff --git a/swift/Sources/Core/Design/ButtonStyles.swift b/swift/Sources/Core/Design/ButtonStyles.swift index ccd2eb3..6986d31 100644 --- a/swift/Sources/Core/Design/ButtonStyles.swift +++ b/swift/Sources/Core/Design/ButtonStyles.swift @@ -11,18 +11,18 @@ struct PrimaryActionStyle: ButtonStyle { var body: some View { configuration.label - .font(.headline) + .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.horizontal, 18) - .padding(.vertical, 14) - .foregroundStyle(.white) + .frame(height: 44) + .foregroundStyle(Color.idpPrimaryForeground) .background( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .fill(isEnabled ? IdP.tint : Color.secondary.opacity(0.25)) + .fill(isEnabled ? Color.idpPrimary : Color.idpMuted) ) - .opacity(configuration.isPressed ? 0.92 : 1) - .scaleEffect(configuration.isPressed ? 0.985 : 1) - .animation(.easeOut(duration: 0.16), value: configuration.isPressed) + .opacity(configuration.isPressed ? 0.9 : 1) + .scaleEffect(configuration.isPressed ? 0.99 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) } } } @@ -30,43 +30,135 @@ struct PrimaryActionStyle: ButtonStyle { struct SecondaryActionStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.headline) + .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.horizontal, 18) - .padding(.vertical, 14) + .frame(height: 44) .foregroundStyle(.primary) .background( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .fill(Color.idpSecondaryGroupedBackground) + .fill(Color.clear) ) .overlay( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .stroke(Color.idpSeparator.opacity(0.55), lineWidth: 1) + .stroke(Color.idpBorder, lineWidth: 1) ) - .opacity(configuration.isPressed ? 0.9 : 1) - .scaleEffect(configuration.isPressed ? 0.985 : 1) - .animation(.easeOut(duration: 0.16), value: configuration.isPressed) + .opacity(configuration.isPressed ? 0.85 : 1) + .scaleEffect(configuration.isPressed ? 0.99 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) } } struct DestructiveStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.headline) + .font(.subheadline.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.horizontal, 18) - .padding(.vertical, 14) - .foregroundStyle(.red) + .frame(height: 44) + .foregroundStyle(.white) .background( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .fill(Color.red.opacity(0.10)) - ) - .overlay( - RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .stroke(Color.red.opacity(0.18), lineWidth: 1) + .fill(Color.idpDestructive) ) .opacity(configuration.isPressed ? 0.9 : 1) - .scaleEffect(configuration.isPressed ? 0.985 : 1) - .animation(.easeOut(duration: 0.16), value: configuration.isPressed) + .scaleEffect(configuration.isPressed ? 0.99 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +struct AccentActionStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.subheadline.weight(.semibold)) + .frame(maxWidth: .infinity) + .padding(.horizontal, 18) + .frame(height: 44) + .foregroundStyle(.white) + .background( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(IdP.tint) + ) + .opacity(configuration.isPressed ? 0.9 : 1) + .scaleEffect(configuration.isPressed ? 0.99 : 1) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +struct GhostActionStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.subheadline.weight(.medium)) + .padding(.horizontal, 12) + .frame(height: 32) + .foregroundStyle(.primary) + .background( + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(configuration.isPressed ? Color.idpMuted : Color.clear) + ) + } +} + +struct ShadcnBadge: View { + enum Tone { + case neutral + case outline + case ok + case warn + case danger + case accent + } + + let title: String + var tone: Tone = .neutral + var leading: Image? = nil + + var body: some View { + HStack(spacing: 4) { + if let leading { + leading + .font(.caption2.weight(.semibold)) + } + Text(title) + } + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 3) + .foregroundStyle(foreground) + .background(background, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(strokeColor, lineWidth: strokeWidth) + ) + } + + private var foreground: Color { + switch tone { + case .neutral: return Color.idpMutedForeground + case .outline: return .primary + case .ok: return Color.idpOK + case .warn: return Color(red: 0.52, green: 0.30, blue: 0.05) + case .danger: return Color(red: 0.60, green: 0.10, blue: 0.10) + case .accent: return IdP.tint + } + } + + private var background: Color { + switch tone { + case .neutral: return Color.idpMuted + case .outline: return .clear + case .ok: return Color.idpOK.opacity(0.12) + case .warn: return Color.idpWarn.opacity(0.18) + case .danger: return Color.idpDestructive.opacity(0.14) + case .accent: return Color.idpAccentSoft + } + } + + private var strokeColor: Color { + tone == .outline ? Color.idpBorder : .clear + } + + private var strokeWidth: CGFloat { + tone == .outline ? 1 : 0 } } diff --git a/swift/Sources/Core/Design/Cards.swift b/swift/Sources/Core/Design/Cards.swift index 9532eef..641e798 100644 --- a/swift/Sources/Core/Design/Cards.swift +++ b/swift/Sources/Core/Design/Cards.swift @@ -5,22 +5,22 @@ struct ApprovalCardModifier: ViewModifier { func body(content: Content) -> some View { content - .padding(18) + .padding(14) .background( RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) - .fill(Color.idpSecondaryGroupedBackground) + .fill(Color.idpCard) ) .overlay( RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) - .stroke(highlighted ? IdP.tint.opacity(0.7) : Color.idpSeparator.opacity(0.55), lineWidth: highlighted ? 1.5 : 1) + .stroke(highlighted ? IdP.tint : Color.idpBorder, lineWidth: 1) + ) + .background( + highlighted + ? RoundedRectangle(cornerRadius: IdP.cardRadius + 3, style: .continuous) + .fill(IdP.tint.opacity(0.10)) + .padding(-3) + : nil ) - .overlay { - if highlighted { - RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) - .stroke(IdP.tint.opacity(0.12), lineWidth: 6) - .padding(-2) - } - } } } @@ -39,27 +39,35 @@ struct RequestHeroCard: View { let handle: String var body: some View { - HStack(alignment: .top, spacing: 16) { - MonogramAvatar(title: request.source, size: 64) + VStack(spacing: 12) { + MonogramAvatar( + title: request.source, + size: 52, + tint: BrandTint.color(for: request.source), + filled: true + ) - VStack(alignment: .leading, spacing: 8) { - Text("\(request.source) wants to sign in as you") - .font(.title3.weight(.semibold)) + VStack(spacing: 4) { + Text("\(request.source) wants to sign in") + .font(.title3.weight(.bold)) + .multilineTextAlignment(.center) .fixedSize(horizontal: false, vertical: true) - Text("Continue as \(Text(handle).foregroundStyle(IdP.tint))") - .font(.subheadline) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - Label(request.kind.title, systemImage: request.kind.systemImage) + HStack(spacing: 4) { + Text("requesting") + Text(handle) + .foregroundStyle(IdP.tint) + .fontWeight(.semibold) + Text("·") Text(request.createdAt, style: .relative) } - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) + .font(.footnote) + .foregroundStyle(Color.idpMutedForeground) } } - .approvalCard(highlighted: true) + .frame(maxWidth: .infinity) + .padding(.vertical, 6) + .approvalCard(highlighted: false) } } @@ -67,34 +75,58 @@ struct MonogramAvatar: View { let title: String var size: CGFloat = 40 var tint: Color = IdP.tint + /// Shadcn-style filled tile (brand color bg, white text). + /// When false, renders the legacy tinted-pastel avatar. + var filled: Bool = true private var monogram: String { - String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased() + let letters = title + .replacingOccurrences(of: "auth.", with: "") + .split { !$0.isLetter && !$0.isNumber } + .prefix(2) + .compactMap { $0.first } + let glyph = String(letters.map(Character.init)) + return glyph.isEmpty ? "I" : glyph.uppercased() } var body: some View { ZStack { - RoundedRectangle(cornerRadius: size * 0.34, style: .continuous) - .fill(tint.opacity(0.14)) - - Image("AppMonogram") - .resizable() - .scaledToFit() - .frame(width: size * 0.44, height: size * 0.44) - .opacity(0.18) + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(filled ? tint : tint.opacity(0.14)) Text(monogram) - .font(.system(size: size * 0.42, weight: .semibold, design: .rounded)) - .foregroundStyle(tint) + .font(.system(size: size * (monogram.count > 1 ? 0.36 : 0.44), + weight: .bold, + design: .default)) + .foregroundStyle(filled ? .white : tint) + .tracking(-0.3) } .frame(width: size, height: size) .accessibilityHidden(true) } } +enum BrandTint { + static func color(for host: String) -> Color { + let key = host.lowercased() + if key.contains("github") { return Color(red: 0.14, green: 0.16, blue: 0.18) } + if key.contains("lufthansa") { return Color(red: 0.02, green: 0.09, blue: 0.30) } + if key.contains("hetzner") { return Color(red: 0.84, green: 0.05, blue: 0.18) } + if key.contains("notion") { return Color(red: 0.12, green: 0.12, blue: 0.12) } + if key.contains("apple") { return Color(red: 0.18, green: 0.18, blue: 0.22) } + if key.contains("reddit") { return Color(red: 1.00, green: 0.27, blue: 0.00) } + if key.contains("cli") { return Color(red: 0.24, green: 0.26, blue: 0.32) } + if key.contains("workspace") { return Color(red: 0.26, green: 0.38, blue: 0.88) } + if key.contains("foss") || key.contains("berlin-mbp") { + return Color(red: 0.12, green: 0.45, blue: 0.70) + } + return IdP.tint + } +} + struct DeviceRowStyle: ViewModifier { func body(content: Content) -> some View { content - .padding(.vertical, 4) + .padding(.vertical, 2) } } diff --git a/swift/Sources/Core/Design/IdPTokens.swift b/swift/Sources/Core/Design/IdPTokens.swift index 94eb332..2e9f07f 100644 --- a/swift/Sources/Core/Design/IdPTokens.swift +++ b/swift/Sources/Core/Design/IdPTokens.swift @@ -8,9 +8,9 @@ import UIKit public enum IdP { public static let tint = Color("IdPTint") - public static let cardRadius: CGFloat = 22 - public static let controlRadius: CGFloat = 14 - public static let badgeRadius: CGFloat = 8 + public static let cardRadius: CGFloat = 12 + public static let controlRadius: CGFloat = 8 + public static let badgeRadius: CGFloat = 999 static func horizontalPadding(compact: Bool) -> CGFloat { compact ? 16 : 24 @@ -21,46 +21,72 @@ public enum IdP { } } +private enum ShadcnHex { + // Light + static let bg = Color(red: 1, green: 1, blue: 1) + static let fg = Color(red: 0.039, green: 0.039, blue: 0.043) + static let muted = Color(red: 0.957, green: 0.957, blue: 0.961) + static let mutedFg = Color(red: 0.443, green: 0.443, blue: 0.478) + static let border = Color(red: 0.894, green: 0.894, blue: 0.906) + static let card = Color(red: 1, green: 1, blue: 1) + static let primary = Color(red: 0.094, green: 0.094, blue: 0.106) + static let primaryFg = Color(red: 0.980, green: 0.980, blue: 0.980) + static let accentSoft = Color(red: 0.961, green: 0.953, blue: 1.0) + static let destructive = Color(red: 0.937, green: 0.267, blue: 0.267) + static let ok = Color(red: 0.086, green: 0.639, blue: 0.290) + static let warn = Color(red: 0.918, green: 0.702, blue: 0.031) + + // Dark + static let bgDark = Color(red: 0.035, green: 0.035, blue: 0.043) + static let fgDark = Color(red: 0.980, green: 0.980, blue: 0.980) + static let mutedDark = Color(red: 0.094, green: 0.094, blue: 0.106) + static let mutedFgDark = Color(red: 0.631, green: 0.631, blue: 0.667) + static let borderDark = Color(red: 0.153, green: 0.153, blue: 0.165) + static let cardDark = Color(red: 0.047, green: 0.047, blue: 0.059) + static let primaryDark = Color(red: 0.980, green: 0.980, blue: 0.980) + static let primaryFgDark = Color(red: 0.094, green: 0.094, blue: 0.106) + static let accentSoftDark = Color(red: 0.102, green: 0.086, blue: 0.180) + static let destructiveDark = Color(red: 0.498, green: 0.114, blue: 0.114) +} + +private extension Color { + static func idpAdaptive(light: Color, dark: Color) -> Color { + #if os(macOS) + Color(nsColor: NSColor(name: nil) { appearance in + let matched = appearance.bestMatch(from: [.darkAqua, .vibrantDark, .aqua, .vibrantLight]) + let isDark = matched == .darkAqua || matched == .vibrantDark + return NSColor(isDark ? dark : light) + }) + #elseif canImport(UIKit) && !os(watchOS) + Color(uiColor: UIColor { traits in + UIColor(traits.userInterfaceStyle == .dark ? dark : light) + }) + #elseif os(watchOS) + dark + #else + light + #endif + } +} + extension Color { - static var idpGroupedBackground: Color { - #if os(macOS) - Color(nsColor: .windowBackgroundColor) - #elseif os(watchOS) - .black - #else - Color(uiColor: .systemGroupedBackground) - #endif - } + static let idpBackground = Color.idpAdaptive(light: ShadcnHex.bg, dark: ShadcnHex.bgDark) + static let idpForeground = Color.idpAdaptive(light: ShadcnHex.fg, dark: ShadcnHex.fgDark) + static let idpMuted = Color.idpAdaptive(light: ShadcnHex.muted, dark: ShadcnHex.mutedDark) + static let idpMutedForeground = Color.idpAdaptive(light: ShadcnHex.mutedFg, dark: ShadcnHex.mutedFgDark) + static let idpBorder = Color.idpAdaptive(light: ShadcnHex.border, dark: ShadcnHex.borderDark) + static let idpCard = Color.idpAdaptive(light: ShadcnHex.card, dark: ShadcnHex.cardDark) + static let idpPrimary = Color.idpAdaptive(light: ShadcnHex.primary, dark: ShadcnHex.primaryDark) + static let idpPrimaryForeground = Color.idpAdaptive(light: ShadcnHex.primaryFg, dark: ShadcnHex.primaryFgDark) + static let idpAccentSoft = Color.idpAdaptive(light: ShadcnHex.accentSoft, dark: ShadcnHex.accentSoftDark) + static let idpDestructive = Color.idpAdaptive(light: ShadcnHex.destructive, dark: ShadcnHex.destructiveDark) + static let idpOK = ShadcnHex.ok + static let idpWarn = ShadcnHex.warn - static var idpSecondaryGroupedBackground: Color { - #if os(macOS) - Color(nsColor: .controlBackgroundColor) - #elseif os(watchOS) - Color.white.opacity(0.08) - #else - Color(uiColor: .secondarySystemGroupedBackground) - #endif - } - - static var idpTertiaryFill: Color { - #if os(macOS) - Color(nsColor: .quaternaryLabelColor).opacity(0.08) - #elseif os(watchOS) - Color.white.opacity(0.12) - #else - Color(uiColor: .tertiarySystemFill) - #endif - } - - static var idpSeparator: Color { - #if os(macOS) - Color(nsColor: .separatorColor) - #elseif os(watchOS) - Color.white.opacity(0.14) - #else - Color(uiColor: .separator) - #endif - } + static var idpGroupedBackground: Color { idpBackground } + static var idpSecondaryGroupedBackground: Color { idpCard } + static var idpTertiaryFill: Color { idpMuted } + static var idpSeparator: Color { idpBorder } } extension View { @@ -93,7 +119,7 @@ extension View { #if os(macOS) searchable(text: text, isPresented: isPresented) #else - searchable(text: text, isPresented: isPresented, placement: .navigationBarDrawer(displayMode: .always)) + searchable(text: text, isPresented: isPresented, placement: .navigationBarDrawer(displayMode: .automatic)) #endif } } diff --git a/swift/Sources/Features/Home/HomeCards.swift b/swift/Sources/Features/Home/HomeCards.swift index 7b890f0..3e7383b 100644 --- a/swift/Sources/Features/Home/HomeCards.swift +++ b/swift/Sources/Features/Home/HomeCards.swift @@ -139,46 +139,156 @@ struct ApprovalRow: View { let handle: String var compact = false var highlighted = false + /// When non-nil the row renders inline Deny/Approve buttons below the + /// header — matching the shadcn inbox card. + var onApprove: (() -> Void)? = nil + var onDeny: (() -> Void)? = nil + var isBusy = false + + private var showsInlineActions: Bool { + request.status == .pending && onApprove != nil && onDeny != nil && !compact + } + + private var tint: Color { BrandTint.color(for: request.appDisplayName) } var body: some View { - HStack(spacing: 12) { - MonogramAvatar(title: request.appDisplayName, size: compact ? 32 : 40) - - VStack(alignment: .leading, spacing: 4) { - Text(request.inboxTitle) - .font(compact ? .subheadline.weight(.semibold) : .headline) - .foregroundStyle(.primary) - .lineLimit(2) - - Text("as \(handle) · \(request.locationSummary)") - .font(compact ? .caption : .subheadline) - .foregroundStyle(.secondary) - .lineLimit(1) - } - - Spacer(minLength: 8) - - HStack(spacing: 10) { - TimeChip(date: request.createdAt, compact: compact) - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) + VStack(alignment: .leading, spacing: showsInlineActions ? 12 : 0) { + header + if showsInlineActions { + actionRow } } - .padding(.vertical, compact ? 6 : 10) - .padding(.horizontal, 12) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(highlighted ? IdP.tint.opacity(0.06) : Color.clear) - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(highlighted ? IdP.tint : Color.clear, lineWidth: highlighted ? 1.5 : 0) - ) + .padding(showsInlineActions ? 14 : (compact ? 10 : 12)) + .background(background) + .overlay(stroke) + .background(glow) .contentShape(Rectangle()) .accessibilityElement(children: .combine) .accessibilityLabel("\(request.inboxTitle), \(request.locationSummary), \(request.createdAt.formatted(date: .omitted, time: .shortened))") } + + private var header: some View { + HStack(alignment: .top, spacing: 12) { + MonogramAvatar( + title: request.appDisplayName, + size: compact ? 32 : 40, + tint: tint, + filled: true + ) + + VStack(alignment: .leading, spacing: 2) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(request.appDisplayName) + .font(compact ? .footnote.weight(.semibold) : .subheadline.weight(.semibold)) + .foregroundStyle(.primary) + .lineLimit(1) + + Spacer(minLength: 4) + + Text(relativeTime) + .font(.caption) + .foregroundStyle(Color.idpMutedForeground) + } + + Text(request.kind.title) + .font(compact ? .caption : .footnote) + .foregroundStyle(Color.idpMutedForeground) + .lineLimit(1) + + if !compact { + HStack(spacing: 8) { + Label { + Text(request.locationSummary) + } icon: { + Image(systemName: "location.fill") + } + .font(.caption) + .foregroundStyle(Color.idpMutedForeground) + .labelStyle(.titleAndIcon) + + ShadcnBadge( + title: riskBadgeTitle, + tone: riskBadgeTone + ) + } + .padding(.top, 6) + } + } + + if !showsInlineActions && compact { + Image(systemName: "chevron.right") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.idpMutedForeground) + .padding(.top, 4) + } + } + } + + private var actionRow: some View { + HStack(spacing: 8) { + Button(action: { onDeny?() }) { + Text("Deny") + } + .buttonStyle(SecondaryActionStyle()) + .disabled(isBusy) + + Button(action: { onApprove?() }) { + if isBusy { + ProgressView().tint(Color.idpPrimaryForeground) + } else { + Label("Approve", systemImage: "checkmark") + .labelStyle(.titleAndIcon) + } + } + .buttonStyle(PrimaryActionStyle()) + .disabled(isBusy) + } + } + + private var background: some View { + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .fill(Color.idpCard) + } + + private var stroke: some View { + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .stroke(highlighted ? IdP.tint : Color.idpBorder, lineWidth: 1) + } + + @ViewBuilder + private var glow: some View { + if highlighted { + RoundedRectangle(cornerRadius: IdP.cardRadius + 3, style: .continuous) + .fill(IdP.tint.opacity(0.10)) + .padding(-3) + } + } + + private var relativeTime: String { + let seconds = Int(Date.now.timeIntervalSince(request.createdAt)) + if seconds < 60 { return "now" } + if seconds < 3600 { return "\(seconds / 60)m" } + if seconds < 86_400 { return "\(seconds / 3600)h" } + return "\(seconds / 86_400)d" + } + + private var riskBadgeTitle: String { + switch (request.status, request.risk) { + case (.approved, _): return "approved" + case (.rejected, _): return "denied" + case (.pending, .routine): return "trusted" + case (.pending, .elevated): return "new network" + } + } + + private var riskBadgeTone: ShadcnBadge.Tone { + switch (request.status, request.risk) { + case (.approved, _): return .ok + case (.rejected, _): return .danger + case (.pending, .routine): return .ok + case (.pending, .elevated): return .warn + } + } } struct NotificationEventRow: View { @@ -270,27 +380,30 @@ struct DeviceItemRow: View { var body: some View { HStack(spacing: 12) { - Image(systemName: device.systemImage) - .font(.headline) - .foregroundStyle(IdP.tint) - .frame(width: 28) + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.idpMuted) + Image(systemName: device.systemImage) + .font(.footnote.weight(.semibold)) + .foregroundStyle(.primary) + } + .frame(width: 32, height: 32) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 1) { Text(device.name) - .font(.body.weight(.medium)) + .font(.footnote.weight(.semibold)) - Text(device.isCurrent ? "This device" : "Seen \(device.lastSeen, style: .relative)") - .font(.subheadline) - .foregroundStyle(.secondary) + Text(device.isCurrent ? "berlin · primary · this device" : "last seen \(device.lastSeen, style: .relative)") + .font(.caption) + .foregroundStyle(Color.idpMutedForeground) } Spacer(minLength: 8) - StatusDot(color: device.isTrusted ? .green : .yellow) - - Image(systemName: "chevron.right") - .font(.caption.weight(.semibold)) - .foregroundStyle(.tertiary) + ShadcnBadge( + title: device.isTrusted ? "high" : "med", + tone: device.isTrusted ? .ok : .warn + ) } .deviceRowStyle() .accessibilityElement(children: .combine) @@ -300,34 +413,56 @@ struct DeviceItemRow: View { struct TrustSignalBanner: View { let request: ApprovalRequest - var body: some View { - HStack(alignment: .top, spacing: 12) { - Image(systemName: symbolName) - .font(.headline) - .foregroundStyle(request.trustColor) - - VStack(alignment: .leading, spacing: 4) { - Text(request.trustHeadline) - .font(.subheadline.weight(.semibold)) - - Text(request.trustExplanation) - .font(.subheadline) - .foregroundStyle(.secondary) - } + private var bg: Color { + switch request.trustColor { + case .green: return Color.idpOK.opacity(0.10) + case .yellow: return Color.idpWarn.opacity(0.15) + default: return Color.idpDestructive.opacity(0.10) + } + } + + private var fg: Color { + switch request.trustColor { + case .green: return Color.idpOK + case .yellow: return Color(red: 0.52, green: 0.30, blue: 0.05) + default: return Color.idpDestructive } - .padding(.vertical, 8) } private var symbolName: String { switch request.trustColor { - case .green: - return "checkmark.shield.fill" - case .yellow: - return "exclamationmark.triangle.fill" - default: - return "xmark.shield.fill" + case .green: return "checkmark.shield" + case .yellow: return "exclamationmark.triangle" + default: return "xmark.shield" } } + + var body: some View { + HStack(alignment: .top, spacing: 12) { + Image(systemName: symbolName) + .font(.subheadline.weight(.semibold)) + .foregroundStyle(fg) + .padding(.top, 1) + + VStack(alignment: .leading, spacing: 2) { + Text(request.trustHeadline) + .font(.footnote.weight(.semibold)) + .foregroundStyle(fg) + + Text(request.trustExplanation) + .font(.caption) + .foregroundStyle(Color.idpMutedForeground) + } + + Spacer(minLength: 0) + } + .padding(12) + .background(bg, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .stroke(Color.idpBorder, lineWidth: 1) + ) + } } struct EmptyPaneView: View { diff --git a/swift/Sources/Features/Home/HomePanels.swift b/swift/Sources/Features/Home/HomePanels.swift index 2160565..59a6e53 100644 --- a/swift/Sources/Features/Home/HomePanels.swift +++ b/swift/Sources/Features/Home/HomePanels.swift @@ -31,71 +31,132 @@ struct InboxListView: View { filteredRequests.first?.id } - var body: some View { - List { - if filteredRequests.isEmpty { - EmptyPaneView( - title: "No sign-in requests", - message: "New approval requests will appear here as soon as a relying party asks for proof.", - systemImage: "tray" - ) - .listRowBackground(Color.clear) - } else { - ForEach(recentRequests) { request in - row(for: request, compact: false) - .transition(.move(edge: .top).combined(with: .opacity)) - } + private var oldestPendingMinutes: Int? { + guard let oldest = model.pendingRequests.min(by: { $0.createdAt < $1.createdAt }) else { + return nil + } + return max(1, Int(Date.now.timeIntervalSince(oldest.createdAt)) / 60) + } + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 10, pinnedViews: []) { + InboxHeader( + pendingCount: model.pendingRequests.count, + oldestMinutes: oldestPendingMinutes + ) + .padding(.horizontal, 4) + .padding(.bottom, 6) + + if filteredRequests.isEmpty { + EmptyPaneView( + title: "No sign-in requests", + message: "New approval requests will appear here as soon as a relying party asks for proof.", + systemImage: "tray" + ) + .padding(.top, 40) + } else { + ForEach(recentRequests) { request in + row(for: request, compact: false) + .transition(.move(edge: .top).combined(with: .opacity)) + } + + if !earlierRequests.isEmpty { + Text("Earlier today") + .font(.caption2.weight(.semibold)) + .tracking(0.5) + .textCase(.uppercase) + .foregroundStyle(Color.idpMutedForeground) + .padding(.top, 12) + .padding(.leading, 4) - if !earlierRequests.isEmpty { - Section { ForEach(earlierRequests) { request in row(for: request, compact: true) .transition(.move(edge: .top).combined(with: .opacity)) } - } header: { - Text("Earlier today") - .textCase(nil) } } } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 120) } - .listStyle(.plain) + .scrollIndicators(.hidden) + .background(Color.idpBackground.ignoresSafeArea()) .navigationTitle("Inbox") .animation(.spring(response: 0.35, dampingFraction: 0.88), value: filteredRequests.map(\.id)) - .idpSearchable(text: $searchText, isPresented: $isSearchPresented) + #if !os(macOS) + .searchable(text: $searchText, isPresented: $isSearchPresented, placement: .navigationBarDrawer(displayMode: .automatic)) + #else + .searchable(text: $searchText, isPresented: $isSearchPresented) + #endif } @ViewBuilder private func row(for request: ApprovalRequest, compact: Bool) -> some View { + let handle = model.profile?.handle ?? "@you" + let highlighted = highlightedRequestID == request.id + let isBusy = model.activeRequestID == request.id + let rowContent = ApprovalRow( + request: request, + handle: handle, + compact: compact, + highlighted: highlighted, + onApprove: compact ? nil : { Task { await model.approve(request) } }, + onDeny: compact ? nil : { + Haptics.warning() + Task { await model.reject(request) } + }, + isBusy: isBusy + ) + if usesSelection { Button { selectedRequestID = request.id Haptics.selection() } label: { - ApprovalRow( - request: request, - handle: model.profile?.handle ?? "@you", - compact: compact, - highlighted: highlightedRequestID == request.id - ) + rowContent } .buttonStyle(.plain) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) } else { NavigationLink(value: request.id) { - ApprovalRow( - request: request, - handle: model.profile?.handle ?? "@you", - compact: compact, - highlighted: highlightedRequestID == request.id - ) + rowContent } .buttonStyle(.plain) - .listRowInsets(EdgeInsets(top: 4, leading: 8, bottom: 4, trailing: 8)) - .listRowSeparator(.hidden) - .listRowBackground(Color.clear) + } + } +} + +private struct InboxHeader: View { + let pendingCount: Int + let oldestMinutes: Int? + + var body: some View { + HStack(spacing: 10) { + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.idpPrimary) + Image(systemName: "shield.lefthalf.filled") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.idpPrimaryForeground) + } + .frame(width: 24, height: 24) + + Text("idp.global") + .font(.subheadline.weight(.semibold)) + + Spacer() + + if pendingCount > 0 { + ShadcnBadge(title: "\(pendingCount) pending", tone: .ok) + if let oldestMinutes { + Text("oldest \(oldestMinutes) min ago") + .font(.caption) + .foregroundStyle(Color.idpMutedForeground) + } + } else { + ShadcnBadge(title: "all clear", tone: .ok) + } } } } diff --git a/swift/Sources/Features/Home/HomeRootView.swift b/swift/Sources/Features/Home/HomeRootView.swift index 65d262b..1bd1d9e 100644 --- a/swift/Sources/Features/Home/HomeRootView.swift +++ b/swift/Sources/Features/Home/HomeRootView.swift @@ -230,34 +230,40 @@ private struct InboxToolbar: ToolbarContent { var body: some ToolbarContent { ToolbarItem(placement: .idpTrailingToolbar) { - HStack(spacing: 8) { + HStack(spacing: 6) { Button { - isSearchPresented = true + isSearchPresented.toggle() } label: { Image(systemName: "magnifyingglass") - .font(.headline) + .font(.footnote.weight(.medium)) .foregroundStyle(.primary) + .frame(width: 32, height: 32) } .accessibilityLabel("Search inbox") Button { model.selectedSection = .identity } label: { - MonogramAvatar(title: model.profile?.name ?? "idp.global", size: 28) + ZStack { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(Color.idpAccentSoft) + Text(initials(from: model.profile?.name)) + .font(.caption.weight(.semibold)) + .foregroundStyle(IdP.tint) + } + .frame(width: 28, height: 28) } .accessibilityLabel("Open identity") } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(.clear) - .idpGlassChrome() - ) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.white.opacity(0.16), lineWidth: 1) - ) } } + + private func initials(from name: String?) -> String { + guard let name else { return "YOU" } + let letters = name + .split(separator: " ") + .prefix(2) + .compactMap { $0.first } + return String(letters.map(Character.init)).uppercased() + } } diff --git a/swift/Sources/Features/Home/HomeSheets.swift b/swift/Sources/Features/Home/HomeSheets.swift index b7ff904..9c6a563 100644 --- a/swift/Sources/Features/Home/HomeSheets.swift +++ b/swift/Sources/Features/Home/HomeSheets.swift @@ -15,54 +15,45 @@ struct ApprovalDetailView: View { var body: some View { Group { if let request { - VStack(spacing: 0) { - RequestHeroCard( - request: request, - handle: model.profile?.handle ?? "@you" - ) - .padding(.horizontal, 16) - .padding(.top, 16) + ScrollView { + VStack(alignment: .leading, spacing: 14) { + RequestHeroCard( + request: request, + handle: model.profile?.handle ?? "@you" + ) - Form { - Section("Context") { - LabeledContent("From device", value: request.deviceSummary) - LabeledContent("Location", value: request.locationSummary) - LabeledContent("Network", value: request.networkSummary) - LabeledContent("IP") { - Text(request.ipSummary) - .monospacedDigit() - } - } + ShadcnMetaCard(rows: [ + .init(label: "From device", value: request.deviceSummary), + .init(label: "Location", value: request.locationSummary), + .init(label: "Network", value: request.networkSummary), + .init(label: "IP", value: request.ipSummary, monospaced: true) + ]) - Section("Will share") { - ForEach(request.scopes, id: \.self) { scope in - Label(scope, systemImage: "checkmark.circle.fill") - .foregroundStyle(.green) - } - } + SectionHeader(title: "Will share") + ShadcnScopesCard(scopes: request.scopes, profile: model.profile) - Section("Trust signals") { - TrustSignalBanner(request: request) - } + TrustSignalBanner(request: request) } - .scrollContentBackground(.hidden) - .background(Color.idpGroupedBackground) + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 110) } - .background(Color.idpGroupedBackground) + .scrollIndicators(.hidden) + .background(Color.idpBackground) .navigationTitle(request.appDisplayName) .idpInlineNavigationTitle() .toolbar { ToolbarItem(placement: .idpTrailingToolbar) { - IdPGlassCapsule(padding: EdgeInsets(top: 8, leading: 12, bottom: 8, trailing: 12)) { - Text(request.expiresAt, style: .timer) - .font(.caption.weight(.semibold)) - .monospacedDigit() - } + ShadcnBadge( + title: "expires \(formattedExpires(request.expiresAt))", + tone: .outline, + leading: Image(systemName: "clock") + ) } } .safeAreaInset(edge: .bottom) { if request.status == .pending { - HStack(spacing: 12) { + HStack(spacing: 8) { Button("Deny") { Task { await performReject(request) @@ -76,10 +67,11 @@ struct ApprovalDetailView: View { } .padding(.horizontal, 16) .padding(.vertical, 12) - .background { + .background(Color.idpBackground) + .overlay(alignment: .top) { Rectangle() - .fill(.clear) - .idpGlassChrome() + .fill(Color.idpBorder) + .frame(height: 1) } } } @@ -96,6 +88,13 @@ struct ApprovalDetailView: View { } } + private func formattedExpires(_ date: Date) -> String { + let seconds = Int(max(0, date.timeIntervalSince(.now))) + let m = seconds / 60 + let s = seconds % 60 + return String(format: "%d:%02d", m, s) + } + @ViewBuilder private func keyboardShortcuts(for request: ApprovalRequest) -> some View { Group { @@ -158,24 +157,20 @@ struct HoldToApproveButton: View { var body: some View { ZStack { RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .fill(isBusy ? Color.secondary.opacity(0.24) : IdP.tint) - - RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .stroke(Color.white.opacity(0.16), lineWidth: 1) + .fill(isBusy ? Color.idpMuted : Color.idpPrimary) label .padding(.horizontal, 20) - .padding(.vertical, 14) - GeometryReader { geometry in + GeometryReader { _ in RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .trim(from: 0, to: progress) - .stroke(Color.white.opacity(0.85), style: StrokeStyle(lineWidth: 3, lineCap: .round)) + .stroke(IdP.tint, style: StrokeStyle(lineWidth: 2, lineCap: .round)) .rotationEffect(.degrees(-90)) - .padding(2) + .padding(1.5) } } - .frame(minHeight: 52) + .frame(height: 44) .contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)) .onLongPressGesture(minimumDuration: 0.6, maximumDistance: 20, pressing: updateProgress) { guard !isBusy else { return } @@ -194,11 +189,14 @@ struct HoldToApproveButton: View { private var label: some View { if isBusy { ProgressView() - .tint(.white) + .tint(Color.idpPrimaryForeground) } else { - Text(title) - .font(.headline) - .foregroundStyle(.white) + HStack(spacing: 6) { + Image(systemName: "checkmark") + Text(title) + } + .font(.subheadline.weight(.semibold)) + .foregroundStyle(Color.idpPrimaryForeground) } } @@ -210,6 +208,132 @@ struct HoldToApproveButton: View { } } +struct ShadcnMetaCard: View { + struct Row: Identifiable { + let id = UUID() + let label: String + let value: String + var monospaced: Bool = false + } + + let rows: [Row] + + var body: some View { + VStack(spacing: 0) { + ForEach(Array(rows.enumerated()), id: \.element.id) { index, row in + HStack { + Text(row.label) + .foregroundStyle(Color.idpMutedForeground) + Spacer() + Text(row.value) + .font(row.monospaced ? .footnote.monospaced() : .footnote.weight(.medium)) + .foregroundStyle(.primary) + } + .font(.footnote) + .padding(.horizontal, 14) + .padding(.vertical, 10) + + if index < rows.count - 1 { + Rectangle() + .fill(Color.idpBorder) + .frame(height: 1) + } + } + } + .background(Color.idpCard, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .stroke(Color.idpBorder, lineWidth: 1) + ) + } +} + +struct ShadcnScopesCard: View { + let scopes: [String] + let profile: MemberProfile? + + var body: some View { + VStack(spacing: 0) { + ForEach(Array(scopes.enumerated()), id: \.offset) { index, scope in + HStack(spacing: 10) { + Image(systemName: icon(for: scope)) + .font(.caption) + .foregroundStyle(Color.idpMutedForeground) + .frame(width: 16) + + Text(label(for: scope)) + .font(.footnote.weight(.medium)) + + Spacer() + + Text(value(for: scope)) + .font(.footnote) + .foregroundStyle(Color.idpMutedForeground) + + Image(systemName: "checkmark") + .font(.caption.weight(.semibold)) + .foregroundStyle(Color.idpOK) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + + if index < scopes.count - 1 { + Rectangle() + .fill(Color.idpBorder) + .frame(height: 1) + } + } + } + .background(Color.idpCard, in: RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) + .stroke(Color.idpBorder, lineWidth: 1) + ) + } + + private func icon(for scope: String) -> String { + let key = scope.lowercased() + if key.contains("email") { return "envelope" } + if key.contains("location") { return "location" } + if key.contains("profile") { return "person" } + if key.contains("device") { return "iphone" } + if key.contains("session") { return "key" } + return "checkmark.seal" + } + + private func label(for scope: String) -> String { + let key = scope.lowercased() + if key.contains("email") { return "Email address" } + if key.contains("location") { return "Coarse location" } + if key.contains("profile") { return "Profile name" } + if key.contains("device") { return "Device posture" } + if key.contains("session") { return "Session read" } + return scope.capitalized + } + + private func value(for scope: String) -> String { + let key = scope.lowercased() + if key.contains("email") { return "\(profile?.handle ?? "@you")" } + if key.contains("location") { return "Berlin region" } + if key.contains("profile") { return profile?.name ?? "You" } + return "Yes" + } +} + +struct SectionHeader: View { + let title: String + + var body: some View { + Text(title) + .font(.caption2.weight(.semibold)) + .tracking(0.5) + .textCase(.uppercase) + .foregroundStyle(Color.idpMutedForeground) + .padding(.leading, 4) + .padding(.top, 4) + } +} + struct NFCSheet: View { var title = "Hold near reader" var message = "Tap to confirm sign-in. Your location will be signed and sent." @@ -227,34 +351,39 @@ struct NFCSheet: View { } var body: some View { - VStack(spacing: 24) { + VStack(spacing: 20) { ZStack { ForEach(0..<3, id: \.self) { index in Circle() - .stroke(IdP.tint.opacity(0.16), lineWidth: 1.5) - .frame(width: 88 + CGFloat(index * 34), height: 88 + CGFloat(index * 34)) - .scaleEffect(pulse ? 1.08 : 0.92) - .opacity(pulse ? 0.2 : 0.6) - .animation(.easeInOut(duration: 1.4).repeatForever().delay(Double(index) * 0.12), value: pulse) + .stroke(IdP.tint.opacity(0.55 - Double(index) * 0.18), lineWidth: 1) + .frame(width: 96 + CGFloat(index * 24), height: 96 + CGFloat(index * 24)) + .scaleEffect(pulse ? 1.10 : 0.92) + .opacity(pulse ? 0.0 : 0.9) + .animation(.easeOut(duration: 2.0).repeatForever(autoreverses: false).delay(Double(index) * 0.5), value: pulse) } - Image(systemName: "wave.3.right") - .font(.system(size: 34, weight: .semibold)) - .foregroundStyle(IdP.tint) + Circle() + .fill(IdP.tint) + .frame(width: 56, height: 56) + .overlay( + Image(systemName: "wave.3.right") + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(.white) + ) } - .frame(height: 160) + .frame(height: 130) - VStack(spacing: 8) { + VStack(spacing: 6) { Text(title) - .font(.title3.weight(.semibold)) + .font(.title3.weight(.bold)) Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(.footnote) + .foregroundStyle(Color.idpMutedForeground) .multilineTextAlignment(.center) } - VStack(spacing: 12) { + HStack(spacing: 8) { Button("Cancel") { dismiss() } @@ -274,6 +403,7 @@ struct NFCSheet: View { } } .padding(24) + .background(Color.idpBackground) .presentationDetents([.medium]) .presentationDragIndicator(.visible) .task { diff --git a/swift/WatchApp/Design/ButtonStyles.swift b/swift/WatchApp/Design/ButtonStyles.swift index da17ec0..96c2bcd 100644 --- a/swift/WatchApp/Design/ButtonStyles.swift +++ b/swift/WatchApp/Design/ButtonStyles.swift @@ -3,55 +3,105 @@ import SwiftUI struct PrimaryActionStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.headline) + .font(.footnote.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.horizontal, 12) - .padding(.vertical, 12) + .padding(.vertical, 10) + .foregroundStyle(Color.idpPrimaryForeground) .background( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .fill(IdP.tint) + .fill(Color.idpPrimary) ) - .foregroundStyle(.white) - .opacity(configuration.isPressed ? 0.92 : 1) + .opacity(configuration.isPressed ? 0.85 : 1) } } struct SecondaryActionStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.headline) + .font(.footnote.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.horizontal, 12) - .padding(.vertical, 12) + .padding(.vertical, 10) + .foregroundStyle(.white) .background( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .fill(Color.idpSecondaryGroupedBackground) + .fill(Color.clear) ) .overlay( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .stroke(Color.idpSeparator, lineWidth: 1) + .stroke(Color.white.opacity(0.22), lineWidth: 1) ) - .foregroundStyle(.white) - .opacity(configuration.isPressed ? 0.92 : 1) + .opacity(configuration.isPressed ? 0.7 : 1) } } struct DestructiveStyle: ButtonStyle { func makeBody(configuration: Configuration) -> some View { configuration.label - .font(.headline) + .font(.footnote.weight(.semibold)) .frame(maxWidth: .infinity) .padding(.horizontal, 12) - .padding(.vertical, 12) + .padding(.vertical, 10) + .foregroundStyle(.white) .background( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .fill(Color.red.opacity(0.18)) + .fill(Color.idpDestructive) ) - .overlay( - RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .stroke(Color.red.opacity(0.25), lineWidth: 1) - ) - .foregroundStyle(.red) - .opacity(configuration.isPressed ? 0.92 : 1) + .opacity(configuration.isPressed ? 0.85 : 1) + } +} + +struct WatchBadge: View { + enum Tone { + case neutral + case accent + case ok + case warn + case danger + } + + let title: String + var tone: Tone = .neutral + + var body: some View { + Text(title) + .font(.system(size: 9, weight: .bold)) + .tracking(0.5) + .foregroundStyle(foreground) + .padding(.horizontal, 6) + .padding(.vertical, 2) + .background(background, in: Capsule(style: .continuous)) + .overlay( + Capsule(style: .continuous) + .stroke(stroke, lineWidth: 1) + ) + } + + private var foreground: Color { + switch tone { + case .neutral: return Color.idpMutedForeground + case .accent: return IdP.tint + case .ok: return Color.idpOK + case .warn: return Color.idpWarn + case .danger: return Color.idpDestructive + } + } + + private var background: Color { + switch tone { + case .neutral: return Color.white.opacity(0.05) + default: return .clear + } + } + + private var stroke: Color { + switch tone { + case .accent: return IdP.tint.opacity(0.45) + case .ok: return Color.idpOK.opacity(0.45) + case .warn: return Color.idpWarn.opacity(0.45) + case .danger: return Color.idpDestructive.opacity(0.55) + case .neutral: return .clear + } } } diff --git a/swift/WatchApp/Design/Cards.swift b/swift/WatchApp/Design/Cards.swift index 20a480a..4fde91e 100644 --- a/swift/WatchApp/Design/Cards.swift +++ b/swift/WatchApp/Design/Cards.swift @@ -5,14 +5,15 @@ struct ApprovalCardModifier: ViewModifier { func body(content: Content) -> some View { content - .padding(14) + .padding(8) .background( RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) - .fill(Color.idpSecondaryGroupedBackground) + .fill(Color.clear) ) .overlay( RoundedRectangle(cornerRadius: IdP.cardRadius, style: .continuous) - .stroke(highlighted ? IdP.tint.opacity(0.75) : Color.idpSeparator, lineWidth: highlighted ? 1.5 : 1) + .stroke(highlighted ? IdP.tint.opacity(0.55) : Color.white.opacity(0.12), + lineWidth: 1) ) } } @@ -28,38 +29,75 @@ struct RequestHeroCard: View { let handle: String var body: some View { - HStack(spacing: 12) { - MonogramAvatar(title: request.source, size: 40) - - VStack(alignment: .leading, spacing: 4) { + VStack(alignment: .leading, spacing: 6) { + HStack(spacing: 5) { + MonogramAvatar(title: request.source, size: 18) Text(request.source) - .font(.headline) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.idpMutedForeground) + .lineLimit(1) + } + + HStack(spacing: 4) { + Text("Sign in as") .foregroundStyle(.white) Text(handle) - .font(.footnote) .foregroundStyle(IdP.tint) + .fontWeight(.semibold) + Text("?") + .foregroundStyle(.white) } + .font(.system(size: 15, weight: .semibold)) + .lineLimit(2) } - .approvalCard(highlighted: true) + .frame(maxWidth: .infinity, alignment: .leading) } } struct MonogramAvatar: View { let title: String - var size: CGFloat = 22 + var size: CGFloat = 20 + var tint: Color = IdP.tint private var monogram: String { - String(title.trimmingCharacters(in: .whitespacesAndNewlines).first ?? "I").uppercased() + let letters = title + .replacingOccurrences(of: "auth.", with: "") + .split { !$0.isLetter && !$0.isNumber } + .prefix(2) + .compactMap { $0.first } + let glyph = String(letters.map(Character.init)) + return glyph.isEmpty ? "I" : glyph.uppercased() } var body: some View { - RoundedRectangle(cornerRadius: size * 0.34, style: .continuous) - .fill(IdP.tint.opacity(0.2)) - .frame(width: size, height: size) - .overlay { - Text(monogram) - .font(.system(size: size * 0.48, weight: .semibold, design: .rounded)) - .foregroundStyle(IdP.tint) - } + ZStack { + RoundedRectangle(cornerRadius: 5, style: .continuous) + .fill(tint) + Text(monogram) + .font(.system(size: size * (monogram.count > 1 ? 0.36 : 0.44), + weight: .bold)) + .foregroundStyle(.white) + .tracking(-0.3) + } + .frame(width: size, height: size) + .accessibilityHidden(true) + } +} + +enum BrandTint { + static func color(for host: String) -> Color { + let key = host.lowercased() + if key.contains("github") { return Color(red: 0.14, green: 0.16, blue: 0.18) } + if key.contains("lufthansa") { return Color(red: 0.02, green: 0.09, blue: 0.30) } + if key.contains("hetzner") { return Color(red: 0.84, green: 0.05, blue: 0.18) } + if key.contains("notion") { return Color(red: 0.12, green: 0.12, blue: 0.12) } + if key.contains("apple") { return Color(red: 0.18, green: 0.18, blue: 0.22) } + if key.contains("reddit") { return Color(red: 1.00, green: 0.27, blue: 0.00) } + if key.contains("cli") { return Color(red: 0.24, green: 0.26, blue: 0.32) } + if key.contains("workspace") { return Color(red: 0.26, green: 0.38, blue: 0.88) } + if key.contains("foss") || key.contains("berlin-mbp") { + return Color(red: 0.12, green: 0.45, blue: 0.70) + } + return IdP.tint } } diff --git a/swift/WatchApp/Design/IdPTokens.swift b/swift/WatchApp/Design/IdPTokens.swift index e7c4f51..85e9400 100644 --- a/swift/WatchApp/Design/IdPTokens.swift +++ b/swift/WatchApp/Design/IdPTokens.swift @@ -1,15 +1,25 @@ import SwiftUI public enum IdP { - public static let tint = Color("IdPTint") - public static let cardRadius: CGFloat = 20 - public static let controlRadius: CGFloat = 14 - public static let badgeRadius: CGFloat = 8 + // Direct value — watch target has no Assets.xcassets so `Color("IdPTint")` + // would fall back to a transparent color and hide tinted text. + public static let tint = Color(red: 0.561, green: 0.486, blue: 0.961) + public static let cardRadius: CGFloat = 8 + public static let controlRadius: CGFloat = 8 + public static let badgeRadius: CGFloat = 999 } extension Color { + // Shadcn on watch = pure black bg, subtle white dividers, white primary for inverted CTA. static var idpGroupedBackground: Color { .black } - static var idpSecondaryGroupedBackground: Color { Color.white.opacity(0.08) } - static var idpTertiaryFill: Color { Color.white.opacity(0.12) } + static var idpSecondaryGroupedBackground: Color { Color.white.opacity(0.06) } + static var idpTertiaryFill: Color { Color.white.opacity(0.10) } static var idpSeparator: Color { Color.white.opacity(0.14) } + static var idpBorder: Color { Color.white.opacity(0.12) } + static var idpMutedForeground: Color { Color.white.opacity(0.55) } + static var idpPrimary: Color { Color(red: 0.980, green: 0.980, blue: 0.980) } + static var idpPrimaryForeground: Color { Color(red: 0.094, green: 0.094, blue: 0.106) } + static var idpOK: Color { Color(red: 0.086, green: 0.639, blue: 0.290) } + static var idpWarn: Color { Color(red: 0.918, green: 0.702, blue: 0.031) } + static var idpDestructive: Color { Color(red: 0.498, green: 0.114, blue: 0.114) } } diff --git a/swift/WatchApp/Features/WatchRootView.swift b/swift/WatchApp/Features/WatchRootView.swift index ee44642..0a189ba 100644 --- a/swift/WatchApp/Features/WatchRootView.swift +++ b/swift/WatchApp/Features/WatchRootView.swift @@ -9,17 +9,16 @@ struct WatchRootView: View { Group { if model.session == nil { WatchPairingView(model: model) + } else if showsQueue { + WatchQueueView(model: model) } else { - if showsQueue { - WatchQueueView(model: model) - } else { - WatchHomeView(model: model) - } + WatchHomeView(model: model) } } - .background(Color.idpGroupedBackground.ignoresSafeArea()) + .background(Color.black.ignoresSafeArea()) } .tint(IdP.tint) + .preferredColorScheme(.dark) .onOpenURL { url in if (url.host ?? url.lastPathComponent).lowercased() == "inbox" { showsQueue = true @@ -32,14 +31,19 @@ private struct WatchPairingView: View { @ObservedObject var model: AppViewModel var body: some View { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { + WatchBadge(title: "PAIR · STEP 1", tone: .accent) + Text("Link your watch") - .font(.headline) + .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white) Text("Use the shared demo passport so approvals stay visible on your wrist.") .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) + .foregroundStyle(Color.idpMutedForeground) + .fixedSize(horizontal: false, vertical: true) + + Spacer(minLength: 4) Button("Use demo payload") { Task { @@ -48,8 +52,8 @@ private struct WatchPairingView: View { } .buttonStyle(PrimaryActionStyle()) } - .approvalCard(highlighted: true) - .padding(10) + .frame(maxWidth: .infinity, alignment: .leading) + .padding(8) .navigationTitle("idp.global") } } @@ -76,22 +80,49 @@ struct WatchApprovalView: View { model.requests.first(where: { $0.id == requestID }) } + @ViewBuilder + private func signInPrompt(handle: String) -> some View { + var attributed = AttributedString("Sign in as \(handle)?") + attributed.font = .system(size: 15, weight: .semibold) + attributed.foregroundColor = .white + if let range = attributed.range(of: handle) { + attributed[range].foregroundColor = IdP.tint + } + return Text(attributed) + .lineLimit(2) + .minimumScaleFactor(0.8) + } + var body: some View { Group { if let request { - ScrollView { - VStack(alignment: .leading, spacing: 12) { - MonogramAvatar(title: request.watchAppDisplayName, size: 42) + VStack(alignment: .leading, spacing: 5) { + HStack(spacing: 5) { + MonogramAvatar( + title: request.watchAppDisplayName, + size: 18, + tint: BrandTint.color(for: request.watchAppDisplayName) + ) + Text(request.watchAppDisplayName) + .font(.system(size: 11, weight: .medium)) + .foregroundStyle(Color.idpMutedForeground) + .lineLimit(1) + } - Text("Sign in as \(model.profile?.handle ?? "@you")?") - .font(.headline) - .foregroundStyle(.white) + signInPrompt(handle: model.profile?.handle ?? "@you") - Text(request.watchLocationSummary) - .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) + HStack(spacing: 3) { + Image(systemName: "location.fill") + .font(.system(size: 8)) + Text("\(request.watchLocationSummary) · now") + } + .font(.system(size: 10)) + .foregroundStyle(Color.idpMutedForeground) - HStack(spacing: 8) { + Spacer(minLength: 4) + + GeometryReader { geo in + HStack(spacing: 5) { Button { Task { Haptics.warning() @@ -99,21 +130,23 @@ struct WatchApprovalView: View { } } label: { Image(systemName: "xmark") - .frame(maxWidth: .infinity) + .font(.footnote.weight(.semibold)) } .buttonStyle(SecondaryActionStyle()) - .frame(maxWidth: .infinity) + .frame(width: (geo.size.width - 5) / 3) WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) { await model.approve(request) } - .frame(maxWidth: .infinity) } } - .approvalCard(highlighted: true) - .padding(10) + .frame(height: 36) } - .navigationTitle("Approve") + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 8) + .padding(.top, 4) + .padding(.bottom, 6) + .navigationBarTitleDisplayMode(.inline) .toolbar { ToolbarItem(placement: .bottomBar) { NavigationLink("Queue") { @@ -136,23 +169,36 @@ private struct WatchQueueView: View { @ObservedObject var model: AppViewModel var body: some View { - List { - if model.requests.isEmpty { - WatchEmptyState( - title: "All clear", - message: "New sign-in requests will appear on your watch here.", - systemImage: "shield" - ) - } else { - ForEach(model.requests) { request in - NavigationLink { - WatchRequestDetailView(model: model, requestID: request.id) - } label: { - WatchQueueRow(request: request) + ScrollView { + VStack(alignment: .leading, spacing: 6) { + Text("INBOX · \(model.requests.count)") + .font(.system(size: 10, weight: .bold)) + .tracking(0.6) + .foregroundStyle(IdP.tint) + .padding(.horizontal, 4) + .padding(.top, 2) + + if model.requests.isEmpty { + WatchEmptyState( + title: "All clear", + message: "New sign-in requests appear here.", + systemImage: "shield" + ) + } else { + ForEach(model.requests) { request in + NavigationLink { + WatchRequestDetailView(model: model, requestID: request.id) + } label: { + WatchQueueRow(request: request) + } + .buttonStyle(.plain) } } } + .padding(.horizontal, 6) + .padding(.bottom, 8) } + .scrollIndicators(.hidden) .navigationTitle("Queue") } } @@ -161,19 +207,43 @@ private struct WatchQueueRow: View { let request: ApprovalRequest var body: some View { - HStack(spacing: 8) { - MonogramAvatar(title: request.watchAppDisplayName, size: 22) + HStack(spacing: 6) { + MonogramAvatar( + title: request.watchAppDisplayName, + size: 20, + tint: BrandTint.color(for: request.watchAppDisplayName) + ) - VStack(alignment: .leading, spacing: 2) { + VStack(alignment: .leading, spacing: 0) { Text(request.watchAppDisplayName) - .font(.footnote.weight(.semibold)) + .font(.system(size: 11, weight: .semibold)) .foregroundStyle(.white) - Text(request.createdAt, style: .time) - .font(.caption2) - .foregroundStyle(.white.opacity(0.68)) + .lineLimit(1) + Text(request.kind.title) + .font(.system(size: 9)) + .foregroundStyle(Color.idpMutedForeground) + .lineLimit(1) } + + Spacer(minLength: 4) + + Text(relativeTime) + .font(.system(size: 9)) + .foregroundStyle(Color.idpMutedForeground) } - .padding(.vertical, 2) + .padding(6) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) + ) + } + + private var relativeTime: String { + let seconds = Int(Date.now.timeIntervalSince(request.createdAt)) + if seconds < 60 { return "now" } + if seconds < 3600 { return "\(seconds / 60)m" } + if seconds < 86_400 { return "\(seconds / 3600)h" } + return "\(seconds / 86_400)d" } } @@ -189,12 +259,13 @@ private struct WatchRequestDetailView: View { Group { if let request { ScrollView { - VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 10) { RequestHeroCard(request: request, handle: model.profile?.handle ?? "@you") Text(request.watchTrustExplanation) .font(.footnote) - .foregroundStyle(.white.opacity(0.72)) + .foregroundStyle(Color.idpMutedForeground) + .fixedSize(horizontal: false, vertical: true) if request.status == .pending { WatchHoldToApproveButton(isBusy: model.activeRequestID == request.id) { @@ -210,7 +281,7 @@ private struct WatchRequestDetailView: View { .buttonStyle(SecondaryActionStyle()) } } - .padding(10) + .padding(8) } } else { WatchEmptyState( @@ -233,22 +304,28 @@ private struct WatchHoldToApproveButton: View { var body: some View { ZStack { RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .fill(isBusy ? Color.white.opacity(0.18) : IdP.tint) + .fill(isBusy ? Color.white.opacity(0.18) : Color.idpPrimary) - RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) - .stroke(Color.white.opacity(0.16), lineWidth: 1) - - Text(isBusy ? "Working…" : "Approve") - .font(.headline) - .foregroundStyle(.white) - .padding(.vertical, 12) + if isBusy { + Text("Working…") + .font(.footnote.weight(.semibold)) + .foregroundStyle(.white) + } else { + HStack(spacing: 4) { + Image(systemName: "checkmark") + Text("Approve") + } + .font(.footnote.weight(.semibold)) + .foregroundStyle(Color.idpPrimaryForeground) + } RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .trim(from: 0, to: progress) - .stroke(Color.white.opacity(0.88), style: StrokeStyle(lineWidth: 2.5, lineCap: .round)) + .stroke(IdP.tint, style: StrokeStyle(lineWidth: 2, lineCap: .round)) .rotationEffect(.degrees(-90)) - .padding(2) + .padding(1.5) } + .frame(height: 36) .contentShape(RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous)) .onLongPressGesture(minimumDuration: 0.6, maximumDistance: 18, pressing: updateProgress) { guard !isBusy else { return } @@ -294,7 +371,7 @@ private extension ApprovalRequest { } var watchLocationSummary: String { - "Berlin, DE" + "Berlin" } } @@ -304,23 +381,34 @@ private struct WatchEmptyState: View { let systemImage: String var body: some View { - ContentUnavailableView { - Label(title, systemImage: systemImage) - } description: { + VStack(alignment: .leading, spacing: 6) { + Image(systemName: systemImage) + .font(.title3) + .foregroundStyle(Color.idpMutedForeground) + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(.white) Text(message) + .font(.footnote) + .foregroundStyle(Color.idpMutedForeground) + .fixedSize(horizontal: false, vertical: true) } + .padding(8) } } -#Preview("Watch Approval Light") { - WatchApprovalPreviewHost() -} - -#Preview("Watch Approval Dark") { +#Preview("Watch Approval") { WatchApprovalPreviewHost() .preferredColorScheme(.dark) } +#Preview("Watch Queue") { + NavigationStack { + WatchQueuePreviewHost() + } + .preferredColorScheme(.dark) +} + @MainActor private struct WatchApprovalPreviewHost: View { @State private var model = WatchPreviewFixtures.model() @@ -330,6 +418,15 @@ private struct WatchApprovalPreviewHost: View { } } +@MainActor +private struct WatchQueuePreviewHost: View { + @State private var model = WatchPreviewFixtures.model() + + var body: some View { + WatchQueueView(model: model) + } +} + private enum WatchPreviewFixtures { static let profile = MemberProfile( name: "Jurgen Meyer", @@ -358,6 +455,26 @@ private enum WatchPreviewFixtures { risk: .routine, scopes: ["profile", "email"], status: .pending + ), + ApprovalRequest( + title: "Lufthansa sign-in", + subtitle: "Verify identity", + source: "lufthansa.com", + createdAt: .now.addingTimeInterval(-60 * 4), + kind: .accessGrant, + risk: .routine, + scopes: ["profile"], + status: .pending + ), + ApprovalRequest( + title: "Hetzner", + subtitle: "Console", + source: "hetzner.cloud", + createdAt: .now.addingTimeInterval(-60 * 8), + kind: .elevatedAction, + risk: .elevated, + scopes: ["device"], + status: .pending ) ] diff --git a/swift/WatchApp/Widgets/Info.plist b/swift/WatchApp/Widgets/Info.plist index c53e2da..01600fa 100644 --- a/swift/WatchApp/Widgets/Info.plist +++ b/swift/WatchApp/Widgets/Info.plist @@ -24,8 +24,6 @@ NSExtensionPointIdentifier com.apple.widgetkit-extension - NSExtensionPrincipalClass - $(PRODUCT_MODULE_NAME).IDPGlobalWidgetsBundle