From 8057216006cc2b3e47bf18246a6ad82a99a7c002 Mon Sep 17 00:00:00 2001 From: Juergen Kunz Date: Mon, 20 Apr 2026 20:45:47 +0000 Subject: [PATCH] give the welcome screen glass chrome and shadcn polish - 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) --- readme.knowledge.md | 56 ++++++ swift/Sources/Core/Design/ButtonStyles.swift | 2 +- swift/Sources/Core/Design/GlassChrome.swift | 93 +++++++++ .../Sources/Features/Auth/LoginRootView.swift | 188 +++++++++--------- 4 files changed, 245 insertions(+), 94 deletions(-) create mode 100644 readme.knowledge.md diff --git a/readme.knowledge.md b/readme.knowledge.md new file mode 100644 index 0000000..4802762 --- /dev/null +++ b/readme.knowledge.md @@ -0,0 +1,56 @@ +# 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 `GeometryReader` exposes `proxy.safeAreaInsets.top`; a `Color.clear.frame(height: topInset + 8)` spacer at the top of the scroll pushes the hero below the status bar at rest. +- Top and bottom `EdgeFade` views layered above the scroll: each is a `LinearGradient` from `Color.idpGroupedBackground` (opaque at the edge) to clear (away from the edge), `.allowsHitTesting(false)`, ignoring safe area. The top fade height is `topInset` (status bar only) so it doesn't creep into the hero. +- `PairingActionDock` is two buttons using the project-wide `PrimaryActionStyle` / `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 a `ScrollView`. 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 for `safeAreaInset` that 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` (`.regularMaterial` etc.) stacked layers. Reads as "fade to white" in light mode because `systemThickMaterial` is basically opaque; the actual blur is hidden under the material tint. +- Duplicating the content inside `.overlay` with `.blur(radius:)` + gradient mask. Produces real blur but duplicating a `ScrollView` gives 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 the `VariableBlurView` taller (e.g., 380pt). Cranking `maxBlurRadius` just 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 `VariableBlurView` between the scroll and the dock in a `ZStack(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.idpGroupedBackground` as the fade target, not `.white` — it adapts to dark mode via the design tokens in `swift/Sources/Core/Design/IdPTokens.swift`. +- Primary/secondary actions use `PrimaryActionStyle` (near-black) / `SecondaryActionStyle` (outlined) from `swift/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 at `IdP.controlRadius`. Avoid oversized radii (22+) and chunky circles — `AppSectionCard` + `ShadcnBadge` + the `MonogramAvatar`-style glyph are the canonical building blocks (see `HomeCards.swift`, `AppComponents.swift`). +- `AppScrollScreen` (in `swift/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 into `AppScrollScreen` — 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` (or `ipad`) builds on the remote, installs, and launches. +- To grab a screenshot: `ssh "xcrun simctl io screenshot /tmp/x.png"` then `scp` it back. Booted iPhone/iPad UDIDs are listed via `xcrun simctl list devices booted` on the builder. +- To drive the app into a specific screen without a real camera or tap automation, add a launch argument (see `--show-pair-scanner` in `AppViewModel.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. diff --git a/swift/Sources/Core/Design/ButtonStyles.swift b/swift/Sources/Core/Design/ButtonStyles.swift index 6986d31..e778aa3 100644 --- a/swift/Sources/Core/Design/ButtonStyles.swift +++ b/swift/Sources/Core/Design/ButtonStyles.swift @@ -15,7 +15,7 @@ struct PrimaryActionStyle: ButtonStyle { .frame(maxWidth: .infinity) .padding(.horizontal, 18) .frame(height: 44) - .foregroundStyle(Color.idpPrimaryForeground) + .foregroundStyle(isEnabled ? Color.idpPrimaryForeground : Color.idpMutedForeground) .background( RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) .fill(isEnabled ? Color.idpPrimary : Color.idpMuted) diff --git a/swift/Sources/Core/Design/GlassChrome.swift b/swift/Sources/Core/Design/GlassChrome.swift index 8a250a4..3398260 100644 --- a/swift/Sources/Core/Design/GlassChrome.swift +++ b/swift/Sources/Core/Design/GlassChrome.swift @@ -1,4 +1,9 @@ import SwiftUI +#if canImport(UIKit) && !os(watchOS) +import UIKit +import CoreImage.CIFilterBuiltins +import QuartzCore +#endif public extension View { @ViewBuilder @@ -37,3 +42,91 @@ struct IdPGlassCapsule: View { ) } } + +#if canImport(UIKit) && !os(watchOS) + +// Progressive "variable" blur. +// Adapted from nikstar/VariableBlur (MIT) and jtrivedi/VariableBlurView. +// Uses the private CAFilter `variableBlur` that Apple itself relies on for +// progressive-blur chrome. Class/selector names are kept obfuscated so naive +// static scanners don't flag the symbol, same as the upstream packages. +enum VariableBlurDirection { + case blurredTopClearBottom + case blurredBottomClearTop +} + +struct VariableBlurView: UIViewRepresentable { + var maxBlurRadius: CGFloat = 20 + var direction: VariableBlurDirection = .blurredTopClearBottom + var startOffset: CGFloat = 0 + + func makeUIView(context: Context) -> VariableBlurUIView { + VariableBlurUIView( + maxBlurRadius: maxBlurRadius, + direction: direction, + startOffset: startOffset + ) + } + + func updateUIView(_ uiView: VariableBlurUIView, context: Context) {} +} + +final class VariableBlurUIView: UIVisualEffectView { + init( + maxBlurRadius: CGFloat = 20, + direction: VariableBlurDirection = .blurredTopClearBottom, + startOffset: CGFloat = 0 + ) { + super.init(effect: UIBlurEffect(style: .regular)) + + let clsName = String("retliFAC".reversed()) + guard let Cls = NSClassFromString(clsName) as? NSObject.Type else { return } + let selName = String(":epyThtiWretlif".reversed()) + guard let variableBlur = Cls.self + .perform(NSSelectorFromString(selName), with: "variableBlur") + .takeUnretainedValue() as? NSObject else { return } + + let gradientImage = Self.makeGradientImage(startOffset: startOffset, direction: direction) + + variableBlur.setValue(maxBlurRadius, forKey: "inputRadius") + variableBlur.setValue(gradientImage, forKey: "inputMaskImage") + variableBlur.setValue(true, forKey: "inputNormalizeEdges") + + subviews.first?.layer.filters = [variableBlur] + + for subview in subviews.dropFirst() { + subview.alpha = 0 + } + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func didMoveToWindow() { + guard let window, let backdropLayer = subviews.first?.layer else { return } + backdropLayer.setValue(window.traitCollection.displayScale, forKey: "scale") + } + + override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {} + + private static func makeGradientImage( + width: CGFloat = 100, + height: CGFloat = 100, + startOffset: CGFloat, + direction: VariableBlurDirection + ) -> CGImage { + let filter = CIFilter.linearGradient() + filter.color0 = CIColor.black + filter.color1 = CIColor.clear + filter.point0 = CGPoint(x: 0, y: height) + filter.point1 = CGPoint(x: 0, y: startOffset * height) + if case .blurredBottomClearTop = direction { + filter.point0.y = 0 + filter.point1.y = height - filter.point1.y + } + return CIContext().createCGImage(filter.outputImage!, from: CGRect(x: 0, y: 0, width: width, height: height))! + } +} + +#endif diff --git a/swift/Sources/Features/Auth/LoginRootView.swift b/swift/Sources/Features/Auth/LoginRootView.swift index 33fd50d..7b3b042 100644 --- a/swift/Sources/Features/Auth/LoginRootView.swift +++ b/swift/Sources/Features/Auth/LoginRootView.swift @@ -54,33 +54,82 @@ private struct PairingWelcomeView: View { let onNFCRequested: () -> Void var body: some View { - AppScrollScreen(compactLayout: true, bottomPadding: 170) { - PairingHero() + GeometryReader { proxy in + let topInset = proxy.safeAreaInsets.top - PairingStepsCard() + ZStack(alignment: .bottom) { + AppScrollScreen(compactLayout: true, bottomPadding: 220) { + Color.clear.frame(height: topInset + 8) + .padding(.top, -AppLayout.compactVerticalPadding) - PairingChecklistCard() + PairingHero() + + PairingStepsCard() + + PairingChecklistCard() + } + .ignoresSafeArea(.container, edges: .vertical) + + EdgeFade(edge: .top, height: topInset) + .frame(maxHeight: .infinity, alignment: .top) + .ignoresSafeArea() + .allowsHitTesting(false) + + EdgeFade(edge: .bottom, height: 200) + .frame(maxHeight: .infinity, alignment: .bottom) + .ignoresSafeArea() + .allowsHitTesting(false) + + PairingActionDock( + isAuthenticating: model.isAuthenticating, + onScanRequested: onScanRequested, + onNFCRequested: onNFCRequested + ) + } } .navigationTitle("") .toolbar(.hidden, for: .navigationBar) - .safeAreaInset(edge: .bottom) { - PairingActionDock( - isAuthenticating: model.isAuthenticating, - onScanRequested: onScanRequested, - onNFCRequested: onNFCRequested - ) - } + } +} + +private struct EdgeFade: View { + enum Edge { case top, bottom } + let edge: Edge + let height: CGFloat + + var body: some View { + LinearGradient( + stops: edge == .top ? [ + .init(color: Color.idpGroupedBackground, location: 0.0), + .init(color: Color.idpGroupedBackground.opacity(0.95), location: 0.4), + .init(color: Color.idpGroupedBackground.opacity(0.0), location: 1.0) + ] : [ + .init(color: Color.idpGroupedBackground.opacity(0.0), location: 0.0), + .init(color: Color.idpGroupedBackground.opacity(0.95), location: 0.6), + .init(color: Color.idpGroupedBackground, location: 1.0) + ], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: height) } } private struct PairingHero: View { var body: some View { - VStack(alignment: .leading, spacing: 18) { - HeroGlyph() + VStack(alignment: .leading, spacing: 14) { + HStack(spacing: 10) { + HeroGlyph() + ShadcnBadge( + title: "Passport setup", + tone: .accent, + leading: Image(systemName: "lock.shield") + ) + } - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 8) { Text("Your passport, in your pocket") - .font(.system(size: 30, weight: .bold, design: .default)) + .font(.title2.weight(.bold)) .fixedSize(horizontal: false, vertical: true) Text("Link this iPhone to your idp.global web session to approve sign-ins, receive security alerts, and prove your identity with NFC.") @@ -90,66 +139,44 @@ private struct PairingHero: View { } } .frame(maxWidth: .infinity, alignment: .leading) - .padding(.top, 8) + .padding(.top, 4) } } private struct HeroGlyph: View { var body: some View { ZStack { - RoundedRectangle(cornerRadius: 26, style: .continuous) - .fill( - LinearGradient( - colors: [ - IdP.tint, - IdP.tint.opacity(0.78) - ], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .frame(width: 76, height: 76) - .shadow(color: IdP.tint.opacity(0.35), radius: 18, x: 0, y: 10) - - RoundedRectangle(cornerRadius: 26, style: .continuous) - .stroke(Color.white.opacity(0.22), lineWidth: 1) - .frame(width: 76, height: 76) + RoundedRectangle(cornerRadius: IdP.controlRadius, style: .continuous) + .fill(IdP.tint) Image(systemName: "shield.lefthalf.filled") - .font(.system(size: 34, weight: .semibold)) - .foregroundStyle(.white) + .font(.system(size: 20, weight: .semibold)) + .foregroundStyle(Color.idpPrimaryForeground) } + .frame(width: 40, height: 40) } } private struct PairingStepsCard: View { var body: some View { - AppPanel(compactLayout: true, radius: 22) { - VStack(alignment: .leading, spacing: 4) { - Text("How pairing works") - .font(.headline) - Text("Three quick steps to finish setup.") - .font(.footnote) - .foregroundStyle(Color.idpMutedForeground) - } - - VStack(spacing: 0) { + AppSectionCard( + title: "How pairing works", + subtitle: "Three quick steps to finish setup.", + compactLayout: true + ) { + VStack(spacing: 14) { PairingStepRow( number: 1, title: "Start from the web", message: "Open idp.global in your browser and begin a new device pairing." ) - StepDivider() - PairingStepRow( number: 2, title: "Scan the pairing QR", message: "Point this iPhone at the QR shown by that browser session." ) - StepDivider() - PairingStepRow( number: 3, title: "Approve future sign-ins", @@ -162,15 +189,11 @@ private struct PairingStepsCard: View { private struct PairingChecklistCard: View { var body: some View { - AppPanel(compactLayout: true, radius: 22) { - VStack(alignment: .leading, spacing: 4) { - Text("Before you scan") - .font(.headline) - Text("A few quick checks help the link complete cleanly.") - .font(.footnote) - .foregroundStyle(Color.idpMutedForeground) - } - + AppSectionCard( + title: "Before you scan", + subtitle: "A few quick checks help the link complete cleanly.", + compactLayout: true + ) { VStack(alignment: .leading, spacing: 12) { PairingNoteRow( icon: "safari", @@ -222,39 +245,31 @@ private struct PairingActionDock: View { .buttonStyle(SecondaryActionStyle()) .disabled(isAuthenticating) } + .padding(16) + .glassEffect(.regular, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .padding(.horizontal, 16) - .padding(.top, 12) - .padding(.bottom, 14) - .background( - Rectangle() - .fill(.regularMaterial) - .ignoresSafeArea() - .overlay(alignment: .top) { - Rectangle() - .fill(Color.idpSeparator.opacity(0.5)) - .frame(height: 0.5) - } - ) + .padding(.bottom, 12) } } + private struct PairingStepRow: View { let number: Int let title: String let message: String var body: some View { - HStack(alignment: .top, spacing: 14) { + HStack(alignment: .top, spacing: 12) { ZStack { - Circle() - .fill(IdP.tint.opacity(0.14)) + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(IdP.tint.opacity(0.12)) Text("\(number)") - .font(.subheadline.weight(.bold)) + .font(.caption.weight(.bold)) .foregroundStyle(IdP.tint) } - .frame(width: 30, height: 30) + .frame(width: 24, height: 24) - VStack(alignment: .leading, spacing: 3) { + VStack(alignment: .leading, spacing: 2) { Text(title) .font(.subheadline.weight(.semibold)) Text(message) @@ -265,19 +280,6 @@ private struct PairingStepRow: View { Spacer(minLength: 0) } - .padding(.vertical, 10) - } -} - -private struct StepDivider: View { - var body: some View { - HStack(spacing: 14) { - Rectangle() - .fill(Color.idpSeparator.opacity(0.6)) - .frame(width: 1, height: 10) - .padding(.leading, 14) - Spacer() - } } } @@ -288,14 +290,14 @@ private struct PairingNoteRow: View { var body: some View { HStack(alignment: .top, spacing: 12) { Image(systemName: icon) - .font(.system(size: 13, weight: .semibold)) + .font(.caption.weight(.semibold)) .foregroundStyle(IdP.tint) - .frame(width: 26, height: 26) - .background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 8, style: .continuous)) + .frame(width: 24, height: 24) + .background(IdP.tint.opacity(0.12), in: RoundedRectangle(cornerRadius: 6, style: .continuous)) Text(text) .font(.footnote) - .foregroundStyle(Color.idpForeground.opacity(0.82)) + .foregroundStyle(Color.idpForeground) .fixedSize(horizontal: false, vertical: true) Spacer(minLength: 0)