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>
This commit is contained in:
2026-04-19 16:22:10 +02:00
parent 15af566353
commit 549aaa634c
12 changed files with 1129 additions and 874 deletions

View File

@@ -0,0 +1,6 @@
{
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.420",
"red" : "0.184"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.561",
"red" : "0.369"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.039",
"green" : "0.624",
"red" : "1.000"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.251",
"green" : "0.702",
"red" : "1.000"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.345",
"green" : "0.820",
"red" : "0.188"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "0.447",
"green" : "0.847",
"red" : "0.392"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -0,0 +1,38 @@
{
"colors" : [
{
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.420",
"red" : "0.184"
}
},
"idiom" : "universal"
},
{
"appearances" : [
{
"appearance" : "luminosity",
"value" : "dark"
}
],
"color" : {
"color-space" : "srgb",
"components" : {
"alpha" : "1.000",
"blue" : "1.000",
"green" : "0.561",
"red" : "0.369"
}
},
"idiom" : "universal"
}
],
"info" : {
"author" : "xcode",
"version" : 1
}
}

View File

@@ -16,6 +16,8 @@
A10000000000000000000007 /* AppControlService.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000008 /* AppControlService.swift */; };
A10000000000000000000008 /* AppNavigationCommandTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000009 /* AppNavigationCommandTests.swift */; };
A10000000000000000000009 /* AppViewModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A2000000000000000000000A /* AppViewModelTests.swift */; };
A10000000000000000000010 /* SIODesign.swift in Sources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000020 /* SIODesign.swift */; };
A10000000000000000000011 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = A20000000000000000000021 /* Assets.xcassets */; };
/* End PBXBuildFile section */
/* Begin PBXContainerItemProxy section */
@@ -40,6 +42,8 @@
A20000000000000000000009 /* AppNavigationCommandTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppNavigationCommandTests.swift; sourceTree = "<group>"; };
A2000000000000000000000A /* AppViewModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppViewModelTests.swift; sourceTree = "<group>"; };
A2000000000000000000000B /* SocialIOTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SocialIOTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
A20000000000000000000020 /* SIODesign.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SIODesign.swift; sourceTree = "<group>"; };
A20000000000000000000021 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
/* End PBXFileReference section */
/* Begin PBXFrameworksBuildPhase section */
@@ -73,6 +77,7 @@
children = (
A40000000000000000000003 /* Sources */,
A4000000000000000000000B /* Tests */,
A20000000000000000000021 /* Assets.xcassets */,
);
name = SocialIO;
sourceTree = "<group>";
@@ -103,10 +108,19 @@
children = (
A40000000000000000000006 /* Models */,
A40000000000000000000007 /* Services */,
A40000000000000000000020 /* Design */,
);
path = Core;
sourceTree = "<group>";
};
A40000000000000000000020 /* Design */ = {
isa = PBXGroup;
children = (
A20000000000000000000020 /* SIODesign.swift */,
);
path = Design;
sourceTree = "<group>";
};
A40000000000000000000006 /* Models */ = {
isa = PBXGroup;
children = (
@@ -238,6 +252,7 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
A10000000000000000000011 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
@@ -262,6 +277,7 @@
A10000000000000000000001 /* SocialIOApp.swift in Sources */,
A10000000000000000000006 /* AppNavigationCommand.swift in Sources */,
A10000000000000000000007 /* AppControlService.swift in Sources */,
A10000000000000000000010 /* SIODesign.swift in Sources */,
);
runOnlyForDeploymentPostprocessing = 0;
};

View File

@@ -5,6 +5,7 @@ import Observation
@Observable
final class AppViewModel {
var selectedMailbox: Mailbox = .inbox
var selectedLane: Lane?
var selectedThreadID: MailThread.ID?
var focusedMessageRouteID: String?
var searchText = ""
@@ -44,6 +45,10 @@ final class AppViewModel {
.filter { thread in
selectedMailbox == .starred ? thread.isStarred : thread.mailbox == selectedMailbox
}
.filter { thread in
guard let lane = selectedLane else { return true }
return thread.lane == lane
}
.filter { thread in
!showUnreadOnly || thread.isUnread
}
@@ -51,6 +56,27 @@ final class AppViewModel {
.sorted { $0.lastUpdated > $1.lastUpdated }
}
func laneUnreadCount(_ lane: Lane) -> Int {
threads.filter { thread in
let inCurrentMailbox = selectedMailbox == .starred
? thread.isStarred
: thread.mailbox == selectedMailbox
return inCurrentMailbox && thread.lane == lane && thread.isUnread
}.count
}
func laneThreadCount(_ lane: Lane) -> Int {
threads.filter { thread in
thread.mailbox == .inbox && thread.lane == lane
}.count
}
func selectLane(_ lane: Lane?) {
selectedLane = lane
clearThreadSelection()
mailboxNavigationToken = UUID()
}
var totalUnreadCount: Int {
threads.filter(\.isUnread).count
}

View File

@@ -7,7 +7,7 @@ struct SocialIOApp: App {
var body: some Scene {
WindowGroup {
MailRootView(model: model)
.tint(MailTheme.accent)
.tint(SIO.tint)
.onOpenURL { url in
model.apply(url: url)
}

View File

@@ -0,0 +1,195 @@
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]
}
}

View File

@@ -80,6 +80,8 @@ struct MailThread: Identifiable, Hashable, Codable {
var isUnread: Bool
var isStarred: Bool
var tags: [String]
var lane: Lane
var summary: [String]?
init(
id: UUID = UUID(),
@@ -90,7 +92,9 @@ struct MailThread: Identifiable, Hashable, Codable {
messages: [MailMessage],
isUnread: Bool,
isStarred: Bool,
tags: [String] = []
tags: [String] = [],
lane: Lane = .people,
summary: [String]? = nil
) {
self.id = id
self.routeID = routeID
@@ -101,6 +105,29 @@ struct MailThread: Identifiable, Hashable, Codable {
self.isUnread = isUnread
self.isStarred = isStarred
self.tags = tags
self.lane = lane
self.summary = summary
}
enum CodingKeys: String, CodingKey {
case id, routeID, mailbox, subject, participants, messages
case isUnread, isStarred, tags, lane, summary
}
init(from decoder: Decoder) throws {
let c = try decoder.container(keyedBy: CodingKeys.self)
self.id = try c.decode(UUID.self, forKey: .id)
self.routeID = try c.decode(String.self, forKey: .routeID)
self.mailbox = try c.decode(Mailbox.self, forKey: .mailbox)
self.subject = try c.decode(String.self, forKey: .subject)
self.participants = try c.decode([MailPerson].self, forKey: .participants)
let rawMessages = try c.decode([MailMessage].self, forKey: .messages)
self.messages = rawMessages.sorted { $0.sentAt < $1.sentAt }
self.isUnread = try c.decode(Bool.self, forKey: .isUnread)
self.isStarred = try c.decode(Bool.self, forKey: .isStarred)
self.tags = try c.decodeIfPresent([String].self, forKey: .tags) ?? []
self.lane = try c.decodeIfPresent(Lane.self, forKey: .lane) ?? .people
self.summary = try c.decodeIfPresent([String].self, forKey: .summary)
}
var latestMessage: MailMessage? {

View File

@@ -82,7 +82,13 @@ struct MockMailService: MailServicing {
],
isUnread: true,
isStarred: true,
tags: ["Design", "Launch"]
tags: ["Design", "Launch"],
lane: .people,
summary: [
"Tanya tightened the onboarding copy and softened the empty-state tone.",
"Phil signed off with a note to keep playful language on iPhone.",
"Desktop first-run copy still needs a lighter pass before handoff."
]
),
MailThread(
routeID: "daily-sync-status",
@@ -100,7 +106,8 @@ struct MockMailService: MailServicing {
],
isUnread: false,
isStarred: false,
tags: ["System"]
tags: ["System"],
lane: .feed
),
MailThread(
routeID: "investor-update",
@@ -118,7 +125,13 @@ struct MockMailService: MailServicing {
],
isUnread: true,
isStarred: false,
tags: ["External"]
tags: ["External"],
lane: .paper,
summary: [
"Mina wants a concise product update before Friday.",
"She's specifically asking for screenshots of the new mail experience.",
"Interested in how we differentiate from commodity inboxes."
]
),
MailThread(
routeID: "search-ranking-polish",
@@ -143,7 +156,8 @@ struct MockMailService: MailServicing {
],
isUnread: false,
isStarred: false,
tags: ["Search"]
tags: ["Search"],
lane: .people
),
MailThread(
routeID: "welcome-to-socialio",
@@ -162,7 +176,8 @@ struct MockMailService: MailServicing {
],
isUnread: false,
isStarred: false,
tags: ["Draft"]
tags: ["Draft"],
lane: .people
),
MailThread(
routeID: "roadmap-notes",
@@ -180,7 +195,13 @@ struct MockMailService: MailServicing {
],
isUnread: false,
isStarred: true,
tags: ["Product"]
tags: ["Product"],
lane: .paper,
summary: [
"Three roadmap themes: faster triage, identity-rich threads, calmer notifications.",
"Raw notes were cleaned up and archived by Nora.",
"Owner split still needs to be reconciled with eng capacity."
]
)
]
}

File diff suppressed because it is too large Load Diff