Files
scarf/scarf/Scarf iOS/Skills/SkillsView.swift
T
Alan Wizemann de611c5343 feat(ios): adopt ScarfDesign across the iOS app
AccentColor.colorset repointed to BrandRust hex (light + dark) so the
tab bar, every .tint, every default button, and every navigation
accent across all 5 tabs read rust automatically. Single-line fix,
biggest visible change.

ScarfDesign now imported across all 27 iOS view files. Color sweep
applies the same patterns as the Mac side, with two iOS-specific
deviations documented in CLAUDE.md:

- ScarfPageHeader is NOT retrofitted onto iOS tab roots. iOS uses
  .navigationTitle(...) + .navigationBarTitleDisplayMode(.large) as
  its native page-header pattern; stacking ScarfPageHeader on top
  creates double titles. ScarfPageHeader is reserved for sub-views
  without a native large-title bar.

- Only .borderedProminent → ScarfPrimaryButton. .bordered and .plain
  stay native because .bordered is the iOS convention for non-primary
  buttons and inherits rust through AccentColor automatically.

Dynamic Type policy (locked + documented in CLAUDE.md): preserve
.font(.headline)/.body/.caption semantic tokens for body copy, list
rows, error messages, and chat content (anything read for content).
Use ScarfFont only for status badges, chip labels, intentional fixed-
size display elements. Mass-swapping ScarfFont on iOS would regress
accessibility for users on .accessibility2 / .xSmall.

Files touched (27 view files + AccentColor + CLAUDE.md):

- Color sweep: .foregroundStyle(.secondary) → ScarfColor.foregroundMuted,
  Color(.secondarySystemBackground) → ScarfColor.backgroundSecondary,
  status colors (.orange/.green/.red) → ScarfColor.warning/success/danger
  in: Dashboard, Skills (root + Installed + Hub + Updates + Detail),
  Projects (root + Detail + Sessions + Site + 8 widgets), Memory
  (List + Editor), Cron, Settings (root + Editor), Servers, Chat
  (root + Picker + Slash browser), Onboarding.

- Primary button swap (5 files): Chat, Projects/Sessions, Skills/
  Updates, Skills/Hub, Onboarding.

- CLAUDE.md: appended "iOS Dynamic Type policy" + "iOS page chrome"
  guidance under the existing Design System section.

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

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-25 15:08:46 +02:00

173 lines
5.7 KiB
Swift

import SwiftUI
import ScarfCore
import ScarfDesign
/// iOS Skills tab 3-tab segmented surface mirroring the Mac
/// `SkillsView`. Owns one `SkillsViewModel` (ScarfCore-side, unified
/// in v2.5) shared across the three sub-tabs so installed-list state +
/// hub query/results + update results all live in one place.
///
/// Sub-tabs:
/// - **Installed**: category-grouped list. Tap a skill to view its
/// files, edit content, or uninstall.
/// - **Browse Hub**: search + source picker. Tap to install. Calls
/// remote `hermes skills search/browse` over SSH.
/// - **Updates**: check + update-all buttons. Calls remote
/// `hermes skills check / update --yes`.
struct SkillsView: View {
let config: IOSServerConfig
@State private var vm: SkillsViewModel
@State private var currentTab: Tab = .installed
/// v2.5 SkillSnapshotService diff against the per-server last-seen
/// snapshot. Drives the "What's New" pill at the top of the tab.
/// Nil before first compute or when there's nothing changed.
@State private var snapshotDiff: SkillSnapshotDiff?
private static let sharedContextID: ServerID = ServerID(
uuidString: "00000000-0000-0000-0000-0000000000A1"
)!
enum Tab: String, CaseIterable, Identifiable {
case installed = "Installed"
case hub = "Browse Hub"
case updates = "Updates"
var id: String { rawValue }
var displayName: String { rawValue }
}
init(config: IOSServerConfig) {
self.config = config
let ctx = config.toServerContext(id: Self.sharedContextID)
_vm = State(initialValue: SkillsViewModel(context: ctx))
}
var body: some View {
VStack(spacing: 0) {
// v2.5 "What's New" pill surfaced above the sub-tab
// picker when the per-server snapshot diff has changes.
// First-load with no prior snapshot silently primes (no
// pill, the snapshot just records what's there).
if let diff = snapshotDiff,
diff.hasChanges,
!diff.previousSnapshotEmpty {
whatsNewPill(diff: diff)
}
tabPicker
.padding(.horizontal)
.padding(.top, 8)
.padding(.bottom, 6)
statusBanner
Divider()
content
}
.navigationTitle(titleString)
.navigationBarTitleDisplayMode(.inline)
.task {
await vm.load()
recomputeSnapshotDiff()
}
.refreshable {
await vm.load()
recomputeSnapshotDiff()
}
}
/// Compute the snapshot diff against the per-server last-seen
/// state. First load with no prior snapshot silently primes
/// the pill never renders for users on day one.
private func recomputeSnapshotDiff() {
let allSkills = vm.categories.flatMap(\.skills)
let svc = SkillSnapshotService(serverID: Self.sharedContextID)
let diff = svc.diff(against: allSkills)
if diff.previousSnapshotEmpty {
svc.markSeen(allSkills)
snapshotDiff = nil
} else {
snapshotDiff = diff
}
}
/// "2 new, 4 updated since you last looked" pill at the top of
/// the tab. Tapping "Seen" persists the current set as the new
/// baseline + dismisses the pill.
@ViewBuilder
private func whatsNewPill(diff: SkillSnapshotDiff) -> some View {
HStack(spacing: 8) {
Image(systemName: "sparkles")
.foregroundStyle(.tint)
Text(diff.label)
.font(.callout)
.foregroundStyle(.primary)
Spacer()
Button("Seen") {
SkillSnapshotService(serverID: Self.sharedContextID)
.markSeen(vm.categories.flatMap(\.skills))
snapshotDiff = nil
}
.buttonStyle(.bordered)
.controlSize(.small)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
.background(.tint.opacity(0.1))
}
private var titleString: String {
vm.totalSkillCount > 0 ? "Skills (\(vm.totalSkillCount))" : "Skills"
}
@ViewBuilder
private var tabPicker: some View {
Picker("Section", selection: $currentTab) {
ForEach(Tab.allCases) { tab in
Text(tab.displayName).tag(tab)
}
}
.pickerStyle(.segmented)
}
@ViewBuilder
private var statusBanner: some View {
if let msg = vm.hubMessage {
HStack(spacing: 6) {
if vm.isHubLoading {
ProgressView()
.controlSize(.small)
}
Text(msg)
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.08))
} else if vm.isHubLoading {
HStack(spacing: 6) {
ProgressView()
.controlSize(.small)
Text("Working…")
.font(.caption)
.foregroundStyle(ScarfColor.foregroundMuted)
Spacer()
}
.padding(.horizontal, 16)
.padding(.vertical, 4)
.background(Color.secondary.opacity(0.08))
}
}
@ViewBuilder
private var content: some View {
switch currentTab {
case .installed:
InstalledSkillsListView(vm: vm)
case .hub:
HubBrowseView(vm: vm)
case .updates:
UpdatesView(vm: vm)
}
}
}