- Wrap the pairing action dock in a rounded glassEffect container with linear-gradient edge fades at the top and bottom of the scroll, producing the native iOS 26 Liquid Glass chrome without masking blur hacks. - Realign the welcome layout to match the shadcn cards used by the home tab: tighter 40pt hero glyph, ShadcnBadge callout, AppSectionCard step/note rows, and PrimaryActionStyle/SecondaryActionStyle buttons instead of a tint-colored glassProminent pill. - Add a VariableBlurView utility in GlassChrome.swift (adapted from nikstar/VariableBlur, MIT) for cases where a real progressive Gaussian blur is needed, and capture the chrome/blur playbook plus tsswift screenshot workflow notes in readme.knowledge.md. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
7.1 KiB
Engineering Knowledge
Project-specific lessons learned while building the idp.global Swift app. Intentionally small and opinionated — append only when a finding will save time on a future task.
iOS 26 Liquid Glass and progressive blur at screen edges
Context: the pairing welcome screen needed a floating bottom action bar with "native Apple chrome" look — a rounded Liquid Glass pill containing the Scan/NFC buttons, with content gracefully fading as it approaches the top and bottom edges of the screen.
What we shipped
Simple and reliable: linear-gradient edge fades + a glassEffect-backed container for the buttons. See swift/Sources/Features/Auth/LoginRootView.swift (PairingWelcomeView, EdgeFade, PairingActionDock).
- Scroll content at the back,
.ignoresSafeArea(.container, edges: .vertical)so content can scroll under both edges. - A
GeometryReaderexposesproxy.safeAreaInsets.top; aColor.clear.frame(height: topInset + 8)spacer at the top of the scroll pushes the hero below the status bar at rest. - Top and bottom
EdgeFadeviews layered above the scroll: each is aLinearGradientfromColor.idpGroupedBackground(opaque at the edge) to clear (away from the edge),.allowsHitTesting(false), ignoring safe area. The top fade height istopInset(status bar only) so it doesn't creep into the hero. PairingActionDockis two buttons using the project-widePrimaryActionStyle/SecondaryActionStyle(shadcn near-black primary + outlined secondary), wrapped in.padding(16).glassEffect(.regular, in: RoundedRectangle(cornerRadius: 20, style: .continuous)), then.padding(.horizontal, 16).padding(.bottom, 12)for outer spacing. Native floating glass pill without tint-colored button chrome.
What we tried that didn't match what we wanted
Keep these in mind before reaching for them again:
scrollEdgeEffectStyle(.soft, for: .all)on aScrollView. Too subtle to read as blur; mostly invisible at rest. It is a style hint for the scroll edge effect, not a blur renderer.safeAreaBar(edge: .bottom)(the iOS 26 replacement forsafeAreaInsetthat auto-adds progressive blur). Apple's default blur intensity is very light — "zero control over radius/tint/fade length" — and it doesn't look like the heavy Notes/Safari chrome effect people expect.safeAreaInset(edge: .bottom) { dock.glassEffect(...) }. Works visually but the glass only covers the inset's intrinsic size, so there's a gap in the home-indicator strip unless you add a hack.background { Rectangle().glassEffect().ignoresSafeArea(.bottom) }..toolbar { ToolbarItem(placement: .bottomBar) { dock } }. The toolbar placement flattens a custom VStack of full-width stacked buttons into a single icon-only toolbar item. Unusable for two stacked full-width action buttons..backgroundExtensionEffect(). Mirrors (reflects) the content into the safe area. Content literally appears upside-down at the edge — wrong tool for this.- Gradient-masked
UIVisualEffectView(.regularMaterialetc.) stacked layers. Reads as "fade to white" in light mode becausesystemThickMaterialis basically opaque; the actual blur is hidden under the material tint. - Duplicating the content inside
.overlaywith.blur(radius:)+ gradient mask. Produces real blur but duplicating aScrollViewgives two independent scroll states, and blurring content that extends into the status bar area leaks colored halos (e.g., the hero glyph became a purple smear at the top). .toolbar(.hidden, for: .navigationBar). Kills the top chrome host. Any automatic top scroll-edge effect relies on there being a nav/toolbar/safe-area-bar host at that edge — if you hide it, you lose the top blur even when the rest of the pipeline is correct.
If you actually need a real progressive Gaussian blur
swift/Sources/Core/Design/GlassChrome.swift keeps a VariableBlurView helper (adapted from nikstar/VariableBlur, MIT). It wraps UIVisualEffectView and installs the private CAFilter variableBlur filter with a linear-gradient mask image, producing a real variable-radius Gaussian blur. Strings ("CAFilter", "filterWithType:") are obfuscated via .reversed() so trivial static scanners don't flag the symbol — same pattern the upstream packages use.
Tuning notes from the round of iteration:
- Ramp speed is controlled by layer height, not
maxBlurRadius. To make the ramp slower, make theVariableBlurViewtaller (e.g., 380pt). CrankingmaxBlurRadiusjust makes the final-pixel blur heavier, not more gradual. - For the top edge, height should be approximately
safeAreaInsets.top + ~20–30pt. Bigger than that and the blur bleeds into content (the hero glyph shows up as a colored halo above itself). - Place the bottom
VariableBlurViewbetween the scroll and the dock in aZStack(alignment: .bottom). The scroll needs.ignoresSafeArea(.container, edges: .vertical)so content passes behind both the blur layer and the chrome.
Project conventions for this pattern
- Use
Color.idpGroupedBackgroundas the fade target, not.white— it adapts to dark mode via the design tokens inswift/Sources/Core/Design/IdPTokens.swift. - Primary/secondary actions use
PrimaryActionStyle(near-black) /SecondaryActionStyle(outlined) fromswift/Sources/Core/Design/ButtonStyles.swift. Don't use.buttonStyle(.glassProminent).tint(IdP.tint)— the resulting purple pill doesn't match the rest of the app's shadcn-style chrome. - Shadcn card scale:
IdP.cardRadius(= 12),IdP.controlRadius(= 8). Icon/number chips in steps are 24pt rounded-rects at cornerRadius 6. Hero glyph is 40pt atIdP.controlRadius. Avoid oversized radii (22+) and chunky circles —AppSectionCard+ShadcnBadge+ theMonogramAvatar-style glyph are the canonical building blocks (seeHomeCards.swift,AppComponents.swift). AppScrollScreen(inswift/Sources/App/AppTheme.swift) is the standard scroll container. It does not own safe-area handling; apply.ignoresSafeArea(.container, edges: .vertical)at the call site if you need content to extend behind edges. Don't bake it intoAppScrollScreen— other screens rely on it respecting the safe area.
tsswift remote-builder workflow for UI screenshots
tsswift screenshots and tsswift review are not supported over a remote builder, so the automated review pipelines won't work. For UI verification during a task:
pnpm exec tsswift run --path swift/IDPGlobal.xcodeproj --platform ios(oripad) builds on the remote, installs, and launches.- To grab a screenshot:
ssh <builder> "xcrun simctl io <udid> screenshot /tmp/x.png"thenscpit back. Booted iPhone/iPad UDIDs are listed viaxcrun simctl list devices bootedon the builder. - To drive the app into a specific screen without a real camera or tap automation, add a launch argument (see
--show-pair-scannerinAppViewModel.bootstrap): the harness has no tap primitive, so launch-arg state toggles are the cheapest way to put the app into a specific UI state for screenshots.
xcrun simctl ui does not have a tap/touch primitive. idb is not installed on the remote. The remote also does not allow AppleScript keystroke sending. Don't waste time trying those paths.