Apply Emil Kowalski design across all screens

Near-monochrome palette (ink tokens), black primary buttons, spring
animations, 0.5pt hairline dividers, shadow cards, squircle corners.
Replaces all blue/coloured accents. Adds ButtonStyles component.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Robin Kutesa
2026-05-16 00:43:31 +03:00
parent 848c1da820
commit 04e7a77c1a
14 changed files with 889 additions and 625 deletions

View File

@@ -0,0 +1,63 @@
import SwiftUI
// MARK: Primary (black fill)
struct PrimaryButtonStyle: ButtonStyle {
var color: Color = AppTheme.ink
var height: CGFloat = 50
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .medium))
.foregroundColor(.white)
.frame(maxWidth: .infinity)
.frame(height: height)
.background(color)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
}
}
// MARK: Ghost (outline)
struct GhostButtonStyle: ButtonStyle {
var height: CGFloat = 50
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 15, weight: .medium))
.foregroundColor(AppTheme.ink)
.frame(maxWidth: .infinity)
.frame(height: height)
.overlay(
RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous)
.stroke(AppTheme.cardBorderColor, lineWidth: 1)
)
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
}
}
// MARK: Scale (tap anywhere, just scales)
struct ScaleButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.97 : 1)
.animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed)
}
}
// MARK: Pill (compact, rounded)
struct PillButtonStyle: ButtonStyle {
var color: Color = AppTheme.ink
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.system(size: 13, weight: .medium))
.foregroundColor(.white)
.padding(.horizontal, 14)
.padding(.vertical, 7)
.background(color)
.clipShape(Capsule())
.scaleEffect(configuration.isPressed ? 0.96 : 1)
.animation(.spring(response: 0.2, dampingFraction: 0.7), value: configuration.isPressed)
}
}

View File

@@ -8,31 +8,37 @@ struct FolderRow: View {
var body: some View {
Button(action: onTap) {
HStack(spacing: 12) {
Image(systemName: item.isDirectory ? "folder.fill" : "doc.fill")
.font(.system(size: 18))
.foregroundColor(isSelected ? .white : (item.isDirectory ? AppTheme.blue : AppTheme.textSecondary))
Image(systemName: item.isDirectory ? "folder" : "doc")
.font(.system(size: 15, weight: .regular))
.foregroundStyle(isSelected ? .white : AppTheme.inkSecondary)
.frame(width: 22)
Text(item.name)
.font(AppTheme.bodyFont)
.fontWeight(isSelected ? .semibold : .regular)
.foregroundColor(isSelected ? .white : AppTheme.textPrimary)
.font(.system(size: 15, weight: isSelected ? .medium : .regular))
.foregroundStyle(isSelected ? .white : AppTheme.ink)
.lineLimit(1)
Spacer()
if isSelected {
Image(systemName: "checkmark")
.font(.system(size: 13, weight: .bold))
.foregroundColor(.white)
.font(.system(size: 12, weight: .semibold))
.foregroundStyle(.white)
} else if item.isDirectory {
Image(systemName: "chevron.right")
.font(.system(size: 13, weight: .medium))
.foregroundColor(AppTheme.textTertiary)
.font(.system(size: 12, weight: .medium))
.foregroundStyle(AppTheme.inkQuaternary)
}
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(isSelected ? AppTheme.blue : Color.clear)
.background(
isSelected
? AppTheme.ink
: Color.clear
)
.animation(.spring(response: 0.2, dampingFraction: 0.8), value: isSelected)
}
.buttonStyle(.plain)
.buttonStyle(PlainButtonStyle())
}
}

View File

@@ -2,24 +2,21 @@ import SwiftUI
struct ProgressRing: View {
let progress: Double
var lineWidth: CGFloat = 14
var lineWidth: CGFloat = 8
var size: CGFloat = 200
var color: Color = AppTheme.blue
var backgroundColor: Color = Color(hex: "#E5E7EB")
var color: Color = AppTheme.ink
var backgroundColor: Color = Color(hex: "#EBEBEB")
var body: some View {
ZStack {
Circle()
.stroke(backgroundColor, lineWidth: lineWidth)
.stroke(backgroundColor, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
Circle()
.trim(from: 0, to: CGFloat(min(progress, 1.0)))
.stroke(
color,
style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)
)
.stroke(color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round))
.rotationEffect(.degrees(-90))
.animation(.easeInOut(duration: 0.4), value: progress)
.animation(.spring(response: 0.5, dampingFraction: 0.8), value: progress)
}
.frame(width: size, height: size)
}

View File

@@ -9,18 +9,18 @@ struct ToggleRow: View {
var body: some View {
HStack(spacing: 12) {
Image(systemName: icon)
.font(.system(size: 16, weight: .medium))
.foregroundColor(AppTheme.blue)
.frame(width: 24)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(AppTheme.inkSecondary)
.frame(width: 20)
VStack(alignment: .leading, spacing: 2) {
VStack(alignment: .leading, spacing: 1) {
Text(title)
.font(AppTheme.bodyFont)
.foregroundColor(AppTheme.textPrimary)
.font(AppTheme.body())
.foregroundStyle(AppTheme.ink)
if let subtitle {
Text(subtitle)
.font(AppTheme.captionFont)
.foregroundColor(AppTheme.textSecondary)
.font(AppTheme.caption())
.foregroundStyle(AppTheme.inkTertiary)
}
}
@@ -28,8 +28,8 @@ struct ToggleRow: View {
Toggle("", isOn: $isOn)
.labelsHidden()
.tint(AppTheme.blue)
.tint(AppTheme.ink)
}
.padding(.vertical, 4)
.padding(.vertical, 2)
}
}

View File

@@ -1,38 +1,94 @@
import SwiftUI
enum AppTheme {
// Brand
static let blue = Color(hex: "#2563EB")
static let blueLight = Color(hex: "#3B82F6")
static let green = Color(hex: "#16A34A")
static let greenLight = Color(hex: "#22C55E")
static let red = Color(hex: "#DC2626")
static let orange = Color(hex: "#EA580C")
// MARK: Surfaces
static let background = Color.white
static let surfaceRaised = Color.white
static let surfaceSunken = Color(hex: "#F5F5F5")
// Backgrounds
static let background = Color.white
static let cardBackground = Color.white
static let cardBorder = Color(hex: "#E5E7EB")
// MARK: Ink
static let ink = Color(hex: "#0F0F0F")
static let inkSecondary = Color(hex: "#6B6B6B")
static let inkTertiary = Color(hex: "#ABABAB")
static let inkQuaternary = Color(hex: "#D4D4D4")
// Text
static let textPrimary = Color(hex: "#111827")
static let textSecondary = Color(hex: "#6B7280")
static let textTertiary = Color(hex: "#9CA3AF")
// MARK: Semantic
static let positive = Color(hex: "#16A34A")
static let destructive = Color(hex: "#DC2626")
static let interactive = Color(hex: "#2563EB")
// Status
static let connectedBorder = Color(hex: "#16A34A")
static let lanBorder = Color(hex: "#2563EB")
// MARK: Chrome
static let separator = Color.black.opacity(0.07)
static let cardShadow = Color.black.opacity(0.05)
static let cardBorderColor = Color.black.opacity(0.07)
// Typography
static let titleFont = Font.system(size: 28, weight: .bold, design: .default)
static let headlineFont = Font.system(size: 17, weight: .semibold)
static let bodyFont = Font.system(size: 15, weight: .regular)
static let captionFont = Font.system(size: 13, weight: .regular)
static let smallFont = Font.system(size: 11, weight: .medium)
// MARK: Metrics
static let radius: CGFloat = 14
static let radiusLarge: CGFloat = 18
static let hPad: CGFloat = 20
static let cardPad: CGFloat = 16
static let sectionGap: CGFloat = 28
static let cardCornerRadius: CGFloat = 12
static let cardPadding: CGFloat = 16
static let sectionSpacing: CGFloat = 24
// MARK: Typography
static func display(_ size: CGFloat = 32) -> Font {
.system(size: size, weight: .semibold, design: .default)
}
static func title(_ size: CGFloat = 20) -> Font {
.system(size: size, weight: .semibold)
}
static func headline(_ size: CGFloat = 15) -> Font {
.system(size: size, weight: .medium)
}
static func body(_ size: CGFloat = 15) -> Font {
.system(size: size, weight: .regular)
}
static func caption(_ size: CGFloat = 13) -> Font {
.system(size: size, weight: .regular)
}
static func micro(_ size: CGFloat = 11) -> Font {
.system(size: size, weight: .medium)
}
// MARK: Back-compat aliases used by existing call sites
static let blue = interactive
static let green = positive
static let red = destructive
static let textPrimary = ink
static let textSecondary = inkSecondary
static let textTertiary = inkTertiary
static let cardBorder = cardBorderColor
static let connectedBorder = positive
static let lanBorder = interactive
static let cardCornerRadius = radius
static let cardBackground = surfaceRaised
static let titleFont = Font.system(size: 28, weight: .bold)
static let headlineFont = Font.system(size: 17, weight: .semibold)
static let bodyFont = Font.system(size: 15, weight: .regular)
static let captionFont = Font.system(size: 13, weight: .regular)
static let smallFont = Font.system(size: 11, weight: .medium)
static let sectionSpacing: CGFloat = 28
}
// MARK: Card modifier
struct CardStyle: ViewModifier {
var padded: Bool = true
func body(content: Content) -> some View {
content
.if(padded) { $0.padding(AppTheme.cardPad) }
.background(AppTheme.surfaceRaised)
.clipShape(RoundedRectangle(cornerRadius: AppTheme.radius, style: .continuous))
.shadow(color: AppTheme.cardShadow, radius: 12, x: 0, y: 2)
}
}
extension View {
func card(padded: Bool = true) -> some View {
modifier(CardStyle(padded: padded))
}
@ViewBuilder func `if`<T: View>(_ condition: Bool, transform: (Self) -> T) -> some View {
if condition { transform(self) } else { self }
}
}
extension Color {
@@ -40,9 +96,10 @@ extension Color {
var h = hex.trimmingCharacters(in: .init(charactersIn: "#"))
if h.count == 3 { h = h.map { "\($0)\($0)" }.joined() }
let n = UInt64(h, radix: 16) ?? 0
let r = Double((n >> 16) & 0xFF) / 255
let g = Double((n >> 8) & 0xFF) / 255
let b = Double(n & 0xFF) / 255
self.init(red: r, green: g, blue: b)
self.init(
red: Double((n >> 16) & 0xFF) / 255,
green: Double((n >> 8) & 0xFF) / 255,
blue: Double( n & 0xFF) / 255
)
}
}