From f292b3fe3aad6d0c80b0b804c675eff642558e70 Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Thu, 11 Jun 2026 13:47:27 +0200 Subject: [PATCH] fix(apple/tvOS): focus-native home grid, separated actions, Form-free dialogs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../PunktfunkClient/AddHostSheet.swift | 39 +++++++++-- .../Sources/PunktfunkClient/ContentView.swift | 67 ++++++++++--------- .../Sources/PunktfunkClient/PairSheet.swift | 41 ++++++++++++ 3 files changed, 107 insertions(+), 40 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift index 9003b09..3ab758a 100644 --- a/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/AddHostSheet.swift @@ -12,6 +12,28 @@ struct AddHostSheet: View { let onAdd: (StoredHost) -> Void 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) { Form { TextField("Name", text: $name, prompt: Text("Optional — e.g. Living Room")) @@ -33,13 +55,7 @@ struct AddHostSheet: View { .keyboardShortcut(.cancelAction) #endif Spacer() - Button("Add Host") { - onAdd(StoredHost( - name: name.trimmingCharacters(in: .whitespaces), - address: address.trimmingCharacters(in: .whitespaces), - port: UInt16(clamping: port))) - dismiss() - } + Button("Add Host") { add() } .buttonStyle(.borderedProminent) #if !os(tvOS) .keyboardShortcut(.defaultAction) @@ -55,5 +71,14 @@ struct AddHostSheet: View { .frame(width: 380) .fixedSize(horizontal: false, vertical: true) #endif + #endif + } + + private func add() { + onAdd(StoredHost( + name: name.trimmingCharacters(in: .whitespaces), + address: address.trimmingCharacters(in: .whitespaces), + port: UInt16(clamping: port))) + dismiss() } } diff --git a/clients/apple/Sources/PunktfunkClient/ContentView.swift b/clients/apple/Sources/PunktfunkClient/ContentView.swift index cfc7f60..29a5752 100644 --- a/clients/apple/Sources/PunktfunkClient/ContentView.swift +++ b/clients/apple/Sources/PunktfunkClient/ContentView.swift @@ -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 diff --git a/clients/apple/Sources/PunktfunkClient/PairSheet.swift b/clients/apple/Sources/PunktfunkClient/PairSheet.swift index 20a3003..7b0cfa0 100644 --- a/clients/apple/Sources/PunktfunkClient/PairSheet.swift +++ b/clients/apple/Sources/PunktfunkClient/PairSheet.swift @@ -35,6 +35,46 @@ struct PairSheet: View { @State private var token = CeremonyToken() 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) { Form { Section { @@ -103,6 +143,7 @@ struct PairSheet: View { #endif .interactiveDismissDisabled(busy) .onDisappear { token.cancelled = true } // any other dismissal path + #endif } private func runCeremony() {