fix(apple): the console sub-screens' backdrop, glass and option band

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.
This commit is contained in:
2026-08-10 07:33:11 +02:00
parent beb639f067
commit 2590238b8f
7 changed files with 120 additions and 96 deletions
@@ -71,7 +71,6 @@ struct GamepadAddHostView: View {
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .top) }
} }
.safeAreaInset(edge: .bottom, spacing: 0) { .safeAreaInset(edge: .bottom, spacing: 0) {
bottomTray bottomTray
@@ -79,7 +78,6 @@ struct GamepadAddHostView: View {
.padding(.horizontal, compact ? 12 : 18) .padding(.horizontal, compact ? 12 : 18)
.padding(.bottom, compact ? 12 : 18) .padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 6 : 10) .padding(.top, compact ? 6 : 10)
.background { GamepadTrayScrim(edge: .bottom) }
} }
// No aurora the same clean Liquid-Glass-over-dark base as the gamepad settings screen. // 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). // Hosted in the shell, the field is the shell's (see GamepadSettingsView's twin).
@@ -234,14 +234,20 @@ struct GamepadScreenBackground: View {
colorField(at: t, palette: palette) colorField(at: t, palette: palette)
// ±8° over ~5 min the whole field very slowly warms and cools. // ±8° over ~5 min the whole field very slowly warms and cools.
.hueRotation(.degrees(sin(t * 0.021) * 8)) .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) .opacity(1 - 0.4 * calmMix)
// and a plusLighter wash of the palette's own ground IS the add. Chosen so the // A further plusLighter wash of the ground, which lets a DARK palette's bright pools
// ground lands exactly where it was and the bright pools come down to meet it. // come down to meet its ground rather than merely fading toward 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. // 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) Self.color(palette.ground)
.opacity(0.4 * calmMix) .opacity(0.4 * calmMix * (palette.light ? 0 : 1))
.blendMode(.plusLighter) .blendMode(.plusLighter)
// Cinematic vignette: the edges settle toward the scrim so the cards sit in the // 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 // 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 /// 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 /// 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 /// whatever sits behind it and over black the rows turn invisible. It is now the launcher's own
@@ -149,7 +149,15 @@ struct GamepadHomeView: View {
// Publish the palette's ink to this screen (text, glass, accent, scrims) a // 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. // pale palette flips all of them, and no leaf should have to read the setting.
.gamepadPaletteInk() .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() } .onDisappear { discovery.stop() }
// Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show // Reachability sweep (mDNS-independent) so routed/VPN hosts that never advertise still show
// Online the console mirror of HomeView's `.task`; cancelled on disappear. // Online the console mirror of HomeView's `.task`; cancelled on disappear.
@@ -573,11 +581,21 @@ private struct GamepadHostTile: View {
} }
.padding(Self.pad) .padding(Self.pad)
.frame(width: size.width, height: size.height, alignment: .leading) .frame(width: size.width, height: size.height, alignment: .leading)
// Liquid Glass console tile a brand wash marks a saved host as primary; discovered / // Console tile a brand wash marks a saved host as primary; discovered / Add-Host tiles
// Add-Host tiles stay neutral glass with a dashed edge. Glass clips to the shape itself. // 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( .consoleGlass(
RoundedRectangle(cornerRadius: Self.corner, style: .continuous), 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 { .overlay {
RoundedRectangle(cornerRadius: Self.corner, style: .continuous) RoundedRectangle(cornerRadius: Self.corner, style: .continuous)
.strokeBorder( .strokeBorder(
@@ -38,7 +38,6 @@ struct GamepadLibraryScreen: View {
.padding(.horizontal, 24) .padding(.horizontal, 24)
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
} }
// A hardware keyboard's Esc still closes, without chrome. // A hardware keyboard's Esc still closes, without chrome.
.background { .background {
@@ -66,22 +66,20 @@ struct GamepadOptionBand: View {
rotation: drumPosition, rotation: drumPosition,
target: drumPosition, target: drumPosition,
// Puts the ±1 neighbour ~40 % of the band off-centre, curling to the edge. // 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) .frame(width: width)
.clipped() .clipped()
// Soft edges: the drum dissolves before it reaches the chevrons instead of ending on a cut. // NO `.mask` here. The soft edges used to be a gradient mask over the whole band, and a
.mask { // mask RASTERISES what it covers which flattens `rotation3DEffect`'s perspective, so the
LinearGradient( // drum was being composited as a flat sideways slide rather than a turning cylinder. That
stops: [ // is the "3D effect isn't what it should be" the field kept seeing: the geometry was
.init(color: .clear, location: 0), // always right, and the mask was throwing the projection away every frame.
.init(color: .black, location: 0.12), //
.init(color: .black, location: 0.88), // The same soft edge is folded into each option's own opacity instead (see `Drum.option`),
.init(color: .clear, location: 1), // which costs nothing and leaves the projection intact.
],
startPoint: .leading, endPoint: .trailing)
}
.onChange(of: selection) { old, new in step(from: old, to: new) } .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 // 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 // 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 let target: Double
/// Drum radius in points (from the band width see the caller). /// Drum radius in points (from the band width see the caller).
let radius: Double 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 { var animatableData: Double {
get { rotation } get { rotation }
@@ -140,6 +141,17 @@ private struct Drum: View, Animatable {
/// Angular pitch between adjacent options on the drum. /// Angular pitch between adjacent options on the drum.
private static let stepAngle = 34.0 * .pi / 180.0 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 { var body: some View {
let flight = min(1, abs(rotation - target) * 3) let flight = min(1, abs(rotation - target) * 3)
let content = ZStack { 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 // 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). // ends are the ends (nothing waits beyond the last option).
let d = Double(i) - rotation 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) { if abs(d) < 0.5 || (flight > 0.001 && abs(d) <= 2.5) {
option(i, distance: d, gate: flight) option(i, distance: d, gate: flight)
} }
} }
} }
#if os(tvOS) #if os(tvOS)
// Flatten the transform stack while travelling the 10-foot GPU already made these // Flatten the transform stack the 10-foot GPU already made these rows drop Liquid
// rows drop Liquid Glass, and five projected texts per step is the same class of cost. // 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() content.drawingGroup()
#else #else
content content
@@ -164,17 +180,30 @@ private struct Drum: View, Animatable {
@ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View { @ViewBuilder private func option(_ i: Int, distance d: Double, gate: Double) -> some View {
let angle = d * Self.stepAngle let angle = d * Self.stepAngle
let depth = cos(angle) let depth = cos(angle)
let x = radius * sin(angle)
// The facing option never gates: a resting row still shows its value. // 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]) Text(options[i])
.lineLimit(1) .lineLimit(1)
.fixedSize() // never let a turning label re-wrap to the band's width mid-flight
.scaleEffect(0.70 + 0.30 * depth) .scaleEffect(0.70 + 0.30 * depth)
// Foreshorten the label as it turns away this is what sells the cylinder. // 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) .rotation3DEffect(.radians(angle), axis: (x: 0, y: 1, z: 0), perspective: 0.55)
.offset(x: radius * sin(angle)) .offset(x: x)
.opacity(alpha) .opacity(alpha)
.zIndex(depth) .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 #endif
@@ -161,7 +161,6 @@ struct GamepadSettingsView: View {
} }
.padding(.top, gamepadTitleTopPadding(compact: compact)) .padding(.top, gamepadTitleTopPadding(compact: compact))
.padding(.bottom, gamepadTitleBottomPadding(compact: compact)) .padding(.bottom, gamepadTitleBottomPadding(compact: compact))
.background { GamepadTrayScrim(edge: .top) }
} }
.safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) { .safeAreaInset(edge: .bottom, alignment: .leading, spacing: 0) {
VStack(alignment: .leading, spacing: 8) { VStack(alignment: .leading, spacing: 8) {
@@ -178,7 +177,6 @@ struct GamepadSettingsView: View {
.padding(.bottom, compact ? 12 : 18) .padding(.bottom, compact ? 12 : 18)
.padding(.top, compact ? 6 : 10) .padding(.top, compact ? 6 : 10)
.frame(maxWidth: .infinity, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading)
.background { GamepadTrayScrim(edge: .bottom) }
} }
// The launcher's living field, calmed (GamepadFormBackground) the glass rows keep real // 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 // colour and luminance to lens without the launcher's contrast, and the palette setting
@@ -85,6 +85,12 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
let shape: S let shape: S
var tint: Color? var tint: Color?
var interactive = false 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 /// 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. /// 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. /// Defaults to the dark ink, so every non-gamepad caller is unchanged.
@@ -117,8 +123,18 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
} }
.environment(\.colorScheme, scheme) .environment(\.colorScheme, scheme)
#else #else
if #available(iOS 26, macOS 26, *) { if #available(iOS 26, macOS 26, *), !forceMaterial {
content.glassEffect(glass, in: shape).environment(\.colorScheme, scheme) 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 { } else {
content content
.background { .background {
@@ -137,13 +153,21 @@ private struct ConsoleGlass<S: Shape>: ViewModifier {
#if !os(tvOS) #if !os(tvOS)
@available(iOS 26, macOS 26, *) @available(iOS 26, macOS 26, *)
private var glass: Glass { private var glass: Glass {
// Liquid Glass has ONE tint channel, so the palette wash and the caller's tint share // The glass carries the PALETTE wash only the caller's focus tint is an animatable fill
// it: mixed 60 % toward the caller's (the focused row must still read accented on // above it now (see `body`).
// 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. // A pale palette gets `.clear` glass, not `.regular`. Its `ink.glass` is literal white, so
let wash = ink.glass(ink.isLight ? 0.60 : 0.45) // over `.regular` which is already a bright, high-body material even a light white
var g: Glass = .regular.tint( // wash lands as a flat white slab: the refraction and the blurred field behind stop
tint.map { wash.mix(with: $0, by: 0.6) } ?? wash) // 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() } if interactive { g = g.interactive() }
return g return g
} }
@@ -154,8 +178,13 @@ extension View {
/// Liquid Glass for a console surface (a host tile / settings row), or `.ultraThinMaterial` /// 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 /// 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. /// scheme. Pass the surface's shape explicitly glass defaults to a Capsule.
func consoleGlass<S: Shape>(_ 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<S: Shape>(
_ shape: S, tint: Color? = nil, interactive: Bool = false, forceMaterial: Bool = false
) -> some View {
modifier(ConsoleGlass(
shape: shape, tint: tint, interactive: interactive, forceMaterial: forceMaterial))
} }
} }