ios: full feature/appearance parity with the macOS app (client-side)

Port the Mac's MarkdownText renderer, AppPalette (color-vision modes +
night-softening), appearance settings (theme/text size/color vision),
and BuildBanner channel strip to the iPhone remote. Add session-row
attention/unseen-completion washes and marker icons, Discard action,
branch/worktree options in the new-chat composer, and a transient
error bubble for host wire errors. Stamp the build channel via
NucleicChannel in Info.plist (ios-release.sh passes NUCLEIC_CHANNEL,
default beta). Demo mode gains NUCLEIC_DEMO_SESSION and skips the
notifications prompt so UI previews are scriptable.

Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
2026-07-02 16:06:32 -07:00
co-authored by Claude Fable 5
parent 79a28605b3
commit a33ab80313
11 changed files with 843 additions and 47 deletions
@@ -14,7 +14,10 @@ struct NucleicRemoteApp: App {
store.onAppear()
// Surface the notifications prompt + register for APNS. The token rides along
// in the sync Hello; the relay uses it to wake the phone for approvals (§3).
PushRegistrar.shared.requestAuthorizationAndRegister()
// Skipped in demo mode so offline UI previews aren't blocked by the system dialog.
if ProcessInfo.processInfo.environment["NUCLEIC_DEMO"] != "1" {
PushRegistrar.shared.requestAuthorizationAndRegister()
}
// Anonymous, opt-out, once-a-day DAI heartbeat (docs/CLOUD_INFRA.md §4).
Task { await HeartbeatReporter.reportIfDue() }
}
@@ -30,27 +33,70 @@ struct RootView: View {
@EnvironmentObject var store: RemoteStore
// Initial tab; overridable via NUCLEIC_TAB for offline UI previews.
@State private var tab = Int(ProcessInfo.processInfo.environment["NUCLEIC_TAB"] ?? "") ?? 0
// Appearance preferences — same storage keys and semantics as the Mac's Settings →
// Appearance, so both devices honor the same choices.
@AppStorage(AppAppearance.storageKey) private var appearanceRaw = AppAppearance.system.rawValue
@AppStorage(AppTextSize.storageKey) private var textSizeRaw = AppTextSize.medium.rawValue
@AppStorage(ColorVisionMode.storageKey) private var colorVisionRaw = ColorVisionMode.standard.rawValue
var body: some View {
Group {
if store.isPaired {
TabView(selection: $tab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }.tag(0)
SessionsView()
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
.badge(store.needsYouCount).tag(1)
ProjectsView()
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
TodosView()
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
VStack(spacing: 0) {
// The same non-release warning strip the Mac window carries (dev/canary/beta/rc).
BuildBanner()
Group {
if store.isPaired {
TabView(selection: $tab) {
HomeView()
.tabItem { Label("Home", systemImage: "house") }.tag(0)
SessionsView()
.tabItem { Label("Sessions", systemImage: "square.stack.3d.up") }
.badge(store.needsYouCount).tag(1)
ProjectsView()
.tabItem { Label("Projects", systemImage: "folder") }.tag(2)
TodosView()
.tabItem { Label("To-dos", systemImage: "checklist") }.tag(3)
SettingsView()
.tabItem { Label("Settings", systemImage: "gearshape") }.tag(4)
}
} else {
PairingIntroView()
}
} else {
PairingIntroView()
}
}
.overlay(alignment: .bottom) { ErrorBubble() }
.tint(Palette.accent)
.preferredColorScheme((AppAppearance(rawValue: appearanceRaw) ?? .system).colorScheme)
.dynamicTypeSize((AppTextSize(rawValue: textSizeRaw) ?? .medium).dynamicTypeSize)
// The palette resolves against the stored color-vision mode at render time; rebuild
// the hierarchy when it changes so every cached view picks up the remapped colors.
.id(colorVisionRaw)
}
}
/// A transient red bubble for host-reported errors — the mobile echo of the Mac's last-error
/// overlay at the detail pane's bottom. Auto-dismissed by the store after a few seconds; a tap
/// dismisses it immediately.
private struct ErrorBubble: View {
@EnvironmentObject var store: RemoteStore
var body: some View {
if let error = store.lastError {
HStack(spacing: 8) {
Image(systemName: "exclamationmark.triangle.fill")
Text(error.message).lineLimit(3)
Spacer(minLength: 0)
Image(systemName: "xmark").opacity(0.7)
}
.font(.footnote.weight(.medium))
.foregroundStyle(.white)
.padding(.horizontal, 12)
.padding(.vertical, 10)
.background(Palette.danger, in: RoundedRectangle(cornerRadius: 12))
.padding(.horizontal, 16)
.padding(.bottom, 58) // clear the tab bar
.onTapGesture { store.dismissError() }
.transition(.move(edge: .bottom).combined(with: .opacity))
.animation(.easeInOut(duration: 0.2), value: store.lastError)
}
}
}