// The host grid's cards: a saved host (tap to connect, context menu) and an mDNS-discovered // host (tap to save + connect). Both share the "monogram module" look — a squared brand-purple // monogram tile + a left-aligned bold Geist name over monospaced technical metadata // (address, status), framed by a hairline panel border. Industrial, not soft. import PunktfunkKit import SwiftUI /// Shared host-card sizing — touch-first on iOS, compact on macOS, roomy on tvOS. private struct CardMetrics { let tile: CGFloat // monogram tile side let monogram: CGFloat // monogram letter point size let name: CGFloat // host-name point size let meta: CGFloat // address (mono) point size let status: CGFloat // status-label (mono) point size let padding: CGFloat let spacing: CGFloat // tile ↔ text gap let radius: CGFloat static var current: CardMetrics { #if os(iOS) CardMetrics(tile: 54, monogram: 26, name: 19, meta: 13, status: 11, padding: 16, spacing: 14, radius: 12) #elseif os(tvOS) CardMetrics(tile: 64, monogram: 32, name: 24, meta: 16, status: 14, padding: 18, spacing: 18, radius: 14) #else CardMetrics(tile: 44, monogram: 21, name: 15, meta: 12, status: 10.5, padding: 13, spacing: 12, radius: 10) #endif } } /// First letter of a host name, uppercased — the monogram glyph. Falls back to a bullet. private func monogram(_ name: String) -> String { guard let first = name.trimmingCharacters(in: .whitespacesAndNewlines).first else { return "•" } return String(first).uppercased() } /// The squared monogram tile. `filled` = a solid brand-purple chip (saved hosts); otherwise a /// tinted outline (discovered hosts). Shows a spinner in place of the glyph while connecting. private func monogramTile(_ letter: String, m: CardMetrics, connecting: Bool, filled: Bool) -> some View { let shape = RoundedRectangle(cornerRadius: m.radius - 3, style: .continuous) return ZStack { shape.fill(filled ? AnyShapeStyle(LinearGradient( colors: [Color.brand, Color.brand.opacity(0.72)], startPoint: .top, endPoint: .bottom)) : AnyShapeStyle(Color.brand.opacity(0.14))) if connecting { ProgressView().tint(filled ? .white : Color.brand) } else { // Fixed size (not Dynamic Type): the glyph is pinned inside a fixed tile, so it must // not scale up and spill out at large accessibility text sizes. minimumScaleFactor + // the clip below are belt-and-suspenders for an unusually wide glyph. Text(letter) .font(.geistFixed(m.monogram, .bold)) .minimumScaleFactor(0.5) .lineLimit(1) .foregroundStyle(filled ? Color.white : Color.brand) } } .frame(width: m.tile, height: m.tile) .clipShape(shape) .overlay { if !filled { shape.strokeBorder(Color.brand.opacity(0.45), lineWidth: 1) } } } /// A saved host. A left accent bar marks the most-recently-connected one; the context menu /// pairs / speed-tests / forgets / removes. Disabled while a session is busy. struct HostCardView: View { let host: StoredHost /// Currently advertising on the LAN (matched against live mDNS discovery). False means /// "not seen on this network" — off, or a remote/cross-subnet host we can't observe. let isOnline: Bool let isConnecting: Bool let isMostRecent: Bool let isBusy: Bool let onConnect: () -> Void let onPair: () -> Void let onSpeedTest: () -> Void let onForget: () -> Void let onRemove: () -> Void /// Open the experimental library browser — nil (no menu item) unless the feature flag is on. var onBrowseLibrary: (() -> Void)? = nil var body: some View { let m = CardMetrics.current return Button(action: onConnect) { HStack(spacing: m.spacing) { monogramTile(monogram(host.displayName), m: m, connecting: isConnecting, filled: true) VStack(alignment: .leading, spacing: 4) { Text(host.displayName) .font(.geist(m.name, .bold, relativeTo: .title3)) .foregroundStyle(.primary) .lineLimit(1) Text("\(host.address):\(String(host.port))") .font(.geist(m.meta, relativeTo: .caption)) .foregroundStyle(.secondary) .lineLimit(1) statusRow(m) } Spacer(minLength: 0) } .padding(m.padding) .frame(maxWidth: .infinity, alignment: .leading) #if !os(tvOS) // tvOS: the .card button style owns platter + focus motion; extra chrome mutes it. // Elsewhere: a flat material panel with a hairline border (industrial, not a soft blob), // and a brand accent bar down the leading edge for the most-recent host. .background(.regularMaterial) .overlay(alignment: .leading) { if isMostRecent { Rectangle().fill(Color.brand).frame(width: 3) } } .clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: m.radius, style: .continuous) .strokeBorder(.quaternary, lineWidth: 1) } #endif } #if os(tvOS) .buttonStyle(.card) #elseif os(iOS) .buttonStyle(HostCardButtonStyle(cornerRadius: m.radius)) #else .buttonStyle(.plain) #endif .disabled(isBusy) .contextMenu { Button("Pair with PIN…", action: onPair) Button("Test Network Speed…", action: onSpeedTest) if let onBrowseLibrary { Button("Browse Library…", action: onBrowseLibrary) } if host.pinnedSHA256 != nil { // Dropping the pin does NOT downgrade to TOFU: the next connect must re-pair via // PIN (unless the host advertises pair=optional). Wording reflects that. Button("Forget Identity (re-pair to reconnect)", action: onForget) } Button("Remove", role: .destructive, action: onRemove) } } /// Technical status line: a square presence pip + monospaced ONLINE/OFFLINE, and PAIRED when a /// certificate is pinned (the lock state, spelled out). @ViewBuilder private func statusRow(_ m: CardMetrics) -> some View { HStack(spacing: 6) { RoundedRectangle(cornerRadius: 1.5) .fill(isOnline ? Color.green : Color.secondary.opacity(0.4)) .frame(width: 6, height: 6) // The state is spelled out in the adjacent text, so the pip is decorative — // otherwise VoiceOver reads the status twice ("Online, ONLINE …"). .accessibilityHidden(true) Text(isOnline ? "ONLINE" : "OFFLINE") if host.pinnedSHA256 != nil { Text("· PAIRED") } } .font(.geist(m.status, .medium, relativeTo: .caption2)) .tracking(0.8) .foregroundStyle(.secondary) .lineLimit(1) } } /// A host found on the LAN but not yet saved. A tinted-outline monogram + dashed panel border /// distinguish it from saved cards; tapping saves it and connects (or pairs, if required). struct DiscoveredCardView: View { let discovered: DiscoveredHost let isBusy: Bool let onConnect: () -> Void var body: some View { let m = CardMetrics.current return Button(action: onConnect) { HStack(spacing: m.spacing) { monogramTile(monogram(discovered.name), m: m, connecting: false, filled: false) VStack(alignment: .leading, spacing: 4) { Text(discovered.name) .font(.geist(m.name, .bold, relativeTo: .title3)) .foregroundStyle(.primary) .lineLimit(1) Text("\(discovered.host):\(String(discovered.port))") .font(.geist(m.meta, relativeTo: .caption)) .foregroundStyle(.secondary) .lineLimit(1) HStack(spacing: 6) { Image(systemName: discovered.requiresPairing ? "lock.fill" : "antenna.radiowaves.left.and.right") .font(.system(size: m.status)) .accessibilityHidden(true) // decorative; the adjacent text says the state Text(discovered.requiresPairing ? "PAIRING REQUIRED" : "DISCOVERED") } .font(.geist(m.status, .medium, relativeTo: .caption2)) .tracking(0.8) .foregroundStyle(.secondary) .lineLimit(1) } Spacer(minLength: 0) } .padding(m.padding) .frame(maxWidth: .infinity, alignment: .leading) #if !os(tvOS) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: m.radius, style: .continuous)) .overlay { RoundedRectangle(cornerRadius: m.radius, style: .continuous) .strokeBorder( Color.secondary.opacity(0.3), style: StrokeStyle(lineWidth: 1, dash: [4, 3])) } #endif } #if os(tvOS) .buttonStyle(.card) #elseif os(iOS) .buttonStyle(HostCardButtonStyle(cornerRadius: m.radius)) #else .buttonStyle(.plain) #endif .disabled(isBusy) } } #if os(iOS) /// The iOS host-card press/hover treatment, one style for both idioms: /// - iPhone: a subtle scale-down on press + a light impact haptic on press-down. (`hoverEffect` is /// inert without a pointer.) /// - iPad: the system pointer "magnet" — the cursor morphs into a highlight that conforms to the /// card's rounded rect on hover. (`sensoryFeedback` is inert without a Taptic Engine, and the /// press scale doubles as click feedback.) struct HostCardButtonStyle: ButtonStyle { var cornerRadius: CGFloat func makeBody(configuration: Configuration) -> some View { configuration.label .scaleEffect(configuration.isPressed ? 0.96 : 1) .animation(.spring(response: 0.3, dampingFraction: 0.65), value: configuration.isPressed) // Conform the pointer highlight to the card's rounded rect, not its square bounds. .contentShape(.hoverEffect, RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) .hoverEffect(.highlight) // Light tap on press-down (nil on release so it fires once, on touch). No haptic // hardware on iPad → silently ignored there. .sensoryFeedback(trigger: configuration.isPressed) { _, pressed in pressed ? .impact(weight: .light) : nil } } } #endif