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
@@ -0,0 +1,133 @@
import SwiftUI
/// How this copy of the remote was built — the iOS mirror of the Mac's `BuildChannel`.
/// Stamped at archive time via the `NucleicChannel` Info.plist key (`$(NUCLEIC_CHANNEL)`,
/// see scripts/ios-release.sh); unset builds fall back to runtime detection (Debug → local,
/// TestFlight → beta, App Store → release).
enum BuildChannel {
/// The `dev` channel — red "Local Development Build" banner.
case local
/// The `canary` channel — canary-yellow "Canary" banner with the bird. The bleeding-edge
/// distributed channel, cut before beta (see BUILD.md).
case canary
/// The `beta` channel — blue "Beta" banner.
case beta
/// The `rc` channel — gold "Release Candidate" banner.
case releaseCandidate
/// The `stable` channel — no banner.
case release
}
/// Build identity for the running app: which channel it came from and its build label.
/// The Mac embeds a git commit via a prebuild plugin; the iOS build number is derived from
/// `git rev-list --count` at archive time (scripts/ios-release.sh), so `CFBundleVersion`
/// plays the same role here.
struct BuildInfo {
let channel: BuildChannel
let buildLabel: String
/// `CFBundleShortVersionString` when present, else `nil`.
let version: String?
/// Resolved once — none of this changes while the app is running.
static let current = BuildInfo()
init() {
let info = Bundle.main.infoDictionary
version = info?["CFBundleShortVersionString"] as? String
buildLabel = (info?["CFBundleVersion"] as? String) ?? "local"
channel = Self.detectChannel(stamped: info?["NucleicChannel"] as? String)
}
/// Prefer the archive-time stamp (mirrors the Mac's `NUCLEIC_CHANNEL` build setting);
/// fall back to what the runtime can tell on its own.
private static func detectChannel(stamped: String?) -> BuildChannel {
switch stamped?.lowercased() {
case "dev", "local": return .local
case "canary": return .canary
case "beta": return .beta
case "rc": return .releaseCandidate
case "stable", "release": return .release
default: break
}
#if DEBUG
return .local
#else
// TestFlight installs carry the sandbox receipt; App Store installs don't.
if Bundle.main.appStoreReceiptURL?.lastPathComponent == "sandboxReceipt" { return .beta }
return .release
#endif
}
}
/// Header strip warning that this is a non-release build — the same channel colors, icons,
/// and copy as the Mac's `BuildBanner`: red for local builds, canary-yellow for canary, blue
/// for beta, gold for a release candidate. The leading edge names the channel; the build
/// label and an all-caps "EXPERIMENTAL" badge sit on the trailing edge. Renders nothing for
/// a shipping App Store release.
struct BuildBanner: View {
var info: BuildInfo = .current
var body: some View {
if let style = Style(channel: info.channel) {
HStack(spacing: 8) {
Image(systemName: style.icon)
.font(.system(size: 15, weight: .heavy))
Text(style.label)
.font(.footnote.weight(.semibold))
.lineLimit(1)
Spacer(minLength: 12)
Text(info.buildLabel)
.font(.caption.monospaced())
.opacity(0.85)
Text("EXPERIMENTAL")
.font(.caption2.weight(.heavy))
.tracking(0.9)
.padding(.horizontal, 6)
.padding(.vertical, 2)
.background(.white.opacity(0.16), in: .capsule)
}
.foregroundStyle(.white.opacity(0.92))
.padding(.horizontal, 12)
.padding(.vertical, 5)
.frame(maxWidth: .infinity)
.background(style.tint.opacity(0.5))
.accessibilityLabel(style.tooltip(version: info.version, build: info.buildLabel))
}
}
/// Per-channel appearance — identical constants to the Mac. `nil` for `.release` so the
/// banner disappears entirely.
private struct Style {
var tint: Color
var label: String
var icon: String
init?(channel: BuildChannel) {
switch channel {
case .local:
tint = Color(red: 0.78, green: 0.18, blue: 0.18) // red
label = "Local Development Build"
icon = "hammer.fill"
case .canary:
tint = Color(red: 1.00, green: 0.87, blue: 0.00) // bright canary yellow
label = "Canary Build"
icon = "bird.fill"
case .beta:
tint = Color(red: 0.13, green: 0.40, blue: 0.86) // blue
label = "Engineering Beta"
icon = "person.2.badge.gearshape.fill"
case .releaseCandidate:
tint = Color(red: 0.83, green: 0.65, blue: 0.12) // gold
label = "Release Candidate"
icon = "checkmark.seal.fill"
case .release:
return nil
}
}
func tooltip(version: String?, build: String) -> String {
let v = version.map { "\($0) " } ?? ""
return "\(label) — \(v)build \(build). Not a release build."
}
}
}