fix(apple/tvOS): focus-native home grid, separated actions, Form-free dialogs
ci / rust (push) Has been cancelled

Three more tvOS-isms, all the same lesson — let the focus engine own the chrome:

- Host cards drew their own material platter + accent ring INSIDE the .card button
  style, muting the native grow/tilt focus motion. On tvOS the card style now owns the
  platter outright (material/ring stay on the pointer platforms), and the grid gets
  48 pt spacing so the focused card swells without overlapping siblings.
- Add Host and Settings no longer sit in the hosts row: they're a compact button row
  below the grid (and the empty state gains a Settings button, since tvOS has no
  toolbar).
- The Add Host and pairing dialogs drop Form entirely on tvOS — list rows added a
  full-width focus fill plus a row platter behind every field's own pill (the
  "second outer pill"). As standalone fields in a centered dialog over the dimmed
  home, each input is exactly one pill with vertically centered text.

Verified by screenshot in the Apple TV simulator (home grid + Add Host dialog).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 13:47:27 +02:00
parent 9e57a5a1ff
commit f292b3fe3a
3 changed files with 107 additions and 40 deletions
@@ -12,6 +12,28 @@ struct AddHostSheet: View {
let onAdd: (StoredHost) -> Void let onAdd: (StoredHost) -> Void
var body: some View { var body: some View {
#if os(tvOS)
// No Form here: tvOS list rows add a full-width focus fill + row platter
// behind the field's own pill. Standalone fields have exactly one pill.
VStack(spacing: 28) {
Text("Add Host")
.font(.title3.weight(.semibold))
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
.labelsHidden()
TextField("Address", text: $address, prompt: Text("IP or hostname"))
.labelsHidden()
TextField("Port", value: $port, format: .number.grouping(.never))
.labelsHidden()
HStack(spacing: 32) {
Button("Cancel", role: .cancel) { dismiss() }
Button("Add Host") { add() }
.disabled(address.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
#else
VStack(spacing: 0) { VStack(spacing: 0) {
Form { Form {
TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room")) TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room"))
@@ -33,13 +55,7 @@ struct AddHostSheet: View {
.keyboardShortcut(.cancelAction) .keyboardShortcut(.cancelAction)
#endif #endif
Spacer() Spacer()
Button("Add Host") { Button("Add Host") { add() }
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(clamping: port)))
dismiss()
}
.buttonStyle(.borderedProminent) .buttonStyle(.borderedProminent)
#if !os(tvOS) #if !os(tvOS)
.keyboardShortcut(.defaultAction) .keyboardShortcut(.defaultAction)
@@ -55,5 +71,14 @@ struct AddHostSheet: View {
.frame(width: 380) .frame(width: 380)
.fixedSize(horizontal: false, vertical: true) .fixedSize(horizontal: false, vertical: true)
#endif #endif
#endif
}
private func add() {
onAdd(StoredHost(
name: name.trimmingCharacters(in: .whitespaces),
address: address.trimmingCharacters(in: .whitespaces),
port: UInt16(clamping: port)))
dismiss()
} }
} }
@@ -123,20 +123,28 @@ struct ContentView: View {
emptyState emptyState
} else { } else {
ScrollView { ScrollView {
LazyVGrid(columns: gridColumns, spacing: 16) { LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(store.hosts) { host in ForEach(store.hosts) { host in
hostCard(host) hostCard(host)
} }
#if os(tvOS)
actionTile("Add Host", systemImage: "plus") {
showAddHost = true
}
actionTile("Settings", systemImage: "gearshape") {
showSettings = true
}
#endif
} }
.padding() .padding()
#if os(tvOS)
// Actions live below the hosts, not between them.
HStack(spacing: 32) {
Button {
showAddHost = true
} label: {
Label("Add Host", systemImage: "plus")
}
Button {
showSettings = true
} label: {
Label("Settings", systemImage: "gearshape")
}
}
.padding(.top, 24)
#endif
} }
} }
} }
@@ -213,11 +221,21 @@ struct ContentView: View {
private var gridColumns: [GridItem] { private var gridColumns: [GridItem] {
#if os(macOS) #if os(macOS)
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)] [GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
#elseif os(tvOS)
[GridItem(.adaptive(minimum: 320), spacing: 48)]
#else #else
[GridItem(.adaptive(minimum: 280), spacing: 16)] [GridItem(.adaptive(minimum: 280), spacing: 16)]
#endif #endif
} }
private var gridSpacing: CGFloat {
#if os(tvOS)
48 // the focused card scales up give it room instead of overlapping siblings
#else
16
#endif
}
private var addHostButton: some View { private var addHostButton: some View {
Button { Button {
showAddHost = true showAddHost = true
@@ -247,6 +265,9 @@ struct ContentView: View {
#if os(iOS) #if os(iOS)
.controlSize(.large) .controlSize(.large)
#endif #endif
#if os(tvOS)
Button("Settings") { showSettings = true }
#endif
} }
} }
@@ -303,6 +324,9 @@ struct ContentView: View {
.frame(maxWidth: .infinity) .frame(maxWidth: .infinity)
.padding(.vertical, cardPadding) .padding(.vertical, cardPadding)
.padding(.horizontal, 12) .padding(.horizontal, 12)
#if !os(tvOS)
// tvOS: the .card button style owns platter + focus motion extra chrome
// inside it mutes the grow/tilt. Material + accent ring are for pointer UIs.
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14)) .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
.overlay { .overlay {
if host.id == mostRecentHostID { if host.id == mostRecentHostID {
@@ -310,6 +334,7 @@ struct ContentView: View {
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5) .strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
} }
} }
#endif
} }
#if os(tvOS) #if os(tvOS)
.buttonStyle(.card) .buttonStyle(.card)
@@ -344,30 +369,6 @@ struct ContentView: View {
#endif #endif
} }
#if os(tvOS)
/// Grid-resident replacement for the toolbar (whose items are neither sized nor
/// focusable on tvOS): a full-size, focus-native tile per action.
private func actionTile(
_ label: String, systemImage: String, action: @escaping () -> Void
) -> some View {
Button(action: action) {
VStack(spacing: 10) {
Image(systemName: systemImage)
.font(.system(size: 56, weight: .light))
.foregroundStyle(.tint)
.frame(height: 76)
Text(label)
.font(.title3.weight(.semibold))
}
.frame(maxWidth: .infinity)
.padding(.vertical, 28)
.padding(.horizontal, 12)
.background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14))
}
.buttonStyle(.card)
}
#endif
/// The host of the most recent session its card carries the accent ring. /// The host of the most recent session its card carries the accent ring.
private var mostRecentHostID: UUID? { private var mostRecentHostID: UUID? {
store.hosts store.hosts
@@ -35,6 +35,46 @@ struct PairSheet: View {
@State private var token = CeremonyToken() @State private var token = CeremonyToken()
var body: some View { var body: some View {
#if os(tvOS)
VStack(spacing: 24) {
Label("Pair with \(host.displayName)", systemImage: "lock.shield")
.font(.title3.weight(.semibold))
.foregroundStyle(.tint)
Text("The host prints the PIN when pairing is armed "
+ "(--allow-pairing, \u{201C}PAIRING ARMED\u{201D} in its log). "
+ "Pairing verifies both sides at once — no fingerprint comparison "
+ "needed.")
.font(.callout)
.foregroundStyle(.secondary)
.multilineTextAlignment(.center)
TextField("PIN", text: $pin, prompt: Text("Shown in the host's log"))
.labelsHidden()
TextField(
"Client name", text: $clientName,
prompt: Text("How the host lists this device"))
.labelsHidden()
if let errorText {
Text(errorText)
.font(.callout)
.foregroundStyle(.red)
}
HStack(spacing: 32) {
Button("Cancel", role: .cancel) {
token.cancelled = true
dismiss()
}
if busy {
ProgressView()
}
Button("Pair & Connect") { runCeremony() }
.disabled(busy || pin.trimmingCharacters(in: .whitespaces).isEmpty)
}
.padding(.top, 12)
}
.frame(maxWidth: 1000)
.padding(60)
.onDisappear { token.cancelled = true }
#else
VStack(spacing: 0) { VStack(spacing: 0) {
Form { Form {
Section { Section {
@@ -103,6 +143,7 @@ struct PairSheet: View {
#endif #endif
.interactiveDismissDisabled(busy) .interactiveDismissDisabled(busy)
.onDisappear { token.cancelled = true } // any other dismissal path .onDisappear { token.cancelled = true } // any other dismissal path
#endif
} }
private func runCeremony() { private func runCeremony() {