// GlassStyle.swift — the app's single, availability-gated entry point to Apple's "Liquid // Glass" (iOS / macOS / tvOS 26). Every Liquid Glass symbol (glassEffect, Glass, the // .glassProminent button style …) is HARD-gated to OS 26: referencing one with our // deployment targets (macOS 14 / iOS 17 / tvOS 17) is a COMPILE error, not a silent no-op, // unless it sits behind `if #available`. So all glass in the app routes through the two // helpers below, each of which falls back to the EXACT look the app shipped before // (.regularMaterial / .borderedProminent) — nothing regresses on older OSes, and the gating // lives in exactly one file. import SwiftUI // MARK: - Glass background /// Liquid Glass behind a floating / overlay surface, with the pre-26 `.regularMaterial` /// look as the fallback. Use ONLY on the floating control / overlay layer (the streaming /// HUD, the trust card, the touch exit chip) — never on content tiles or dense forms (HIG). /// /// `glassEffect()`'s own default shape is a Capsule, so panels MUST pass an explicit shape /// (a RoundedRectangle / Circle) or they render as a pill. `interactive` makes the glass /// react to press — only meaningful when the glass itself is the tap target. private struct GlassBackground: ViewModifier { let shape: S var interactive = false func body(content: Content) -> some View { if #available(iOS 26, macOS 26, tvOS 26, *) { content.glassEffect(interactive ? .regular.interactive() : .regular, in: shape) } else { content.background(.regularMaterial, in: shape) } } } extension View { /// Liquid Glass (26+) or the existing `.regularMaterial` (pre-26) behind a floating /// surface. Pass the surface's shape explicitly — glass defaults to a Capsule otherwise. func glassBackground(_ shape: S, interactive: Bool = false) -> some View { modifier(GlassBackground(shape: shape, interactive: interactive)) } } // MARK: - Glass primary button /// The single prominent action on a floating / overlay or sheet surface: the Liquid-Glass /// prominent button style on 26+, falling back to `.borderedProminent` (the app's current /// primary style) below. Apply directly to a `Button`; role / keyboardShortcut / disabled /// chain after it as usual. tvOS stays `.borderedProminent` always — glass chrome fights the /// focus engine, and keeping it preserves today's tvOS look exactly. private struct GlassProminentButton: ViewModifier { func body(content: Content) -> some View { #if os(tvOS) content.buttonStyle(.borderedProminent) #else if #available(iOS 26, macOS 26, *) { content.buttonStyle(.glassProminent) } else { content.buttonStyle(.borderedProminent) } #endif } } extension View { /// Liquid-Glass prominent style (26+, non-tvOS) or `.borderedProminent`. Drop-in for the /// `.buttonStyle(.borderedProminent)` on a surface's primary action. func glassProminentButtonStyle() -> some View { modifier(GlassProminentButton()) } } // MARK: - Console glass (gamepad host tiles + settings rows) /// Liquid Glass tuned for the gamepad UI's "console" surfaces — the host-carousel tiles and /// the settings rows. Unlike `glassBackground` (floating-overlay only, per HIG), this deliberately /// clads content tiles / dense rows: a chosen part of the 10-foot console look. `tint` washes the /// glass toward a color (the palette accent on the focused / primary surface); `interactive` makes /// it flex on press. /// /// Every tier is WASHED with the palette's `ink.glass` — the same surface colour the console /// fills its panels with — so switching the background palette recolours the surfaces, not just /// the text on them. The wash alphas are tune-on-device values with one fixed direction: the /// pale palettes' white frost needs MORE body than the dark glass (the console's 0.66-vs-0.62 /// pair), because a thin white wash over a colourful field reads as haze, not as a surface. private struct ConsoleGlass: ViewModifier { let shape: S var tint: Color? var interactive = false /// Take the MATERIAL path even where real Liquid Glass is available. For surfaces that get /// transformed while they animate: glass samples the backdrop through its own layer, and under /// a `rotation3DEffect` / `opacity` it cannot, so it renders one way mid-animation and snaps to /// another the instant the transform ends — on glass that reads as the tile being SWAPPED for a /// different one as it lands. A material is a flat composite and looks identical throughout. var forceMaterial = false /// The console surface follows the background palette: a PALE field needs the material to /// frost light and the glass to read as white, or the dark ink on top of it disappears. /// Defaults to the dark ink, so every non-gamepad caller is unchanged. @Environment(\.gamepadInk) private var ink private var scheme: ColorScheme { ink.isLight ? .light : .dark } /// The palette wash over the material tiers (the material itself supplies the blur body). private var materialWash: Color { ink.glass(ink.isLight ? 0.55 : 0.40) } func body(content: Content) -> some View { // The scheme goes on the WHOLE modified view, not just the fill inside `.background {}`. // Scoped to the fill it frosts the material correctly and stops there, so a system colour // in the row's own content (a `.secondary` label, a `.bordered` button) still resolved // against the device appearance — which is how the pale palettes came out light-on-light // on tvOS, whose appearance is always Dark. The 26 branch had it right all along; the // tvOS and pre-26 branches were the odd ones out. #if os(tvOS) // ALWAYS the material fallback on tvOS: the gamepad settings list is 15+ of these // surfaces, and live Liquid Glass per row made the whole screen visibly laggy on the // Apple TV's GPU (same class of call GlassProminentButton already makes — glass fights // the 10-foot platform). The wash and tint ride overlays — two flat fills, no GPU cost. content .background { shape.fill(.ultraThinMaterial) .environment(\.colorScheme, scheme) .overlay { shape.fill(materialWash) } .overlay { if let tint { shape.fill(tint) } } } .environment(\.colorScheme, scheme) #else if #available(iOS 26, macOS 26, *), !forceMaterial { content // The caller's tint rides HERE, not in `Glass.tint`, so it can ANIMATE. A Glass // value is opaque to SwiftUI's animation system: changing its tint swaps one // effect for another, which is why a focused row's accent used to appear (and, // worse, disappear a beat late) as a hard jump while the row's scale animated // smoothly beside it. A plain fill interpolates, so `.animation(value: focused)` // at the call site now covers the whole row. Sits between the glass and the // content: `.background` is behind the label, `glassEffect` behind both. .background { shape.fill(tint ?? .clear) } .glassEffect(glass, in: shape) .environment(\.colorScheme, scheme) } else { content .background { shape.fill(.ultraThinMaterial) .environment(\.colorScheme, scheme) .overlay { shape.fill(materialWash) } .overlay { if let tint { shape.fill(tint) } } } .environment(\.colorScheme, scheme) } #endif } #if !os(tvOS) @available(iOS 26, macOS 26, *) private var glass: Glass { // The glass carries the PALETTE wash only — the caller's focus tint is an animatable fill // above it now (see `body`). // // A pale palette gets `.clear` glass, not `.regular`. Its `ink.glass` is literal white, so // over `.regular` — which is already a bright, high-body material — even a light white // wash lands as a flat white slab: the refraction and the blurred field behind stop // reading entirely, which is the "opaque fully white bg" on every row, pill and legend. // Lowering the tint alone did NOT fix it, because the opacity was coming from the glass // BODY rather than from the tint. `.clear` is the variant meant for exactly this — a // surface over content that must stay visible through it — and a small white wash on top // of it is enough to keep the dark ink legible without closing the surface up. let wash = ink.glass(ink.isLight ? 0.18 : 0.45) // Spelled out rather than `.clear`/`.regular`: a ternary between two leading-dot members // gives the compiler no base type to infer from. var g: Glass = (ink.isLight ? Glass.clear : Glass.regular).tint(wash) if interactive { g = g.interactive() } return g } #endif } extension View { /// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial` /// pre-26 — both washed with the palette's own glass colour, both frosting to the palette's /// scheme. Pass the surface's shape explicitly — glass defaults to a Capsule. /// /// `forceMaterial` opts a TRANSFORMED surface out of live glass; see the property. func consoleGlass( _ shape: S, tint: Color? = nil, interactive: Bool = false, forceMaterial: Bool = false ) -> some View { modifier(ConsoleGlass( shape: shape, tint: tint, interactive: interactive, forceMaterial: forceMaterial)) } } // MARK: - Console floating glass (the gamepad screens' close buttons) /// `glassBackground` for a floating control INSIDE the gamepad UI (the close ✕): same shape /// contract, but washed with the palette's ink and frosted to the palette's scheme — plain /// `glassBackground` follows the SYSTEM appearance, which leaves the frost dark under dark ink /// when a pale palette is up. The non-gamepad floating surfaces (the HUD, the trust card, the /// touch connect modal) keep plain `glassBackground`: they sit over video or the touch UI, /// where the palette means nothing. private struct ConsoleGlassBackground: ViewModifier { let shape: S var interactive = false @Environment(\.gamepadInk) private var ink private var scheme: ColorScheme { ink.isLight ? .light : .dark } func body(content: Content) -> some View { if #available(iOS 26, macOS 26, tvOS 26, *) { content .glassEffect( (interactive ? Glass.regular.interactive() : .regular) .tint(ink.glass(ink.isLight ? 0.60 : 0.45)), in: shape) .environment(\.colorScheme, scheme) } else { // Same hoist as ConsoleGlass: the content needs the scheme too, not only the frost. content .background { shape.fill(.regularMaterial) .environment(\.colorScheme, scheme) .overlay { shape.fill(ink.glass(ink.isLight ? 0.55 : 0.40)) } } .environment(\.colorScheme, scheme) } } } extension View { /// Palette-washed floating glass for the gamepad screens' own controls. Same fallback story /// as `glassBackground` (`.regularMaterial` pre-26), plus the ink wash and scheme flip. func consoleGlassBackground(_ shape: S, interactive: Bool = false) -> some View { modifier(ConsoleGlassBackground(shape: shape, interactive: interactive)) } }