Files
punktfunk/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift
T
enricobuehler c010139e6e
ci / bun-nix (pull_request) Successful in 49s
ci / web (pull_request) Successful in 1m3s
ci / docs-site (pull_request) Successful in 1m16s
apple / swift (pull_request) Successful in 1m33s
apple / screenshots (pull_request) Skipped
ci / rust-arm64 (pull_request) Successful in 4m38s
ci / rust (pull_request) Successful in 16m9s
feat(client/apple): the gamepad UI moves and colours like the console it mirrors
Four reworks from the first palette-era on-glass review, all iOS-facing:

- Surfaces carry the palette now, not just the text on them: ConsoleGlass
  washes every tier (Liquid Glass tint, pre-26 material, tvOS material)
  with ink.glass — the same colour the desktop console fills its panels
  with — and the close buttons move to an ink-aware consoleGlassBackground.
  The pre-26 branch also gains the focus tint it had silently dropped.
  Stray literals follow: ConnectOverlay text rides ink in the console
  takeover, card shadows soften on pale fields, the focused keycap reads
  onAccent. The online pip stays status-green on purpose.

- The header breathes: title top padding 4/10 -> 10/18 plus shared
  header-spacing and title-bottom helpers mapped from the console shell's
  rhythm, applied to the launcher, settings and add-host alike, with the
  add-host close X re-anchored to the title row.

- Settings, Add Host and the Library present IN PLACE on iOS: one
  persistent aurora whose calm is chased (the console's bg_mix), screens
  as transparent layers with the console's 0.26 s ease-out-cubic push/pop,
  an input drop for the transition, and the controller handed off through
  isActive — no more opaque bottom-up covers, no backdrop teardown.
  macOS keeps its sheets, tvOS its focus-engine covers.

- The settings select is a real band: choice rows mount GamepadOptionBand,
  a spring-driven drum (Animatable body, ring-distance wrap, neighbours
  gated by focus and flight) whose retargeting spring accumulates rapid
  steps into one continuous spin. Reduce Motion falls back to a plain
  crossfade; toggles keep the quiet 14 pt slip.

Verified: swift build (macOS), swift build --triple arm64-apple-ios17.0,
swift test 208 passed / 0 failed. On-glass QA still owed: palette sweep on
a pale palette, transition compositing over materials, drum feel on device.
2026-08-07 13:59:40 +02:00

631 lines
30 KiB
Swift

// The gamepad-driven home screen: a distinct, "10-foot" console-style host
// launcher shown INSTEAD of HomeView while GamepadUIEnvironment is active — a separate screen built
// around a center-snapping carousel of hosts, driven from the couch with a controller. No touch is
// required anywhere: A connects, Y opens a saved host's library (when the flag is on), X opens the
// gamepad settings screen, and the carousel always ends in an Add Host tile that opens the
// controller-keyboard add flow. (A tap still works as a fallback for all of it.)
//
// All the scrolling/snapping/navigation/haptics live in GamepadCarousel; this file is the launcher's
// chrome. Layout discipline (so nothing is EVER clipped, portrait or landscape): the gradient is a
// `.background` modifier — NOT a ZStack sibling — because an `.ignoresSafeArea()` sibling expands the
// stack to full-screen and hands the GeometryReader the full height, laying content out under the
// status bar / home indicator. As a background it draws behind without affecting layout, so the
// GeometryReader is sized to the safe area. The title and the controller-glyph hints are pinned with
// `.safeAreaInset` (top / bottom-leading) — guaranteed inside the safe area and out of the carousel's
// vertical budget — and the card is sized off the remaining height. macOS mounts it too (the
// couch Mac-mini case) — same screen, with the settings/add-host covers presented as sheets
// (macOS has no fullScreenCover). tvOS mounts it as well, driven by the native focus engine
// (see GamepadCarousel's tvOS mode) so the Siri Remote works alongside the pad; Play/Pause
// mirrors X for Settings since the focus engine has no concept of that button.
import PunktfunkKit
import SwiftUI
#if os(iOS) || os(macOS) || os(tvOS)
import GameController
/// One navigable tile: a saved host, a discovered-but-unsaved one, or one of the trailing
/// actions. Hashable so it can be the carousel's scroll-position identity.
private enum GamepadHomeTarget: Hashable {
/// A saved host's own tile, or one of its pinned host+profile cards (§5.2a) — which on a
/// controller-first surface are THE profile affordance: focus and press, no menus.
case saved(UUID, profile: String?)
case discovered(String)
case addHost
case rescan
}
/// A fully-resolved launcher tile — display fields + the activate action, built fresh each render
/// from the live stores so nothing goes stale.
private struct HomeTile: Identifiable {
let id: GamepadHomeTarget
let title: String
let subtitle: String
/// The profile this tile connects with — shown as a tinted chip. Set on a pinned card; nil on
/// a plain host tile unless the host is bound to one (then it answers "what will A do?").
var profile: StreamProfile?
/// This tile IS a pinned host+profile card, not a host wearing its binding's chip — the chip
/// reads loud on the former (it is the reason the tile exists) and quiet on the latter.
var isPinnedCard = false
var isOnline = false
var isPaired = false
var isConnecting = false
/// Saved (solid monogram) vs. discovered-but-unsaved / action (tinted outline).
var filled = false
/// Only saved hosts have a library (matches the touch grid's context-menu gate).
var hasLibrary = false
/// Shows this SF symbol in the badge instead of the title monogram (the Add Host tile).
var icon: String?
/// The host's OS-identity chain. When we ship art for it, the badge wears the OS mark instead
/// of the title's initial — the same substitution the touch cards make.
var osChain: String?
/// Offline saved host we hold a MAC for (and WoL is available) — activating it wakes first.
var canWake = false
let activate: () -> Void
}
struct GamepadHomeView: View {
@Environment(\.gamepadInk) private var ink
@ObservedObject var store: HostStore
@ObservedObject var model: SessionModel
@ObservedObject var discovery: HostDiscovery
@Binding var libraryTarget: StoredHost?
/// Wake-and-wait driver — gates the carousel while its overlay is up, and the carousel's
/// activate routes an offline+wakeable host through it (see ContentView.startSession).
@ObservedObject var waker: HostWaker
let connect: (StoredHost, ProfileSelection) -> Void
let connectDiscovered: (DiscoveredHost) -> Void
/// Launch a library title on a host — the in-place library layer's activate path (iOS; the
/// cover/sheet presentations wire ContentView's `launchTitle` into LibraryView themselves).
let launchTitle: (StoredHost, String) -> Void
/// The profile catalog — pinned host+profile combos render as their own tiles here, which is
/// how a controller picks a profile: one focus-and-press instead of a menu (design §5.4).
@ObservedObject private var profiles = ProfileStore.shared
/// Same gate the touch grid's "Browse Library…" context-menu item uses (default ON; the
/// Settings "Game library" toggle opts out).
@AppStorage(DefaultsKey.libraryEnabled) private var libraryEnabled = true
/// Auto-wake on connect (default ON) — when off, activating an offline host just dials (no wake),
/// so the tile drops its "Wake & Connect" affordance for a plain "Connect".
@AppStorage(DefaultsKey.autoWake) private var autoWakeEnabled = true
#if os(iOS)
/// `.compact` in a landscape phone window — drives tighter chrome so everything still fits.
@Environment(\.verticalSizeClass) private var vSizeClass
private var compact: Bool { vSizeClass == .compact }
#else
private let compact = false // no size classes on macOS; the window minimum keeps room
#endif
@ObservedObject private var gamepads = GamepadManager.shared
@Environment(\.accessibilityReduceMotion) private var reduceMotion
@State private var selection: GamepadHomeTarget?
@State private var showSettings = false
@State private var showAddHost = false
/// The console's input drop: true for the transition's 0.26 s, during which NO layer polls
/// the controller — a double-tapped A can't push two screens, and the held button that
/// caused the change is long released before the next poller starts (whose own
/// `needsSnapshot` seed swallows it if not).
@State private var transitioning = false
/// Guards the gate's release against an interrupted transition: only the newest hold clears.
@State private var transitionEpoch = 0
var body: some View {
// The in-place shell (see GamepadShell.swift): the launcher is the base layer, the
// current sub-screen a transparent layer over it, both over ONE persistent backdrop
// that never unmounts — a push slides the screen up out of a fade while the launcher
// recedes underneath, the console's own choreography. On macOS/tvOS `topScreen` is
// constantly nil and this ZStack degenerates to the plain launcher, presented over by
// the sheets/covers below exactly as before.
ZStack {
homeLayer
.opacity(covered ? 0 : 1)
.scaleEffect(covered ? GamepadShellMotion.underScale : 1)
// The covers used to swallow touch; the recessed layer must too.
.allowsHitTesting(!covered)
#if os(iOS)
if let screen = topScreen {
screenLayer(screen)
.zIndex(1)
.id(screen.id)
.transition(.gamepadScreen(slide: GamepadShellMotion.slide(compact: compact)))
}
#endif
}
// Value-keyed rather than `withAnimation` at the triggers: pushes originate outside
// this view too (`model.returnToLibrary` writes `libraryTarget`), and keying on the
// derived id catches every writer. Reduce Motion snaps.
.animation(reduceMotion ? nil : GamepadShellMotion.screen, value: topScreenID)
// ONE living field for every layer, still a `.background` (the layout rule in this
// file's header). Its calm is CHASED between the launcher's aurora and the form
// screens' quiet, never crossfaded per screen — the console's `bg_mix`.
.background {
GamepadScreenBackground(calmMix: calmTarget)
.animation(reduceMotion ? nil : GamepadShellMotion.calm, value: calmTarget)
}
// Publish the palette's ink to this screen (text, glass, accent, scrims) — a
// pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk()
.onAppear { discovery.start() }
.onDisappear { discovery.stop() }
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
// Online — the console mirror of HomeView's `.task`; cancelled on disappear.
.task {
while !Task.isCancelled {
await store.refreshReachability(discovery: discovery)
try? await Task.sleep(for: .seconds(10))
}
}
#if os(iOS)
.onChange(of: topScreenID) { _, _ in
transitionEpoch += 1
let epoch = transitionEpoch
transitioning = true
let hold = reduceMotion ? 0.05 : GamepadShellMotion.duration + 0.02
DispatchQueue.main.asyncAfter(deadline: .now() + hold) {
if epoch == transitionEpoch { transitioning = false }
}
}
#endif
// The remote's Play/Pause mirrors the pad's X (Settings): the focus engine never surfaces
// X, and historically tvOS maps a pad's X to this same press — the poll and this command
// double-firing just sets the same Bool twice.
#if os(tvOS)
.onPlayPauseCommand { showSettings = true }
#endif
// The settings / add-host screens take over the controller (the carousel's `isActive`
// gate above). macOS has no fullScreenCover — they are generously sized sheets over the
// dimmed launcher; tvOS keeps its focus-engine covers. iOS needs nothing here: the
// shell's layers above ARE the presentation.
#if os(macOS)
.sheet(isPresented: $showSettings) {
GamepadSettingsView(store: store)
.frame(width: 720, height: 640)
}
.sheet(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
.frame(width: 660, height: 620)
}
.frame(minWidth: 640, minHeight: 420)
#elseif os(tvOS)
.fullScreenCover(isPresented: $showSettings) { GamepadSettingsView(store: store) }
.fullScreenCover(isPresented: $showAddHost) {
GamepadAddHostView { store.add($0) }
}
#endif
}
// MARK: - The shell's layers (see GamepadShell.swift)
/// The launcher itself — everything the pre-shell body was, minus the backdrop (hoisted to
/// the shell) and the presentation modifiers (below).
private var homeLayer: some View {
GeometryReader { geo in
hero(for: geo.size)
}
// Pinned inside the safe area, out of the carousel's vertical budget — never clipped.
.safeAreaInset(edge: .top, spacing: 0) {
titleBar
.padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact))
}
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
GamepadHintBar(hints: hints)
// Equal distance from the left and bottom edges — the pill's corner inset was the
// real asymmetry (leading 22 vs bottom 10), not its internal padding.
.padding(.leading, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 4 : 8)
}
}
#if os(iOS)
/// The screen the shell shows over the launcher — derived from the same triggers every
/// platform sets, so `returnToLibrary`, the tiles, X and Y all keep writing what they wrote.
private var topScreen: GamepadScreen? {
if showSettings { return .settings }
if showAddHost { return .addHost }
if let host = libraryTarget { return .library(host) }
return nil
}
@ViewBuilder private func screenLayer(_ screen: GamepadScreen) -> some View {
// The layer owns the controller only once the push settles and nothing rides over the
// shell (the connect/wake takeover is an overlay in ContentView, above these layers).
let active = !transitioning && waker.waking == nil && model.phase != .connecting
Group {
switch screen {
case .settings:
GamepadSettingsView(
store: store,
close: { if !transitioning { showSettings = false } },
controllerActive: active)
case .addHost:
GamepadAddHostView(
onAdd: { store.add($0) },
close: { if !transitioning { showAddHost = false } },
controllerActive: active)
case .library(let host):
GamepadLibraryScreen(
store: store, host: host,
onLaunch: { launchTitle(host, $0) },
close: { if !transitioning { libraryTarget = nil } },
controllerActive: active)
}
}
.environment(\.gamepadHostedInShell, true)
}
#endif
private var covered: Bool {
#if os(iOS)
topScreen != nil
#else
false
#endif
}
private var topScreenID: String? {
#if os(iOS)
topScreen?.id
#else
nil
#endif
}
/// The backdrop's calm target: 1 under a form screen, 0 under the launcher/library. The
/// macOS sheets / tvOS covers mount their own calmed field, so the launcher behind them
/// keeps its aurora — exactly what shipped.
private var calmTarget: Double {
#if os(iOS)
topScreen?.isForm == true ? 1 : 0
#else
0
#endif
}
/// Stop consuming the controller while another screen (or the connect/wake takeover) is on
/// top — otherwise the launcher navigates behind it (invisibly on iPhone, visibly on iPad),
/// and a second A during a dial would launch a concurrent connect. `.connecting` covers the
/// takeover's Connecting phase; `waker.waking` its Waking phase. On iOS the shell adds the
/// transition's input drop, during which NOBODY polls.
private var homeOwnsController: Bool {
#if os(iOS)
topScreen == nil && !transitioning
&& waker.waking == nil && model.phase != .connecting
#else
libraryTarget == nil && !showSettings && !showAddHost
&& waker.waking == nil && model.phase != .connecting
#endif
}
// MARK: - Hero (carousel + detail), sized to fit the space between the pinned title and hints
@ViewBuilder private func hero(for size: CGSize) -> some View {
#if os(tvOS)
// 10-foot scale: the phone-sized card reads like a postage stamp from the couch.
let cardWidth = min(560, size.width * 0.34)
let cardHeight = min(350, max(240, size.height - 260))
#else
let cardWidth = min(340, size.width * 0.84)
// 48 ≈ the carousel's own vertical breathing (+40) plus a small margin; clamp so the strip
// always fits the region the pinned title / hints safe-area insets leave. (The old detail
// line below the strip is gone — it only re-printed what the centered card already shows.)
let cardHeight = min(compact ? 176 : 224, max(118, size.height - 48))
#endif
VStack(spacing: compact ? 8 : 10) {
Spacer(minLength: 0)
carousel(cardWidth: cardWidth, cardHeight: cardHeight)
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
// MARK: - Chrome
private var titleBar: some View {
// The chip used to be a trailing `.overlay`, which reserves no width: on a portrait phone
// it sat directly on top of the centred title ("Select a Host" ran straight into the pad
// name). Laying it out as a row with a hidden mirror on the leading side keeps the title
// optically centred AND clear of the chip at every width; the title shrinks a little
// before it would ever truncate.
HStack(spacing: 12) {
statusChip(hidden: true)
Text("Select a Host")
.font(.geist(gamepadTitleSize(compact: compact), .bold, relativeTo: .title))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.75)
.frame(maxWidth: .infinity)
statusChip(hidden: false)
}
.padding(.horizontal, 20)
}
/// Which pad is driving this UI (name + battery) — quiet, and only where there's room; a
/// compact-height phone gives the pixels to the carousel instead. `hidden` renders the same
/// chip purely as a width reserve.
@ViewBuilder private func statusChip(hidden: Bool) -> some View {
if !compact, let active = gamepads.active {
ControllerStatusChip(controller: active)
.opacity(hidden ? 0 : 1)
.accessibilityHidden(hidden)
}
}
private var cardSpacing: CGFloat {
#if os(tvOS)
44
#else
30
#endif
}
// MARK: - Carousel
private func carousel(cardWidth: CGFloat, cardHeight: CGFloat) -> some View {
GamepadCarousel(
items: tiles,
selection: $selection,
itemWidth: cardWidth,
spacing: cardSpacing,
onActivate: { $0.activate() },
onSecondary: { openLibraryForSelected() },
onTertiary: { showSettings = true },
isActive: homeOwnsController
) { tile in
hostCard(tile, size: CGSize(width: cardWidth, height: cardHeight))
}
.frame(height: cardHeight + 40)
}
/// The host tile plus its focus treatment. Every continuous visual reads the scroll view's own
/// per-frame `phase` (real distance-from-centered), so the look always matches what's on screen
/// mid-scroll. `.shadow`/`.overlay` aren't part of `VisualEffect`, so the focus pop is scale +
/// brightness/saturation + a depth blur on the recessed neighbors.
private func hostCard(_ tile: HomeTile, size: CGSize) -> some View {
GamepadHostTile(tile: tile, size: size)
.scrollTransition { content, phase in
let d = CGFloat(min(abs(phase.value), 1))
let scale = 1 - d * 0.12
let bright = Double(-d * 0.24)
let sat = Double(1 - d * 0.42)
let soft = d * 3
let fade = Double(1 - d * 0.22)
return content
.scaleEffect(scale)
.brightness(bright)
.saturation(sat)
.blur(radius: soft)
.opacity(fade)
}
}
// MARK: - Hint bar (pinned bottom-leading via safeAreaInset)
private var hints: [GamepadHint] {
let selected = tiles.first { $0.id == selection }
let action: String? = switch selected?.id {
case .addHost: "Add Host"
case .rescan: "Rescan"
default: nil
}
var hints = [GamepadHint(
glyph: buttonGlyph(\.buttonA, fallback: "a.circle"),
text: action ?? (selected?.canWake == true ? "Wake & Connect" : "Connect"))]
if libraryEnabled, selected?.hasLibrary == true {
hints.append(.init(glyph: buttonGlyph(\.buttonY, fallback: "y.circle"), text: "Library"))
}
hints.append(.init(glyph: buttonGlyph(\.buttonX, fallback: "x.circle"), text: "Settings"))
return hints
}
// MARK: - Data + actions
/// Built fresh each render from the live stores (no stale value capture) — saved hosts first,
/// then discovered-but-unsaved ones, then the Add Host action tile (so the strip is never
/// empty and manual entry is always one press away).
private var tiles: [HomeTile] {
var saved: [HomeTile] = []
for host in store.hosts {
// Online = advertising on mDNS OR proven reachable by the probe (a routed/VPN host
// never advertises); the wake item is offered only when neither holds.
let online = discovery.advertises(host) || store.probedOnline.contains(host.id)
let bound = profiles.binding(for: host)
let connecting = model.phase == .connecting && model.activeHost?.id == host.id
// The host's own tile, then one per pinned profile — the same order the touch grid
// uses, so a pin reads as belonging to its host on both surfaces.
for profile in [StreamProfile?.none] + profiles.pinned(for: host).map(Optional.some) {
saved.append(HomeTile(
id: .saved(host.id, profile: profile?.id),
title: host.displayName,
subtitle: "\(host.address):\(String(host.port))",
profile: profile ?? bound,
isPinnedCard: profile != nil,
isOnline: online,
isPaired: host.pinnedSHA256 != nil,
isConnecting: connecting,
filled: true,
// A pinned card is a shortcut, not a second host — Y (library) stays on the
// host's own tile, where the host-level actions live.
hasLibrary: profile == nil,
osChain: host.osChain,
canWake: autoWakeEnabled && PunktfunkConnection.wakeOnLANAvailable
&& !online && !host.wakeMacs.isEmpty,
activate: {
connect(host, profile.map { .profile($0.id) } ?? .inherit)
}))
}
}
let discovered = discovery.unsaved(among: store.hosts).map { d in
HomeTile(
id: .discovered(d.id),
title: d.name,
subtitle: "\(d.host):\(String(d.port))",
isOnline: true,
osChain: d.osChain,
activate: { connectDiscovered(d) })
}
let add = HomeTile(
id: .addHost,
title: "Add Host",
subtitle: "Register a host by address",
icon: "plus",
activate: { showAddHost = true })
// A controller surface has no toolbar and no pull-to-refresh, so the rescan the field
// asked for is a tile like any other — one press from wherever the stick already is.
let rescan = HomeTile(
id: .rescan,
title: "Rescan",
subtitle: discovery.isScanning ? "Scanning…" : "Look for hosts on this network",
icon: "arrow.clockwise",
activate: { discovery.refresh() })
return saved + discovered + [add, rescan]
}
/// Only saved hosts have a library — matches the touch grid, where "Browse Library…" is a
/// `HostCardView`-only action never offered on `DiscoveredCardView`.
private func openLibraryForSelected() {
guard libraryEnabled, case .saved(let id, let profile) = selection, profile == nil,
let host = store.hosts.first(where: { $0.id == id })
else { return }
libraryTarget = host
}
}
/// One "console tile" in the host carousel — a dark-glass landscape card, bigger and bolder than the
/// touch grid's `HostCardView`. Renders only its base look; the centered-tile pop is layered on by
/// the caller's `.scrollTransition` so it always tracks the real scroll position.
private struct GamepadHostTile: View {
@Environment(\.gamepadInk) private var ink
let tile: HomeTile
let size: CGSize
// 10-foot metrics on tvOS, in-hand metrics elsewhere — one tile, two viewing distances.
#if os(tvOS)
private static let titleFont: CGFloat = 33
private static let subtitleFont: CGFloat = 19
private static let statusFont: CGFloat = 15
private static let pipSide: CGFloat = 12
private static let badgeSide: CGFloat = 70
private static let badgeCorner: CGFloat = 19
private static let monogramFont: CGFloat = 34
private static let iconFont: CGFloat = 32
private static let pad: CGFloat = 28
private static let corner: CGFloat = 30
#else
private static let titleFont: CGFloat = 23
private static let subtitleFont: CGFloat = 13
private static let statusFont: CGFloat = 11
private static let pipSide: CGFloat = 9
private static let badgeSide: CGFloat = 52
private static let badgeCorner: CGFloat = 15
private static let monogramFont: CGFloat = 25
private static let iconFont: CGFloat = 24
private static let pad: CGFloat = 20
private static let corner: CGFloat = 26
#endif
var body: some View {
VStack(alignment: .leading, spacing: 0) {
HStack(alignment: .top, spacing: 8) {
monogramBadge
Spacer(minLength: 0)
// The status the removed detail panel used to spell out, now on the card itself: a
// lock for a paired (pinned-identity) host + a green pip when it's live on the LAN.
HStack(spacing: 7) {
if tile.isPaired {
Image(systemName: "lock.fill")
.font(.system(size: Self.statusFont, weight: .semibold))
.foregroundStyle(ink.fg(0.5))
}
if tile.isOnline {
// Status colours stay palette-independent (a pip must not change meaning
// with the wallpaper) — only the glow softens on a pale field, where it
// reads as a smudge at full strength.
Circle()
.fill(GamepadInk.onlineGreen)
.frame(width: Self.pipSide, height: Self.pipSide)
.shadow(
color: GamepadInk.onlineGreen.opacity(ink.isLight ? 0.45 : 0.7),
radius: 5)
}
}
}
Spacer(minLength: 0)
Text(tile.title)
.font(.geist(Self.titleFont, .bold, relativeTo: .title2))
.foregroundStyle(ink.fg)
.lineLimit(1)
.minimumScaleFactor(0.7)
if let profile = tile.profile {
ProfileChip(
profile: profile, size: Self.statusFont, prominent: tile.isPinnedCard)
.padding(.top, 4)
}
Text(tile.subtitle)
.font(.geist(Self.subtitleFont, relativeTo: .caption))
.foregroundStyle(ink.fg(0.55))
.lineLimit(1)
.padding(.top, 2)
}
.padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading)
// Liquid Glass console tile — a brand wash marks a saved host as primary; discovered /
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself.
.consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous),
tint: tile.filled ? ink.accent(0.20) : nil)
.overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [ink.fg(0.22), ink.fg(0.04)],
startPoint: .top, endPoint: .bottom),
style: StrokeStyle(lineWidth: 1, dash: tile.filled ? [] : [6, 5]))
}
.shadow(color: ink.shadow(0.45), radius: 20, y: 14)
}
private var monogramBadge: some View {
let shape = RoundedRectangle(cornerRadius: Self.badgeCorner, style: .continuous)
return ZStack {
shape.fill(tile.filled
? AnyShapeStyle(LinearGradient(
colors: [ink.accent, ink.accent(0.68)],
startPoint: .top, endPoint: .bottom))
: AnyShapeStyle(ink.accent(0.16)))
if tile.isConnecting {
ProgressView().tint(ink.fg)
} else if let icon = tile.icon {
Image(systemName: icon)
.font(.system(size: Self.iconFont, weight: .semibold))
.foregroundStyle(ink.accent)
} else if let mark = osIconImage(for: tile.osChain) {
// The OS mark stands in for the initial (template asset — tints like the text it
// replaces), and carries the label, since nothing else on the tile names the OS.
mark
.resizable()
.scaledToFit()
.frame(width: Self.monogramFont, height: Self.monogramFont)
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
.accessibilityLabel(tile.osChain ?? "")
} else {
Text(monogram(tile.title))
.font(.geistFixed(Self.monogramFont, .bold))
.foregroundStyle(tile.filled ? ink.fg : ink.accent)
}
}
.frame(width: Self.badgeSide, height: Self.badgeSide)
.overlay {
if !tile.filled {
shape.strokeBorder(ink.accent(0.5), lineWidth: 1)
}
}
}
private func monogram(_ name: String) -> String {
guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" }
return String(first).uppercased()
}
}
#endif