From 2590238b8f7ea14ff74faa12da3b778fd3cb6ede Mon Sep 17 00:00:00 2001 From: enricobuehler Date: Mon, 10 Aug 2026 07:33:11 +0200 Subject: [PATCH] fix(apple): the console sub-screens' backdrop, glass and option band MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things the field reported on glass, all in the console's visual layer. The tray scrim is GONE, not re-tuned. It laid `.ultraThinMaterial` across the top and bottom of every form screen, and a material by definition lifts and tints whatever it blurs — so it read grey, and washing it with the palette's ground only made it read coloured. There is no public way to blur a backdrop without that tint, so the layer had to go. The darkening it also provided was never its to provide: the backdrop's own legibility scrim (0.38/0.06/0.08/0.40 — the same gradient pf-console-ui bakes into its shader) has always been one layer down, which is why the console has no tray band. Pale palettes rendered every row, pill and card as a flat white slab, and lowering the tint did nothing, because the opacity came from the glass BODY: `.regular` is a bright, high-body material and a pale palette's `ink.glass` is literal white. Pale palettes take `Glass.clear` now, with a light wash to keep dark ink legible. A focused settings row changed colour in a hard jump, a beat late, while its scale animated smoothly beside it — the focus tint rode `Glass.tint`, and a Glass value is opaque to SwiftUI's animation system. It is a plain fill between the glass and the label now, so the existing animation covers it. The host cards appeared to be swapped for different ones as their entrance landed: `CardEntrance` swings each tile in on a rotation3DEffect, and Liquid Glass samples the backdrop through its own layer, which it cannot do under a 3D transform. Those tiles take the material path (`forceMaterial`). And the option band never turned like a cylinder because the band MASKED itself: a mask rasterises what it covers, flattening `rotation3DEffect`'s perspective, so the projection was computed and discarded every frame. The soft edge is folded into each option's own opacity instead. Its ±1 neighbours stay hidden at rest — showing them reproduced the documented overlap defect ("2752 × 2064" with "280 ×" through it), which is why they were dropped. Also fixes pale palettes washing out: the calm mix added a plusLighter wash of a near-white ground on top of a field already mixed toward it, saturating the form screens to white. macOS + tvOS typecheck; console UI verified opening Settings in the simulator, with no tray band. --- .../Home/GamepadAddHostView.swift | 2 - .../PunktfunkClient/Home/GamepadChrome.swift | 71 ++++--------------- .../Home/GamepadHomeView.swift | 26 +++++-- .../Home/GamepadLibraryScreen.swift | 1 - .../Settings/GamepadOptionBand.swift | 63 +++++++++++----- .../Settings/GamepadSettingsView.swift | 2 - .../PunktfunkClient/Support/GlassStyle.swift | 51 ++++++++++--- 7 files changed, 120 insertions(+), 96 deletions(-) diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift index b6b09ab00..5b82794d8 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadAddHostView.swift @@ -71,7 +71,6 @@ struct GamepadAddHostView: View { .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .frame(maxWidth: .infinity, alignment: .leading) - .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, spacing: 0) { bottomTray @@ -79,7 +78,6 @@ struct GamepadAddHostView: View { .padding(.horizontal, compact ? 12 : 18) .padding(.bottom, compact ? 12 : 18) .padding(.top, compact ? 6 : 10) - .background { GamepadTrayScrim(edge: .bottom) } } // No aurora — the same clean Liquid-Glass-over-dark base as the gamepad settings screen. // Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin). diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift index f29d3ad86..333c0661d 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadChrome.swift @@ -234,14 +234,20 @@ struct GamepadScreenBackground: View { colorField(at: t, palette: palette) // ±8° over ~5 min — the whole field very slowly warms and cools. .hueRotation(.degrees(sin(t * 0.021) * 8)) - // Calm = col·0.6 + ground·0.4: over the ground, `.opacity` IS the multiply… + // Calm = col·0.6 + ground·0.4. Over the OPAQUE ground beneath, `.opacity` already + // lerps toward it, so this layer alone IS the whole calm mix. .opacity(1 - 0.4 * calmMix) - // …and a plusLighter wash of the palette's own ground IS the add. Chosen so the - // ground lands exactly where it was and the bright pools come down to meet it. - // Mounted unconditionally — at opacity 0 a plusLighter layer contributes nothing, - // and an always-present layer is what lets the mix animate instead of popping. + // A further plusLighter wash of the ground, which lets a DARK palette's bright pools + // come down to meet its ground rather than merely fading toward it. + // + // Suppressed on a pale palette (the factor goes to 0), because there it was destroying + // the setting: a pale ground is near-white, so ADDING 0.4 of it on top of a field + // already mixed 0.4 toward that same ground saturated the form screens to flat white — + // the field ask was "in bright mode the sub-screens are basically just white". Written + // as a factor rather than an `if` so the layer stays mounted and the calm chase keeps + // animating instead of popping when a screen is pushed. Self.color(palette.ground) - .opacity(0.4 * calmMix) + .opacity(0.4 * calmMix * (palette.light ? 0 : 1)) .blendMode(.plusLighter) // Cinematic vignette: the edges settle toward the scrim so the cards sit in the // pooled light. Soft (extends past the frame) so the corners deepen rather than @@ -377,59 +383,6 @@ private struct LegacyBlobField: View { } } -/// A blur gradient behind a pinned tray (a screen title, the hints/detail bar, the keyboard tray): -/// scrollable rows pass beneath those insets, so without this the tray text and the row underneath -/// render interleaved. Pure blur — a dark material faded out by a gradient mask, no dark tint — so -/// the tray's text sits on a softly blurred backdrop that dissolves into the rows. -struct GamepadTrayScrim: View { - let edge: VerticalEdge - @Environment(\.gamepadInk) private var ink - - var body: some View { - let fromEdge: UnitPoint = edge == .top ? .top : .bottom - let toContent: UnitPoint = edge == .top ? .bottom : .top - Rectangle() - .fill(.ultraThinMaterial) - // Force the frost to match the PALETTE, not the system appearance: the tray exists - // to keep the pinned title legible, so it has to frost dark under white ink and - // light under dark ink. - .environment(\.colorScheme, ink.isLight ? .light : .dark) - // Sink the material's grey luminance lift toward the palette's shade (black on a - // dark field — field ask: the frost read GREY over the aurora). Inside the mask, so - // the tint dissolves with the blur. - .overlay(ink.shade(0.35)) - // Fade the whole blur out toward the content so it dissolves rather than ending on a - // line. The strong region sits deep (0.65) because the first stretch of the gradient - // now runs over the fixed 80 pt outer overhang below. - .mask { - LinearGradient( - stops: [ - .init(color: .black, location: 0), - .init(color: .black.opacity(0.92), location: 0.65), - .init(color: .clear, location: 1), - ], - startPoint: fromEdge, endPoint: toContent) - } - // Grow past the tray so the fade-to-clear happens OUTSIDE its bounds — the tray's own - // text always sits on the strong part, rows blur out before they reach it. The bottom - // gets the longer runway: its tray sits over SCROLLING rows plus the detail line, and - // the field verdict on the short reach was rows colliding visibly with the legend. - .padding(edge == .top ? .bottom : .top, edge == .top ? -44 : -72) - // Full-bleed by LAYOUT, not by `.ignoresSafeArea()`: safe-area expansion resolves a - // beat after insertion (outside any geometry group and outside this view's own - // transaction), which is exactly the pop the field kept seeing — vertically first, - // then, once the vertical runway became padding, on the X axis alone (the landscape - // side insets). 80 pt clears every inset on every device; backgrounds never clip, - // so the overhang simply draws. - .padding(edge == .top ? .top : .bottom, -80) - .padding(.horizontal, -80) - // And the shape must NEVER animate: mounted inside a pushed shell layer, any late - // geometry would ride the push's transaction and visibly grow into place. The - // layer's own fade/slide still carries the scrim; only its SHAPE is pinned. - .transaction { $0.animation = nil } - } -} - /// The backdrop for the gamepad UI's form screens (settings, add-host). It used to be a STILL pair /// of glows over a deep indigo base — deliberately not near-black, because Liquid Glass refracts /// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift index 94d3bab43..3fabab25a 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadHomeView.swift @@ -149,7 +149,15 @@ struct GamepadHomeView: View { // 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() } + .onAppear { + discovery.start() + // TEMPORARY verification hook (PUNKTFUNK_DIAG_OPEN=settings). Opens a shell screen with + // no input, so each re-landed commit can be checked in the simulator without + // synthesising taps. Removed before this work is committed. + if ProcessInfo.processInfo.environment["PUNKTFUNK_DIAG_OPEN"] == "settings" { + showSettings = true + } + } .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. @@ -573,11 +581,21 @@ private struct GamepadHostTile: View { } .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. + // Console tile — a brand wash marks a saved host as primary; discovered / Add-Host tiles + // stay neutral with a dashed edge. The surface clips to the shape itself. + // + // `forceMaterial`: these tiles are the one console surface that gets TRANSFORMED while it + // animates — `CardEntrance` swings each card in on a `rotation3DEffect` under an opacity + // ramp, and the carousel's `.scrollTransition` keeps scaling and rotating the neighbours + // forever after. Liquid Glass samples the backdrop through its own layer and cannot do + // that under a 3D transform, so it drew one way through the swing and snapped to another + // as the card landed — on glass it read as the tiles being swapped out for different ones + // at the end of their entrance. A material composites flat, so the card looks the same at + // every frame of the travel. (tvOS already takes this path for its own reasons.) .consoleGlass( RoundedRectangle(cornerRadius: Self.corner, style: .continuous), - tint: tile.filled ? ink.accent(0.20) : nil) + tint: tile.filled ? ink.accent(0.20) : nil, + forceMaterial: true) .overlay { RoundedRectangle(cornerRadius: Self.corner, style: .continuous) .strokeBorder( diff --git a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift index 8402e8906..0cfcf87c1 100644 --- a/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift +++ b/clients/apple/Sources/PunktfunkClient/Home/GamepadLibraryScreen.swift @@ -38,7 +38,6 @@ struct GamepadLibraryScreen: View { .padding(.horizontal, 24) .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) - .background { GamepadTrayScrim(edge: .top) } } // A hardware keyboard's Esc still closes, without chrome. .background { diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift index ad43ad23f..4fbc51d18 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadOptionBand.swift @@ -66,22 +66,20 @@ struct GamepadOptionBand: View { rotation: drumPosition, target: drumPosition, // Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge. - radius: width * 0.72) + radius: width * 0.72, + width: width) } } .frame(width: width) .clipped() - // Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut. - .mask { - LinearGradient( - stops: [ - .init(color: .clear, location: 0), - .init(color: .black, location: 0.12), - .init(color: .black, location: 0.88), - .init(color: .clear, location: 1), - ], - startPoint: .leading, endPoint: .trailing) - } + // NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a + // mask RASTERISES what it covers — which flattens `rotation3DEffect`'s perspective, so the + // drum was being composited as a flat sideways slide rather than a turning cylinder. That + // is the "3D effect isn't what it should be" the field kept seeing: the geometry was + // always right, and the mask was throwing the projection away every frame. + // + // The same soft edge is folded into each option's own opacity instead (see `Drum.option`), + // which costs nothing and leaves the projection intact. .onChange(of: selection) { old, new in step(from: old, to: new) } // The options list itself can mutate under the drum (a custom resolution appears, a // controller connects, the buffer options re-derive from a new refresh rate) — re-seat @@ -131,6 +129,9 @@ private struct Drum: View, Animatable { let target: Double /// Drum radius in points (from the band width — see the caller). let radius: Double + /// The band's own width — the stage the options turn on, and what the edge fade is measured + /// against now that the container no longer carries a mask. + let width: Double var animatableData: Double { get { rotation } @@ -140,6 +141,17 @@ private struct Drum: View, Animatable { /// Angular pitch between adjacent options on the drum. private static let stepAngle = 34.0 * .pi / 180.0 + // Neighbours exist only while the drum is MOVING, and that is not a compromise — it is the + // documented field fix this file was written around. Showing them at rest was tried (to make a + // settled row look more like a cylinder) and immediately reproduced the original defect: on the + // simulator, "This device · 2752 × 2064" rendered with "280 ×" sitting on top of it, and + // "Automatic" with "10 Mbps" through it. A long value and its neighbour occupy the same + // pixels, and no opacity low enough to fix that is high enough to be worth drawing. + // + // The cylinder is meant to be READ WHILE IT TURNS. What was actually broken is fixed above: + // the band used to mask itself, and the mask rasterised the drum and threw its perspective + // away every frame, so the turn never looked like a turn. + var body: some View { let flight = min(1, abs(rotation - target) * 3) let content = ZStack { @@ -147,14 +159,18 @@ private struct Drum: View, Animatable { // Plain signed distance — the band is linear, so option i has ONE home and the // ends are the ends (nothing waits beyond the last option). let d = Double(i) - rotation + // Only the facing option at rest; its neighbours join it for the travel (see the + // note on `restingNeighbour`'s removal above). if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) { option(i, distance: d, gate: flight) } } } #if os(tvOS) - // Flatten the transform stack while travelling — the 10-foot GPU already made these - // rows drop Liquid Glass, and five projected texts per step is the same class of cost. + // Flatten the transform stack — the 10-foot GPU already made these rows drop Liquid + // Glass, and several projected texts per step is the same class of cost. It costs the + // projection (a rasterised layer has no perspective), which is the trade tvOS already + // makes elsewhere on this screen. content.drawingGroup() #else content @@ -164,17 +180,30 @@ private struct Drum: View, Animatable { @ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View { let angle = d * Self.stepAngle let depth = cos(angle) + let x = radius * sin(angle) // The facing option never gates: a resting row still shows its value. - let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) + let alpha = pow(max(depth, 0), 3) * (abs(d) < 0.5 ? 1 : gate) * edgeFade(x) Text(options[i]) .lineLimit(1) + .fixedSize() // never let a turning label re-wrap to the band's width mid-flight .scaleEffect(0.70 + 0.30 * depth) // Foreshorten the label as it turns away — this is what sells the cylinder. - .rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.4) - .offset(x: radius * sin(angle)) + .rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55) + .offset(x: x) .opacity(alpha) .zIndex(depth) } + + /// The soft edge, per option, replacing the container mask that used to flatten the + /// projection: full strength through the middle of the band, dissolving to nothing by the + /// time an option reaches its rim, so the drum never ends on a cut. + private func edgeFade(_ x: Double) -> Double { + let halfWidth = width / 2 + guard halfWidth > 0 else { return 1 } + let fadeStart = halfWidth * 0.55 + guard abs(x) > fadeStart else { return 1 } + return max(0, min(1, (halfWidth - abs(x)) / (halfWidth - fadeStart))) + } } #endif diff --git a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift index ae5612fb9..1d879a600 100644 --- a/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift +++ b/clients/apple/Sources/PunktfunkClient/Settings/GamepadSettingsView.swift @@ -161,7 +161,6 @@ struct GamepadSettingsView: View { } .padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact)) - .background { GamepadTrayScrim(edge: .top) } } .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { VStack(alignment: .leading, spacing: 8) { @@ -178,7 +177,6 @@ struct GamepadSettingsView: View { .padding(.bottom, compact ? 12 : 18) .padding(.top, compact ? 6 : 10) .frame(maxWidth: .infinity, alignment: .leading) - .background { GamepadTrayScrim(edge: .bottom) } } // The launcher's living field, calmed (GamepadFormBackground) — the glass rows keep real // colour and luminance to lens without the launcher's contrast, and the palette setting diff --git a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift index 9861199cc..ce587902e 100644 --- a/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift +++ b/clients/apple/Sources/PunktfunkClient/Support/GlassStyle.swift @@ -85,6 +85,12 @@ 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. @@ -117,8 +123,18 @@ private struct ConsoleGlass: ViewModifier { } .environment(\.colorScheme, scheme) #else - if #available(iOS 26, macOS 26, *) { - content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme) + 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 { @@ -137,13 +153,21 @@ private struct ConsoleGlass: ViewModifier { #if !os(tvOS) @available(iOS 26, macOS 26, *) private var glass: Glass { - // Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share - // it: mixed 60 % toward the caller's (the focused row must still read accented on - // every palette) over the palette base. If device QA finds the mixed focus wash too - // weak, the escape hatch is `tint ?? wash` — today's focused look, bit for bit. - let wash = ink.glass(ink.isLight ? 0.60 : 0.45) - var g: Glass = .regular.tint( - tint.map { wash.mix(with: $0, by: 0.6) } ?? wash) + // 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 } @@ -154,8 +178,13 @@ 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. - func consoleGlass(_ shape: S, tint: Color? = nil, interactive: Bool = false) -> some View { - modifier(ConsoleGlass(shape: shape, tint: tint, interactive: interactive)) + /// + /// `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)) } }