Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/Theme.swift
T
NucleicandClaude Opus 4.8 19c5e20503 Recolor Orchestra gold and make the composer glow more aggressive
Swaps the Orchestra accent from violet to a warm gold (AppTheme.orchestra +
iOS Palette.orchestra), so the composer glow, effort label, and subagent cards all
read gold. Tunes the glow harder — a ~0.9s pulse (was 1.8s) with a thicker stroke
and a bigger outer bloom that swells on the beat — so an Orchestra turn announces
itself. Reduce Motion still holds the glow steady. Against a Control project's
lavender accent, the gold glow stands out cleanly.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
2026-06-21 01:12:24 +00:00

213 lines
9.3 KiB
Swift

import SwiftUI
import NucleicProtocol
/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): a deep
/// teal accent, the same categorical status palette, and the same status→color mapping
/// refined by turn disposition — so the two devices read identically. Pure SwiftUI Color
/// (no AppKit), adapting to light/dark via the system.
enum Palette {
static let accent = Color(red: 0.04, green: 0.52, blue: 0.50) // deep teal
static let active = Color(red: 0.20, green: 0.55, blue: 0.95) // blue
static let attention = Color(red: 1.00, green: 0.65, blue: 0.15) // amber
static let success = Color(red: 0.30, green: 0.80, blue: 0.45) // green
static let paused = Color(red: 0.62, green: 0.49, blue: 0.93) // purple
static let danger = Color(red: 0.92, green: 0.34, blue: 0.34) // red
static let neutral = Color.secondary
/// The orchestra (orchestration mode) signature gold — mirrors the Mac's
/// `AppTheme.orchestra`. Used for the effort picker's Orchestra option and the
/// composer's golden ring.
static let orchestra = Color(red: 0.90, green: 0.66, blue: 0.16)
/// Dot/accent color for a session's status, refined by the last turn's disposition — a
/// finished-the-work turn reads as "done" (success) rather than the calm "ready" accent.
static func status(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> Color {
if status == .awaitingInput, disposition == .completed { return success }
switch status {
case .idle, .provisioning: return neutral
case .running: return active
case .awaitingApproval: return attention
case .awaitingInput: return accent
case .finished: return success
case .interrupted: return paused
case .error: return danger
}
}
static func activity(level: Int) -> Color {
switch level {
case 0: Color.secondary.opacity(0.15)
case 1: success.opacity(0.4)
case 2: success.opacity(0.7)
default: success
}
}
}
/// Status glyph + label, matching the Mac semantics (UX_MACOS §1).
enum StatusStyle {
static func symbol(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> String {
if status == .awaitingInput, disposition == .completed { return "checkmark.circle.fill" }
switch status {
case .idle: return "circle"
case .provisioning: return "circle.dotted"
case .running: return "circle.lefthalf.filled"
case .awaitingApproval: return "exclamationmark.triangle.fill"
case .awaitingInput: return "bubble.left.fill"
case .finished: return "checkmark.circle.fill"
case .interrupted: return "stop.circle"
case .error: return "xmark.octagon.fill"
}
}
static func label(_ status: SessionStatus, disposition: TurnDisposition? = nil) -> String {
if status == .awaitingInput, disposition == .completed { return "Done" }
return status.displayName
}
/// Attention-first ordering for the session list (UX_IOS §3).
static func sortRank(_ summary: WireSessionSummary) -> Int {
switch summary.status {
case .awaitingApproval: return 0
case .awaitingInput: return summary.disposition == .completed ? 4 : 1
case .running, .provisioning: return 2
case .idle: return 3
case .finished, .interrupted, .error: return 5
}
}
}
extension SessionStatus {
/// "Needs you" vs background work, mirroring the Mac sidebar's disposition refinement.
func needsYou(_ disposition: TurnDisposition?) -> Bool {
switch self {
case .awaitingApproval: return true
case .awaitingInput: return disposition != .completed
default: return false
}
}
}
extension Risk {
var isHigh: Bool { self == .destructive || self == .network || self == .hostExec }
var label: String { self == .hostExec ? "host machine" : rawValue }
var color: Color {
switch self {
case .destructive, .network, .hostExec: return Palette.danger
case .execute: return Palette.attention
case .write: return Color(red: 0.85, green: 0.75, blue: 0.2)
case .readOnly, .unknown: return .secondary
}
}
}
/// A lifted card surface with a hairline border — the mobile echo of `AppTheme.surface`.
struct CardBackground: ViewModifier {
var padding: CGFloat = 14
func body(content: Content) -> some View {
content
.padding(padding)
.background(Color(.secondarySystemBackground), in: RoundedRectangle(cornerRadius: 14))
.overlay(
RoundedRectangle(cornerRadius: 14)
.strokeBorder(Color.primary.opacity(0.06), lineWidth: 1))
}
}
extension View {
func card(padding: CGFloat = 14) -> some View { modifier(CardBackground(padding: padding)) }
/// Apply the teal accent app-wide.
func nucleicTint() -> some View { tint(Palette.accent) }
/// Wrap a composer field in the orchestra purple ring while `active` (mirrors the Mac's
/// animated composer glow, lighter touch).
func orchestraGlow(active: Bool, cornerRadius: CGFloat = 8) -> some View {
modifier(OrchestraGlowModifier(active: active, cornerRadius: cornerRadius))
}
}
/// The effort levels the remote offers, mirroring the Mac's `ModelCatalog.efforts` plus
/// orchestra. `orchestraSentinel` MUST stay equal to the host's `OrchestrationMode.effortSentinel`
/// — it's sent verbatim over the sync wire and resolved on the host (to `xhigh` + standing
/// consent), so a mismatch would silently disable the mode.
enum MobileEfforts {
static let levels = ["low", "medium", "high", "xhigh", "max"]
static let orchestraSentinel = "orchestra"
static let fallback = "high"
static func isOrchestra(_ effort: String) -> Bool { effort == orchestraSentinel }
static func displayName(_ effort: String) -> String { isOrchestra(effort) ? "Orchestra" : effort }
}
/// A compact effort selector for the remote composers. Orchestra sits below the API levels,
/// set apart with a sparkles glyph and the purple accent. Orchestra is a Nucleic Control
/// capability, so it's only selectable when the target project is under Control — disabled and
/// labeled otherwise.
struct EffortMenu: View {
@Binding var effort: String
/// Whether the target project is under Nucleic Control (gates Orchestra).
var controlled: Bool = false
/// Orchestra only counts as active where it's permitted; a stray selection on a non-Control
/// project reads as its underlying level so the trigger never shows Orchestra where it can't run.
private var orchestraActive: Bool { controlled && MobileEfforts.isOrchestra(effort) }
private var triggerEffort: String {
orchestraActive ? effort : (MobileEfforts.isOrchestra(effort) ? MobileEfforts.fallback : effort)
}
var body: some View {
Menu {
ForEach(MobileEfforts.levels, id: \.self) { level in
Button { effort = level } label: {
if effort == level { Label(level, systemImage: "checkmark") } else { Text(level) }
}
}
Divider()
Button { effort = MobileEfforts.orchestraSentinel } label: {
Label(
controlled ? "Orchestra" : "Orchestra — Requires Nucleic Control",
systemImage: orchestraActive ? "checkmark" : "sparkles")
}
.disabled(!controlled)
} label: {
HStack(spacing: 4) {
Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3")
Text(MobileEfforts.displayName(triggerEffort))
}
.font(.caption)
.foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent)
}
}
}
/// A bright golden ring that throbs around a composer while orchestra is selected — the
/// remote echo of the Mac's `OrchestraGlow`, tuned to the same fast, high-amplitude pulse.
/// Honors Reduce Motion (holds steady).
struct OrchestraGlowModifier: ViewModifier {
var active: Bool
var cornerRadius: CGFloat
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var pulsing = false
func body(content: Content) -> some View {
let animating = active && pulsing && !reduceMotion
return content
.overlay {
if active {
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(Palette.orchestra.opacity(animating ? 0.95 : 0.5),
lineWidth: animating ? 2.2 : 1.3)
.shadow(color: Palette.orchestra.opacity(animating ? 0.7 : 0.22),
radius: animating ? 13 : 4)
.shadow(color: Palette.orchestra.opacity(animating ? 0.4 : 0.06),
radius: animating ? 20 : 4)
.animation(
reduceMotion ? .default
: .easeInOut(duration: 0.9).repeatForever(autoreverses: true),
value: pulsing)
.allowsHitTesting(false)
}
}
.onAppear { pulsing = active }
.onChange(of: active) { _, now in pulsing = now }
}
}