Files
swiftapp/swift/Sources/Core/Design/SIODesign.swift
Jürgen Kunz 549aaa634c Align Mail UI with social.io design handoff
Rewrite the Mail feature to match the Apple-native look from the
handoff spec: lane-split inbox, AI summary card, clean ThreadRow,
Cc/From + format toolbar in Compose. Drop the gradient hero
surfaces and blurred canvas backgrounds the spec calls out as
anti-patterns, and introduce a token-backed design layer so the
lane palette and SIO tint live in the asset catalog.

- Add Assets.xcassets with SIOTint, LaneFeed, LanePaper, LanePeople
  (light + dark variants).
- Add Sources/Core/Design/SIODesign.swift: SIO tokens, Lane enum,
  LaneChip, AvatarView, AISummaryCard, KeyboardHint, button styles,
  and a glass-chrome helper with iOS 26 / material fallback.
- Extend MailThread with lane + summary; custom Codable keeps old
  payloads decodable. Seed mock threads with sensible lanes and
  hand-write summaries on launch-copy, investor-update, roadmap-notes.
- Add lane filtering to AppViewModel (selectedLane, selectLane,
  laneUnreadCount, laneThreadCount).
- Rewrite MailRootView end to end: sidebar with Inbox/lane rows,
  lane filter strip, Apple-native ThreadRow (avatar, unread dot,
  lane chip, summary chip), ThreadReadingView with AI summary +
  floating reply pill, ComposeView with To/Cc/From/Subject and a
  format toolbar.
- Wire Assets.xcassets + SIODesign.swift into project.pbxproj.

Accessibility identifiers preserved byte-identical; new ones
(mailbox.lane.*, lane.chip.*) added only where new.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-19 16:22:10 +02:00

196 lines
6.1 KiB
Swift

import SwiftUI
enum SIO {
static let tint = Color("SIOTint")
static let laneFeed = Color("LaneFeed")
static let lanePaper = Color("LanePaper")
static let lanePeople = Color("LanePeople")
static let cardRadius: CGFloat = 14
static let controlRadius: CGFloat = 10
static let chipRadius: CGFloat = 6
static let bodyFontSize: CGFloat = 15.5
static let bodyLineSpacing: CGFloat = 15.5 * 0.55
}
enum Lane: String, CaseIterable, Codable, Hashable {
case feed
case paper
case people
var label: String {
switch self {
case .feed: "Feed"
case .paper: "Paper"
case .people: "People"
}
}
var color: Color {
switch self {
case .feed: SIO.laneFeed
case .paper: SIO.lanePaper
case .people: SIO.lanePeople
}
}
}
extension View {
@ViewBuilder
func sioGlassChrome<S: Shape>(in shape: S, tint: Color? = nil, interactive: Bool = false) -> some View {
if #available(iOS 26.0, macOS 26.0, *) {
self.glassEffect(
Glass.regular.tint(tint).interactive(interactive),
in: shape
)
} else {
self
.background(.ultraThinMaterial, in: shape)
.overlay(shape.stroke(Color.primary.opacity(0.08), lineWidth: 0.5))
}
}
@ViewBuilder
func sioGlassChromeContainer(spacing: CGFloat? = nil) -> some View {
if #available(iOS 26.0, macOS 26.0, *) {
GlassEffectContainer(spacing: spacing) { self }
} else {
self
}
}
}
struct LaneChip: View {
let lane: Lane
var compact: Bool = false
var body: some View {
Text(lane.label)
.font(.caption2.weight(.semibold))
.tracking(0.2)
.foregroundStyle(lane.color)
.padding(.horizontal, compact ? 6 : 8)
.padding(.vertical, compact ? 2 : 3)
.background(
lane.color.opacity(0.14),
in: RoundedRectangle(cornerRadius: SIO.chipRadius, style: .continuous)
)
}
}
struct AvatarView: View {
let name: String
let size: CGFloat
var tint: Color = SIO.tint
var initials: String {
let parts = name.split(separator: " ").prefix(2)
return parts.compactMap { $0.first.map(String.init) }.joined().uppercased()
}
var body: some View {
Text(initials)
.font(.system(size: size * 0.42, weight: .semibold))
.foregroundStyle(.white)
.frame(width: size, height: size)
.background(tint, in: Circle())
}
}
struct AISummaryCard: View {
let messageCount: Int
let bullets: [String]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
HStack(spacing: 6) {
Image(systemName: "sparkles")
.font(.caption2.weight(.bold))
Text("SUMMARY · \(messageCount) messages")
.font(.caption2.weight(.bold))
.tracking(0.6)
}
.foregroundStyle(SIO.tint)
ForEach(Array(bullets.enumerated()), id: \.offset) { _, line in
HStack(alignment: .firstTextBaseline, spacing: 8) {
Text("·").foregroundStyle(SIO.tint)
Text(line)
.font(.callout)
.foregroundStyle(.primary)
.fixedSize(horizontal: false, vertical: true)
}
}
}
.padding(14)
.frame(maxWidth: .infinity, alignment: .leading)
.background(SIO.tint.opacity(0.10), in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.stroke(SIO.tint.opacity(0.22), lineWidth: 0.5)
)
}
}
struct KeyboardHint: View {
let label: String
var body: some View {
Text(label)
.font(.system(size: 11, weight: .medium, design: .monospaced))
.foregroundStyle(.secondary)
.padding(.horizontal, 5)
.padding(.vertical, 1)
.background(.secondary.opacity(0.10), in: RoundedRectangle(cornerRadius: 4, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 4, style: .continuous)
.stroke(.secondary.opacity(0.20), lineWidth: 0.5)
)
}
}
struct PrimaryActionStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.headline)
.foregroundStyle(.white)
.padding(.horizontal, 16)
.padding(.vertical, 10)
.background(SIO.tint, in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
.opacity(configuration.isPressed ? 0.85 : 1)
}
}
struct SecondaryActionStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.subheadline.weight(.semibold))
.foregroundStyle(.primary)
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(.secondary.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
.opacity(configuration.isPressed ? 0.85 : 1)
}
}
struct DestructiveActionStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.font(.subheadline.weight(.semibold))
.foregroundStyle(.red)
.padding(.horizontal, 14)
.padding(.vertical, 9)
.background(Color.red.opacity(0.12), in: RoundedRectangle(cornerRadius: SIO.controlRadius, style: .continuous))
.opacity(configuration.isPressed ? 0.85 : 1)
}
}
extension MailPerson {
var avatarTint: Color {
let hash = email.unicodeScalars.reduce(0) { $0 &+ Int($1.value) }
let palette: [Color] = [SIO.laneFeed, SIO.lanePaper, SIO.lanePeople, SIO.tint, .purple, .pink, .teal]
return palette[abs(hash) % palette.count]
}
}