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
@@ -123,20 +123,28 @@ struct ContentView: View {
emptyState
} else {
ScrollView {
LazyVGrid(columns: gridColumns, spacing: 16) {
LazyVGrid(columns: gridColumns, spacing: gridSpacing) {
ForEach(store.hosts) { host in
hostCard(host)
}
#if os(tvOS)
actionTile("Add Host", systemImage: "plus") {
showAddHost = true
}
actionTile("Settings", systemImage: "gearshape") {
showSettings = true
}
#endif
}
.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] {
#if os(macOS)
[GridItem(.adaptive(minimum: 180, maximum: 240), spacing: 16)]
#elseif os(tvOS)
[GridItem(.adaptive(minimum: 320), spacing: 48)]
#else
[GridItem(.adaptive(minimum: 280), spacing: 16)]
#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 {
Button {
showAddHost = true
@@ -247,6 +265,9 @@ struct ContentView: View {
#if os(iOS)
.controlSize(.large)
#endif
#if os(tvOS)
Button("Settings") { showSettings = true }
#endif
}
}
@@ -303,6 +324,9 @@ struct ContentView: View {
.frame(maxWidth: .infinity)
.padding(.vertical, cardPadding)
.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))
.overlay {
if host.id == mostRecentHostID {
@@ -310,6 +334,7 @@ struct ContentView: View {
.strokeBorder(Color.accentColor.opacity(0.35), lineWidth: 1.5)
}
}
#endif
}
#if os(tvOS)
.buttonStyle(.card)
@@ -344,30 +369,6 @@ struct ContentView: View {
#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.
private var mostRecentHostID: UUID? {
store.hosts