Files
nucleic-remote-ios/NucleicRemote/NucleicRemote/Views/Theme.swift
T
abkslmandNucleic b8240aca7b Modify Model Picker Label
Nucleic-Session: 3854DB9E-8136-4DF7-95E7-BBFC7E615174
Co-authored-by: Nucleic <[email protected]>
2026-07-05 18:49:29 -07:00

592 lines
27 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import SwiftUI
import NucleicProtocol
/// The user's preferred appearance (Settings → Appearance). `system` follows iOS.
/// Same storage key and semantics as the Mac's `AppAppearance`.
enum AppAppearance: String, CaseIterable, Identifiable {
case system, light, dark
var id: String { rawValue }
static let storageKey = "nucleic.appearance"
var label: String {
switch self {
case .system: "System"
case .light: "Light"
case .dark: "Dark"
}
}
/// `nil` means follow the system setting.
var colorScheme: ColorScheme? {
switch self {
case .system: nil
case .light: .light
case .dark: .dark
}
}
}
/// User-selectable global text size (Settings → Appearance), applied app-wide via
/// `.dynamicTypeSize` so the semantic fonts the UI uses scale together. Mirrors the Mac's
/// `AppTextSize` (same storage key and steps).
enum AppTextSize: String, CaseIterable, Identifiable {
case small, medium, large, xLarge, xxLarge
var id: String { rawValue }
static let storageKey = "nucleic.textSize"
var label: String {
switch self {
case .small: "Small"
case .medium: "Default"
case .large: "Large"
case .xLarge: "Larger"
case .xxLarge: "Largest"
}
}
/// `.large` is the system default content size, so "Default" maps to it.
var dynamicTypeSize: DynamicTypeSize {
switch self {
case .small: .small
case .medium: .large
case .large: .xLarge
case .xLarge: .xxLarge
case .xxLarge: .xxxLarge
}
}
}
/// Color-vision mode. Remaps the categorical palette (status dots, activity grid, accent) to
/// hues that stay distinguishable under the common color-vision deficiencies — the same
/// palettes as the Mac's `ColorVisionMode`/`AppPalette`, same storage key, so both devices
/// read identically for the same user.
enum ColorVisionMode: String, CaseIterable, Identifiable {
case standard, deuteranopia, protanopia, tritanopia
var id: String { rawValue }
static let storageKey = "nucleic.colorVision"
var label: String {
switch self {
case .standard: "Standard"
case .deuteranopia: "Deuteranopia (red–green)"
case .protanopia: "Protanopia (red–green)"
case .tritanopia: "Tritanopia (blue–yellow)"
}
}
/// The persisted mode (Settings → Appearance).
static var stored: ColorVisionMode {
ColorVisionMode(
rawValue: UserDefaults.standard.string(forKey: storageKey) ?? "") ?? .standard
}
}
/// A resolved set of semantic colors for one `ColorVisionMode` — the mobile mirror of the
/// Mac's `AppPalette`, including its night-softening: vivid in light mode, desaturated and
/// dimmed in dark so status accents read calmly instead of glowing.
struct ResolvedPalette {
var accent: Color
var neutral: Color
var active: Color
var attention: Color
var success: Color
var paused: Color
var danger: Color
var frozen: Color
var activityBase: Color
/// The vivid, light-mode categorical colors (identical constants to the Mac's
/// `AppPalette.base`); `make` night-softens them for dark mode.
private static func base(_ mode: ColorVisionMode) -> ResolvedPalette {
switch mode {
case .standard:
ResolvedPalette(
accent: Color(red: 0.04, green: 0.52, blue: 0.50), // deep teal
neutral: .secondary,
active: Color(red: 0.20, green: 0.55, blue: 0.95), // blue
attention: Color(red: 1.00, green: 0.65, blue: 0.15), // amber
success: Color(red: 0.30, green: 0.80, blue: 0.45), // green
paused: Color(red: 0.62, green: 0.49, blue: 0.93), // purple
danger: Color(red: 0.92, green: 0.34, blue: 0.34), // red
frozen: Color(red: 0.26, green: 0.64, blue: 0.96), // cold "iced" blue
activityBase: Color(red: 0.30, green: 0.80, blue: 0.45))
case .deuteranopia, .protanopia:
// Okabe–Ito-derived: avoids relying on red↔green separation.
ResolvedPalette(
accent: Color(red: 0.00, green: 0.50, blue: 0.58), // deep teal
neutral: .secondary,
active: Color(red: 0.00, green: 0.45, blue: 0.70), // blue
attention: Color(red: 0.90, green: 0.62, blue: 0.00), // orange
success: Color(red: 0.34, green: 0.71, blue: 0.91), // sky blue
paused: Color(red: 0.80, green: 0.47, blue: 0.65), // reddish purple
danger: Color(red: 0.84, green: 0.37, blue: 0.00), // vermillion
frozen: Color(red: 0.20, green: 0.58, blue: 0.86), // cool sky blue
activityBase: Color(red: 0.00, green: 0.45, blue: 0.70))
case .tritanopia:
// Blue↔yellow weak; red↔green is intact, so lean on those plus magenta.
ResolvedPalette(
accent: Color(red: 0.46, green: 0.33, blue: 0.78), // indigo-violet
neutral: .secondary,
active: Color(red: 0.00, green: 0.62, blue: 0.45), // green-teal
attention: Color(red: 0.85, green: 0.20, blue: 0.46), // magenta
success: Color(red: 0.00, green: 0.45, blue: 0.70), // blue
paused: Color(red: 0.67, green: 0.36, blue: 0.66), // purple
danger: Color(red: 0.79, green: 0.10, blue: 0.15), // red
frozen: Color(red: 0.30, green: 0.72, blue: 0.85), // light cyan
activityBase: Color(red: 0.00, green: 0.45, blue: 0.70))
}
}
static func make(_ mode: ColorVisionMode) -> ResolvedPalette {
var p = base(mode)
// The accent is the interactive tint (buttons, menus, toggles, links), not a calm
// status hue — night-softening the already-deep teal made it vanish on dark
// backgrounds, so dark mode brightens it instead.
p.accent = Palette.nightBright(p.accent)
p.active = Palette.nightSoft(p.active)
p.attention = Palette.nightSoft(p.attention)
p.success = Palette.nightSoft(p.success)
p.paused = Palette.nightSoft(p.paused)
p.danger = Palette.nightSoft(p.danger)
p.frozen = Palette.nightSoft(p.frozen)
p.activityBase = Palette.nightSoft(p.activityBase)
return p
}
}
/// The mobile mirror of the Mac's design language (AppTheme/AppPalette on desktop): the same
/// categorical status palette per color-vision mode, night-softened in dark mode, and the
/// same status→color mapping refined by turn disposition — so the two devices read
/// identically. The accessors resolve against the persisted `ColorVisionMode` on each read;
/// the resolved palettes themselves are built once.
enum Palette {
private static let resolved: [ColorVisionMode: ResolvedPalette] = Dictionary(
uniqueKeysWithValues: ColorVisionMode.allCases.map { ($0, ResolvedPalette.make($0)) })
static var current: ResolvedPalette { resolved[ColorVisionMode.stored] ?? resolved[.standard]! }
static var accent: Color { current.accent }
static var active: Color { current.active }
static var attention: Color { current.attention }
static var success: Color { current.success }
static var paused: Color { current.paused }
static var danger: Color { current.danger }
/// Cold "iced" blue for a streak-frozen day in the activity grid — mirrors the Mac's
/// `AppPalette.frozen`.
static var frozen: Color { current.frozen }
static let neutral = Color.secondary
/// The orchestra (orchestration mode) signature gold — the exact appearance-aware pair the
/// Mac's `AppTheme.orchestra` uses: a rich amber-gold in light (dark enough to read on
/// white), a bright warm gold in dark so it glows without burning. A brand color, not a
/// categorical status, so it doesn't remap with color-vision mode.
static let orchestra = dynamic(
light: UIColor(red: 0.80, green: 0.57, blue: 0.09, alpha: 1),
dark: UIColor(red: 1.0, green: 0.82, blue: 0.34, alpha: 1))
/// Primary prose color for chat text — the Mac's `AppTheme.primaryText`: a soft off-white
/// on the deep dark background (reads calmly at night) and a near-black in light.
static let primaryText = dynamic(
light: UIColor(red: 0.24, green: 0.24, blue: 0.27, alpha: 1),
dark: UIColor(red: 0.80, green: 0.81, blue: 0.83, alpha: 1))
/// A `Color` that resolves to `light`/`dark` with the active trait collection (driven by
/// `.preferredColorScheme`) — the UIKit twin of the Mac's `NSColor(name:)` provider.
private static func dynamic(light: UIColor, dark: UIColor) -> Color {
Color(UIColor { traits in traits.userInterfaceStyle == .dark ? dark : light })
}
/// The inverse of `nightSoft`, for the interactive accent: light mode keeps `base`; dark
/// mode gets a brighter, lightly desaturated variant so the tint stays legible against
/// deep backgrounds and glass instead of sinking into them.
static func nightBright(_ base: Color) -> Color {
let ui = UIColor(base)
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base }
let dark = UIColor(hue: h, saturation: s * 0.72, brightness: max(b, 0.85), alpha: a)
return dynamic(light: ui, dark: dark)
}
/// Light mode keeps `base`; dark mode gets a desaturated, dimmed variant — the same
/// night-softening curve as the Mac's `AppPalette.nightSoft`.
static func nightSoft(_ base: Color) -> Color {
let ui = UIColor(base)
var h: CGFloat = 0, s: CGFloat = 0, b: CGFloat = 0, a: CGFloat = 0
guard ui.getHue(&h, saturation: &s, brightness: &b, alpha: &a) else { return base }
let dark = UIColor(hue: h, saturation: s * 0.68, brightness: min(b, 0.80) * 0.82, alpha: a)
return dynamic(light: ui, dark: dark)
}
/// 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: current.activityBase.opacity(0.4)
case 2: current.activityBase.opacity(0.7)
default: current.activityBase
}
}
/// Activity-grid shade for a continuous brightness in `0…1` (relative to the user's own
/// activity, outliers excluded — see `ActivityScale`). `0` is an empty day's neutral gray.
/// Mirrors the Mac's `AppPalette.activity(intensity:)`.
static func activity(intensity: Double) -> Color {
intensity <= 0 ? Color.secondary.opacity(0.15) : current.activityBase.opacity(intensity)
}
}
/// 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
}
}
/// The shared session-list sort: attention-first by `sortRank`, then, within a rank, by
/// last-turn recency (`lastTurnAt`) rather than raw activity — so rows hold their place
/// instead of reshuffling every few seconds as the agent works. Mirrors the Mac sidebar.
static func attentionThenRecency(_ lhs: WireSessionSummary, _ rhs: WireSessionSummary) -> Bool {
let l = sortRank(lhs), r = sortRank(rhs)
return l == r ? lhs.lastTurnAt > rhs.lastTurnAt : l < r
}
}
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
}
}
}
/// Adds a "Done" button to the keyboard's accessory toolbar so any focused composer can always
/// dismiss the keyboard. The multiline composers (`axis: .vertical`) turn Return into a newline,
/// so without this there's no way to release the keyboard once it opens. The modifier carries its
/// own `@FocusState` and binds it to the field, keeping callers to a single `.keyboardDismissable()`.
struct KeyboardDismissable: ViewModifier {
@FocusState private var focused: Bool
func body(content: Content) -> some View {
content
.focused($focused)
.toolbar {
ToolbarItemGroup(placement: .keyboard) {
Spacer()
Button { focused = false } label: {
Label("Done", systemImage: "keyboard.chevron.compact.down")
}
}
}
}
}
/// A floating Liquid Glass surface for bars that hover over scrolling content (the session
/// chat bar, approval card). Real `glassEffect` on iOS 26; an ultra-thin material with a
/// hairline edge on earlier systems so the bar still reads as a translucent layer.
struct GlassSurface: ViewModifier {
var cornerRadius: CGFloat = 24
func body(content: Content) -> some View {
if #available(iOS 26.0, *) {
content.glassEffect(.regular, in: .rect(cornerRadius: cornerRadius, style: .continuous))
} else {
content
.background(.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
}
}
}
/// A circular Liquid Glass backing for a floating round control (the New-chat FAB). Real
/// interactive `glassEffect` on iOS 26; an ultra-thin material disc with a hairline edge and a
/// soft shadow on earlier systems so it still lifts off the list.
struct GlassCircle: ViewModifier {
func body(content: Content) -> some View {
if #available(iOS 26.0, *) {
content.glassEffect(.regular.interactive(), in: .circle)
} else {
content
.background(.ultraThinMaterial, in: .circle)
.overlay(Circle().strokeBorder(Color.primary.opacity(0.08), lineWidth: 1))
.shadow(color: .black.opacity(0.2), radius: 6, y: 2)
}
}
}
/// 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)) }
/// Float content on Liquid Glass (material fallback pre-iOS 26). See `GlassSurface`.
func glassSurface(cornerRadius: CGFloat = 24) -> some View {
modifier(GlassSurface(cornerRadius: cornerRadius))
}
/// Back a circular control (the floating New-chat FAB) with interactive Liquid Glass — the
/// round analogue of `glassSurface`, falling back to an ultra-thin material disc pre-iOS 26.
func glassCircle() -> some View { modifier(GlassCircle()) }
/// Apply the teal accent app-wide.
func nucleicTint() -> some View { tint(Palette.accent) }
/// Give a text field a way to release the keyboard. Without this, iOS leaves a focused
/// composer no dismiss affordance (no Return key on a vertical/multiline field, nothing to
/// tap) and the keyboard stays up. See `KeyboardDismissable`.
func keyboardDismissable() -> some View { modifier(KeyboardDismissable()) }
/// 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 }
}
extension WireModelCatalog {
/// Effort levels to offer for `sku`. When no model is selected yet, fall back to the default
/// model's range (not the lone fallback level), and to the built-in list before a `Welcome`
/// catalog has arrived (an empty catalog, e.g. demo / older host).
func offeredEfforts(forModel sku: String?) -> [String] {
guard !groups.isEmpty else { return MobileEfforts.levels }
let levels = efforts(forModel: sku ?? fallbackModel)
return levels.isEmpty ? MobileEfforts.levels : levels
}
/// The orchestra sentinel, or the built-in default when no catalog is present.
var orchestraSentinelOrFallback: String {
orchestraSentinel.isEmpty ? MobileEfforts.orchestraSentinel : orchestraSentinel
}
/// Pretty label for an effort level, tolerating the empty (pre-`Welcome`) catalog.
func offeredEffortDisplayName(_ effort: String) -> String {
groups.isEmpty ? MobileEfforts.displayName(effort) : effortDisplayName(effort)
}
}
/// A compact model selector driven by the wire catalog (SYNC §5.2). `backend == nil` — the
/// home composer, where the model also selects the backend — shows every provider group with
/// dividers; a fixed `backend` (in-session, where the backend is immutable) shows only that
/// provider's models. Selecting sets the SKU; the binding's `nil` means "host default".
struct ModelMenu: View {
@Binding var model: String?
let catalog: WireModelCatalog
var backend: BackendID? = nil
private var groups: [[WireModelCatalog.Model]] {
if let backend {
let models = catalog.models(for: backend)
return models.isEmpty ? [] : [models]
}
return catalog.groups
}
// `nil` model means "host default": name that model (the catalog's fallback SKU) rather than
// showing a generic "Default model". Falls back to the generic text only for the empty catalog.
private var label: String {
if let model { return catalog.displayName(model) }
let defaultName = catalog.displayName(catalog.fallbackModel)
return defaultName.isEmpty ? "Default model" : defaultName
}
var body: some View {
Menu {
ForEach(Array(groups.enumerated()), id: \.offset) { index, group in
if index > 0 { Divider() }
ForEach(group) { item in
Button { model = item.sku } label: { menuLabel(item) }
}
}
} label: {
// Accent icon, primary-ink text: an all-accent caption washes out on the Liquid
// Glass chat bar, so only the glyph carries the tint.
HStack(spacing: 4) {
Image(systemName: "cpu").foregroundStyle(Palette.accent)
Text(label)
if let badge = model.flatMap({ catalog.contextBadge($0) }) {
Text(badge).foregroundStyle(.secondary)
}
}
.font(.caption.weight(.semibold))
.foregroundStyle(.primary)
}
.disabled(groups.isEmpty)
}
@ViewBuilder private func menuLabel(_ item: WireModelCatalog.Model) -> some View {
let name = item.contextBadge.map { "\(item.displayName) · \($0)" } ?? item.displayName
if item.sku == model { Label(name, systemImage: "checkmark") } else { Text(name) }
}
}
/// A compact effort selector for the remote composers, driven by the wire catalog so it offers
/// exactly the levels the chosen model supports (Codex caps at xhigh, Grok exposes only Auto).
/// Orchestra sits below the API levels with a sparkles glyph and gold accent; it's a Nucleic
/// Control capability, so it's only selectable when the target project is under Control.
struct EffortMenu: View {
@Binding var effort: String
let catalog: WireModelCatalog
/// The model whose effort caps apply (nil → fall back to the built-in level list).
var modelSKU: String? = nil
/// Whether the target project is under Nucleic Control (gates Orchestra).
var controlled: Bool = false
/// Latch Orchestra on once it's active: for an existing chat the host refuses to turn Orchestra
/// off (`SessionController.setEffort`), so the other levels are disabled to match. Left off for
/// new-chat composition, where the choice isn't committed yet and can still be changed.
var lockWhenOrchestra: Bool = false
private var levels: [String] { catalog.offeredEfforts(forModel: modelSKU) }
private var sentinel: String { catalog.orchestraSentinelOrFallback }
private func isOrchestra(_ e: String) -> Bool { e.caseInsensitiveCompare(sentinel) == .orderedSame }
private func display(_ e: String) -> String {
isOrchestra(e) ? "Orchestra" : catalog.offeredEffortDisplayName(e)
}
/// Orchestra only counts as active where it's permitted; a stray selection on a non-Control
/// project (or a level the current model doesn't support) reads as the nearest supported level
/// so the trigger never shows something that can't run.
private var orchestraActive: Bool { controlled && isOrchestra(effort) }
private var triggerEffort: String {
if orchestraActive { return effort }
if isOrchestra(effort) { return levels.last ?? MobileEfforts.fallback }
return levels.contains(effort) ? effort : (levels.last ?? MobileEfforts.fallback)
}
/// Once Orchestra is latched on for an existing chat, the ordinary levels can't be chosen.
private var locked: Bool { lockWhenOrchestra && orchestraActive }
var body: some View {
Menu {
ForEach(levels, id: \.self) { level in
Button { effort = level } label: {
if effort == level { Label(display(level), systemImage: "checkmark") }
else { Text(display(level)) }
}
.disabled(locked)
}
Divider()
Button { effort = sentinel } label: {
Label(
controlled ? "Orchestra" : "Orchestra — Requires Nucleic Control",
systemImage: orchestraActive ? "checkmark" : "sparkles")
}
.disabled(!controlled)
} label: {
// Same treatment as `ModelMenu`: tinted glyph, primary-ink text for glass-bar
// legibility. Orchestra keeps its full-gold signature — that state should glow.
HStack(spacing: 4) {
Image(systemName: orchestraActive ? "sparkles" : "slider.horizontal.3")
.foregroundStyle(orchestraActive ? Palette.orchestra : Palette.accent)
Text(display(triggerEffort))
}
.font(.caption.weight(.semibold))
.foregroundStyle(orchestraActive ? AnyShapeStyle(Palette.orchestra) : AnyShapeStyle(.primary))
}
}
}
/// A bright golden ring that throbs around a composer while orchestra is selected — the
/// remote echo of the Mac's `OrchestraGlow`: the same bold, high-amplitude swell on a slow
/// beat with a long pause between pulses. 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: 2.6).repeatForever(autoreverses: true),
value: pulsing)
.allowsHitTesting(false)
}
}
.onAppear { pulsing = active }
.onChange(of: active) { _, now in pulsing = now }
}
}