feat(ios): Mac-style page backgrounds + Dashboard + Chat redesign

iOS now uses ScarfColor.backgroundPrimary throughout instead of the
default iOS systemGroupedBackground. List-based screens add
.scrollContentBackground(.hidden) + the rust background underneath;
list rows use ScarfColor.backgroundSecondary as their card surface.
Applied to: Projects, Memory, Cron, Settings, Skills/Installed, and
the Servers root.

Dashboard rewritten in Mac-style cards (no more native iOS list):
- ScrollView + VStack with rust background
- Activity stat grid (2-col LazyVGrid) with bordered rust-tinted
  cards: Sessions / Messages / Tool Calls / Tokens (with in/out sub-
  label).
- Recent sessions card — bordered, ScarfColor.backgroundSecondary,
  inline session rows with 1px dividers, "See all" nav to Sessions
  sub-tab.
- Error banner styled with ScarfColor.warning tinted card per Mac.
- Sessions sub-tab keeps a List view but renders against rust
  background with ScarfColor.backgroundSecondary row backgrounds.

Chat redesigned to match the Mac chat reference:
- User bubble: rust accent fill with ScarfColor.onAccent text and
  uneven rounded corners (top/bottom-leading + top-trailing 14px;
  bottom-trailing 4px) — visually pinches to the sender side, same
  as Mac.
- Assistant bubble: rust gradient sparkles avatar tile (24x24)
  alongside a ScarfColor.backgroundSecondary bordered card.
- ToolCallCard: kind-tinted border + uppercase tracked label
  (READ/EDIT/EXECUTE/FETCH/BROWSER) using ScarfColor.success/info/
  warning/Tool.web/Tool.search; expanded JSON in a bordered
  ScarfColor.backgroundSecondary panel.
- ReasoningDisclosure: warning-tinted card with REASONING uppercase
  label.

Both Mac (scarf) and iOS (scarf mobile) schemes build green.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Alan Wizemann
2026-04-25 15:20:20 +02:00
parent de611c5343
commit 295f2dfefc
8 changed files with 302 additions and 132 deletions
+108 -33
View File
@@ -1068,14 +1068,28 @@ private struct MessageBubble: View {
if message.isUser {
Text(message.content)
.font(.body)
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(.white)
.background(Color.accentColor)
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.horizontal, 14)
.padding(.vertical, 10)
.foregroundStyle(ScarfColor.onAccent)
.background(
UnevenRoundedRectangle(cornerRadii:
.init(topLeading: 14, bottomLeading: 14, bottomTrailing: 4, topTrailing: 14))
.fill(ScarfColor.accent)
)
.textSelection(.enabled)
.contextMenu { messageContextMenu }
} else {
HStack(alignment: .top, spacing: 8) {
// Assistant avatar rust gradient sparkles tile,
// matches the Mac side and the ScarfChatView reference.
RoundedRectangle(cornerRadius: 7, style: .continuous)
.fill(ScarfGradient.brand)
.frame(width: 24, height: 24)
.overlay(
Image(systemName: "sparkles")
.foregroundStyle(.white)
.font(.system(size: 10, weight: .semibold))
)
VStack(alignment: .leading, spacing: 8) {
ForEach(Array(ChatContentFormatter.segments(for: message.content).enumerated()), id: \.offset) { _, segment in
switch segment {
@@ -1089,13 +1103,20 @@ private struct MessageBubble: View {
}
}
.padding(.horizontal, 12)
.padding(.vertical, 8)
.foregroundStyle(Color.primary)
.background(ScarfColor.backgroundSecondary)
.clipShape(RoundedRectangle(cornerRadius: 14))
.padding(.vertical, 10)
.foregroundStyle(ScarfColor.foregroundPrimary)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
.fill(ScarfColor.backgroundSecondary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.xl, style: .continuous)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
.contextMenu { messageContextMenu }
}
}
}
/// Shared context-menu actions for user + assistant bubbles.
/// Copy is the most-used action; Share hands off to the system
@@ -1206,64 +1227,118 @@ private struct ReasoningDisclosure: View {
.textSelection(.enabled)
.padding(.top, 4)
} label: {
Label("Thinking…", systemImage: "brain")
HStack(spacing: 5) {
Image(systemName: "brain")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
Text("REASONING")
.font(.caption2)
.fontWeight(.semibold)
.tracking(0.5)
}
.padding(.horizontal, 6)
.foregroundStyle(ScarfColor.warning)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(ScarfColor.warning.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1)
)
)
}
}
/// Expanding card for a single `HermesToolCall` shows function name
/// + summary collapsed; full JSON arguments expanded.
/// Expanding card for a single `HermesToolCall` kind-tinted with
/// uppercase tracked label, matches the Mac ToolCallCard treatment.
private struct ToolCallCard: View {
let call: HermesToolCall
@State private var isExpanded = false
var body: some View {
VStack(alignment: .leading, spacing: 4) {
VStack(alignment: .leading, spacing: 6) {
Button {
withAnimation(.easeInOut(duration: 0.15)) { isExpanded.toggle() }
} label: {
HStack(spacing: 6) {
Image(systemName: iconName)
.foregroundStyle(.tint)
HStack(spacing: 8) {
HStack(spacing: 4) {
Image(systemName: call.toolKind.icon)
.foregroundStyle(toolColor)
.font(.caption2)
Text(toolLabel)
.font(.caption2)
.fontWeight(.semibold)
.tracking(0.4)
.foregroundStyle(toolColor)
}
Text(call.functionName)
.font(.caption.monospaced())
.foregroundStyle(.primary)
.fontWeight(.semibold)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(call.argumentsSummary.prefix(60))
.font(.caption)
.font(.caption.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted)
.lineLimit(1)
Spacer()
.truncationMode(.middle)
Spacer(minLength: 4)
Image(systemName: isExpanded ? "chevron.up" : "chevron.down")
.font(.caption2)
.foregroundStyle(.tertiary)
.foregroundStyle(ScarfColor.foregroundFaint)
}
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(
RoundedRectangle(cornerRadius: 7)
.fill(toolColor.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: 7)
.strokeBorder(toolColor.opacity(0.30), lineWidth: 1)
)
)
}
.buttonStyle(.plain)
if isExpanded {
Text(call.arguments)
.font(.caption2.monospaced())
.foregroundStyle(ScarfColor.foregroundMuted)
.foregroundStyle(ScarfColor.foregroundPrimary)
.textSelection(.enabled)
.padding(.top, 2)
}
}
.padding(8)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 8)
.fill(Color(.tertiarySystemBackground))
)
RoundedRectangle(cornerRadius: 7)
.fill(ScarfColor.backgroundSecondary)
.overlay(
RoundedRectangle(cornerRadius: 8)
.strokeBorder(Color(.separator), lineWidth: 0.5)
RoundedRectangle(cornerRadius: 7)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
)
.padding(.leading, 4)
}
}
}
private var iconName: String {
call.toolKind.icon
private var toolLabel: String {
switch call.toolKind {
case .read: return "READ"
case .edit: return "EDIT"
case .execute: return "EXECUTE"
case .fetch: return "FETCH"
case .browser: return "BROWSER"
case .other: return "TOOL"
}
}
private var toolColor: Color {
switch call.toolKind {
case .read: return ScarfColor.success
case .edit: return ScarfColor.info
case .execute: return ScarfColor.warning
case .fetch: return ScarfColor.Tool.web
case .browser: return ScarfColor.Tool.search
case .other: return ScarfColor.foregroundMuted
}
}
}
+3 -1
View File
@@ -26,7 +26,7 @@ struct CronListView: View {
if let err = vm.lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.foregroundStyle(ScarfColor.warning)
}
}
@@ -62,6 +62,8 @@ struct CronListView: View {
}
}
.scarfGoListDensity()
.scrollContentBackground(.hidden)
.background(ScarfColor.backgroundPrimary)
.navigationTitle("Cron jobs")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
+142 -66
View File
@@ -3,10 +3,10 @@ import ScarfCore
import ScarfIOS
import ScarfDesign
/// iOS Dashboard shows session count, token usage, cost, and the
/// last 5 sessions pulled from the remote Hermes SQLite snapshot.
/// Every data source routes through `ServerContext CitadelServerTransport`
/// so the same services that drive the Mac Dashboard power this one.
/// iOS Dashboard adopts the Mac-style card layout (status row +
/// stats grid + recent-sessions card) instead of a native iOS list.
/// Sessions sub-tab keeps a List view for scrolling density but
/// renders against the rust page background.
struct DashboardView: View {
let config: IOSServerConfig
let key: SSHKeyBundle
@@ -16,17 +16,8 @@ struct DashboardView: View {
@State private var selectedSection: Section = .overview
@State private var sessionProjectFilter: String? = nil
/// Two top-level surfaces in the Dashboard. Overview = stats +
/// 5 most-recent sessions for glance. Sessions = the 25-session
/// deeper list with a project filter. Split added in pass-2 per
/// user feedback the old single-List layout grew too busy
/// once we started adding project badges, and users wanted a
/// way to slice by project.
enum Section: Hashable { case overview, sessions }
/// Stable ID used when building the `ServerContext` tied to the
/// config's host+user tuple so re-launching the app without reset
/// yields the same ID (important for the snapshot cache dir).
private static let contextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
)!
@@ -48,18 +39,18 @@ struct DashboardView: View {
Text("Sessions").tag(Section.sessions)
}
.pickerStyle(.segmented)
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 4)
.padding(.horizontal, ScarfSpace.s4)
.padding(.top, ScarfSpace.s2)
.padding(.bottom, ScarfSpace.s1)
Group {
switch selectedSection {
case .overview: overviewList
case .overview: overviewContent
case .sessions: sessionsList
}
}
}
.scarfGoListDensity()
.background(ScarfColor.backgroundPrimary.ignoresSafeArea())
.navigationTitle(config.displayName)
.navigationBarTitleDisplayMode(.large)
.refreshable { await vm.refresh() }
@@ -68,62 +59,159 @@ struct DashboardView: View {
ProgressView("Loading dashboard…")
.padding()
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 10))
.clipShape(RoundedRectangle(cornerRadius: ScarfRadius.lg))
}
}
.task { await vm.load() }
}
// MARK: - Overview
// MARK: - Overview (Mac-style cards)
@ViewBuilder
private var overviewList: some View {
List {
private var overviewContent: some View {
ScrollView {
VStack(alignment: .leading, spacing: ScarfSpace.s5) {
if let err = vm.lastError {
SwiftUI.Section {
VStack(alignment: .leading, spacing: 8) {
Label("Connection issue", systemImage: "exclamationmark.triangle.fill")
errorBanner(err)
}
statsSection
if !vm.recentSessions.isEmpty {
recentSessionsSection
}
}
.padding(.horizontal, ScarfSpace.s4)
.padding(.vertical, ScarfSpace.s4)
.frame(maxWidth: .infinity, alignment: .topLeading)
}
.background(ScarfColor.backgroundPrimary)
}
private func errorBanner(_ err: String) -> some View {
HStack(alignment: .top, spacing: ScarfSpace.s2) {
Image(systemName: "exclamationmark.triangle.fill")
.foregroundStyle(ScarfColor.warning)
VStack(alignment: .leading, spacing: 4) {
Text("Connection issue")
.font(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Text(err)
.font(.callout)
.foregroundStyle(ScarfColor.foregroundMuted)
.fixedSize(horizontal: false, vertical: true)
Button("Retry") {
Task { await vm.refresh() }
}
.buttonStyle(.bordered)
.padding(.top, 4)
}
Spacer(minLength: 0)
}
.padding(ScarfSpace.s3)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.fill(ScarfColor.warning.opacity(0.10))
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.strokeBorder(ScarfColor.warning.opacity(0.30), lineWidth: 1)
)
)
}
private var statsSection: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
Text("Activity")
.font(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
LazyVGrid(
columns: [GridItem(.flexible(), spacing: ScarfSpace.s3),
GridItem(.flexible(), spacing: ScarfSpace.s3)],
spacing: ScarfSpace.s3
) {
statCard(label: "Sessions", value: "\(vm.stats.totalSessions)")
statCard(label: "Messages", value: "\(vm.stats.totalMessages)")
statCard(label: "Tool Calls", value: "\(vm.stats.totalToolCalls)")
statCard(
label: "Tokens",
value: formatTokens(vm.stats.totalInputTokens + vm.stats.totalOutputTokens),
sub: tokenSub
)
}
.padding(.vertical, 4)
}
}
SwiftUI.Section("Activity") {
statRow("Total sessions", value: "\(vm.stats.totalSessions)")
statRow("Total messages", value: "\(vm.stats.totalMessages)")
statRow("Tool calls", value: "\(vm.stats.totalToolCalls)")
private var tokenSub: String? {
let inT = vm.stats.totalInputTokens
let outT = vm.stats.totalOutputTokens
guard inT + outT > 0 else { return nil }
return "\(formatTokens(inT)) in · \(formatTokens(outT)) out"
}
SwiftUI.Section("Tokens") {
statRow("Input", value: formatTokens(vm.stats.totalInputTokens))
statRow("Output", value: formatTokens(vm.stats.totalOutputTokens))
statRow("Reasoning", value: formatTokens(vm.stats.totalReasoningTokens))
private func statCard(label: String, value: String, sub: String? = nil) -> some View {
VStack(alignment: .leading, spacing: 4) {
Text(label)
.font(.caption)
.fontWeight(.semibold)
.textCase(.uppercase)
.tracking(0.5)
.foregroundStyle(ScarfColor.foregroundMuted)
Text(value)
.font(.system(size: 22, weight: .semibold, design: .monospaced))
.foregroundStyle(ScarfColor.foregroundPrimary)
.lineLimit(1)
.minimumScaleFactor(0.7)
if let sub {
Text(sub)
.font(.caption2)
.foregroundStyle(ScarfColor.foregroundFaint)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(ScarfSpace.s3)
.background(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.fill(ScarfColor.backgroundSecondary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
}
if !vm.recentSessions.isEmpty {
SwiftUI.Section {
ForEach(vm.recentSessions) { session in
sessionRow(session)
}
} header: {
private var recentSessionsSection: some View {
VStack(alignment: .leading, spacing: ScarfSpace.s2) {
HStack {
Text("Recent sessions")
.font(.headline)
.foregroundStyle(ScarfColor.foregroundPrimary)
Spacer()
Button("See all") { selectedSection = .sessions }
.font(.caption)
.textCase(nil)
.foregroundStyle(ScarfColor.accent)
.buttonStyle(.plain)
}
VStack(spacing: 0) {
ForEach(Array(vm.recentSessions.enumerated()), id: \.element.id) { idx, session in
sessionRow(session)
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, ScarfSpace.s2 + 2)
if idx < vm.recentSessions.count - 1 {
Rectangle()
.fill(ScarfColor.border)
.frame(height: 1)
}
}
}
.background(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.fill(ScarfColor.backgroundSecondary)
)
.overlay(
RoundedRectangle(cornerRadius: ScarfRadius.lg, style: .continuous)
.strokeBorder(ScarfColor.border, lineWidth: 1)
)
}
}
@@ -134,8 +222,8 @@ struct DashboardView: View {
VStack(spacing: 0) {
if !vm.allProjects.isEmpty {
filterBar
.padding(.horizontal, 12)
.padding(.bottom, 8)
.padding(.horizontal, ScarfSpace.s3)
.padding(.vertical, ScarfSpace.s2)
}
List {
@@ -149,20 +237,20 @@ struct DashboardView: View {
: "No sessions for that project yet. Try another filter or start a chat in that project.")
)
.listRowSeparator(.hidden)
.listRowBackground(Color.clear)
} else {
ForEach(filtered) { session in
sessionRow(session)
.listRowBackground(ScarfColor.backgroundSecondary)
}
}
}
.listStyle(.plain)
.scrollContentBackground(.hidden)
.background(ScarfColor.backgroundPrimary)
}
}
/// Project filter control rendered above the Sessions list. Uses
/// a Menu instead of a segmented Picker because there can be many
/// projects segments don't scale past 34 options on a phone.
/// Shows the active filter as the button label (tappable to
/// change); an explicit "All projects" entry clears the filter.
@ViewBuilder
private var filterBar: some View {
HStack {
@@ -191,16 +279,16 @@ struct DashboardView: View {
.font(.caption2)
}
.font(.caption)
.foregroundStyle(.tint)
.foregroundStyle(ScarfColor.accent)
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.tint.opacity(0.1), in: Capsule())
.background(ScarfColor.accentTint, in: Capsule())
}
Spacer()
}
}
// MARK: - Row helpers
// MARK: - Row helper
@ViewBuilder
private func sessionRow(_ session: HermesSession) -> some View {
@@ -211,7 +299,7 @@ struct DashboardView: View {
Text(session.displayTitle)
.font(.body)
.lineLimit(2)
.foregroundStyle(.primary)
.foregroundStyle(ScarfColor.foregroundPrimary)
HStack(spacing: 12) {
Label(session.source, systemImage: session.sourceIcon)
.font(.caption)
@@ -230,11 +318,11 @@ struct DashboardView: View {
if let projectName = vm.projectName(for: session) {
Label(projectName, systemImage: "folder.fill")
.font(.caption2)
.foregroundStyle(.tint)
.foregroundStyle(ScarfColor.accent)
.labelStyle(.titleAndIcon)
.padding(.vertical, 2)
.padding(.horizontal, 6)
.background(.tint.opacity(0.12), in: Capsule())
.background(ScarfColor.accentTint, in: Capsule())
}
}
.padding(.vertical, 2)
@@ -244,18 +332,6 @@ struct DashboardView: View {
.buttonStyle(.plain)
}
@ViewBuilder
private func statRow(_ label: String, value: String) -> some View {
LabeledContent(label) {
Text(value)
.monospacedDigit()
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
/// Mirror of `ScarfCore.formatTokens` inlined here rather than
/// exported from ScarfCore because it's currently wrapped in
/// `#if canImport(SQLite3)` (from the M0d InsightsViewModel move).
private func formatTokens(_ count: Int) -> String {
if count >= 1_000_000 {
return String(format: "%.1fM", Double(count) / 1_000_000)
@@ -24,16 +24,22 @@ struct MemoryListView: View {
Section {
memoryRow(.memory, context: ctx)
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
memoryRow(.user, context: ctx)
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
memoryRow(.soul, context: ctx)
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
} footer: {
Text("MEMORY.md and USER.md live under `~/.hermes/memories/`. SOUL.md lives at `~/.hermes/SOUL.md`.")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
}
}
.scarfGoListDensity()
.scrollContentBackground(.hidden)
.background(ScarfColor.backgroundPrimary)
.navigationTitle("Memory")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
@@ -67,6 +67,7 @@ struct ProjectsListView: View {
Section {
ForEach(topLevel) { project in
projectRow(project)
.listRowBackground(ScarfColor.backgroundSecondary)
}
}
}
@@ -74,11 +75,14 @@ struct ProjectsListView: View {
Section(folder) {
ForEach(visibleProjects.filter { $0.folder == folder }) { project in
projectRow(project)
.listRowBackground(ScarfColor.backgroundSecondary)
}
}
}
}
.scarfGoListDensity()
.scrollContentBackground(.hidden)
.background(ScarfColor.backgroundPrimary)
}
private func projectRow(_ project: ProjectEntry) -> some View {
+3 -1
View File
@@ -24,7 +24,7 @@ struct ServerListView: View {
Section {
VStack(alignment: .leading, spacing: 8) {
Label("Something went wrong", systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.foregroundStyle(ScarfColor.warning)
.font(.headline)
Text(err)
.font(.callout)
@@ -60,6 +60,8 @@ struct ServerListView: View {
}
}
.scarfGoListDensity()
.scrollContentBackground(.hidden)
.background(ScarfColor.backgroundPrimary)
.navigationTitle("Servers")
.navigationBarTitleDisplayMode(.inline)
.toolbar {
+3 -1
View File
@@ -29,7 +29,7 @@ struct SettingsView: View {
if let err = vm.lastError {
Section {
Label(err, systemImage: "exclamationmark.triangle.fill")
.foregroundStyle(.orange)
.foregroundStyle(ScarfColor.warning)
}
}
@@ -49,6 +49,8 @@ struct SettingsView: View {
}
}
.scarfGoListDensity()
.scrollContentBackground(.hidden)
.background(ScarfColor.backgroundPrimary)
.navigationTitle("Settings")
.navigationBarTitleDisplayMode(.inline)
.refreshable { await vm.load() }
@@ -45,11 +45,14 @@ struct InstalledSkillsListView: View {
}
}
.scarfGoCompactListRow()
.listRowBackground(ScarfColor.backgroundSecondary)
}
}
}
}
.scarfGoListDensity()
.scrollContentBackground(.hidden)
.background(ScarfColor.backgroundPrimary)
.searchable(text: $vm.searchText, placement: .navigationBarDrawer(displayMode: .always))
}
}