ios+mac: sign-in doors share one button geometry + the pressed door spins (#1151)
* ios+mac: sign-in doors share one button geometry + the pressed door spins Paul's two rules from the device session: (1) the Apple control fixes the shape — full-width, low-radius rounded corners — so every door matches it (DoorButtonStyle / MacDoorButtonStyle; no mixed button types in a list; the demoted passkey text link becomes a row), and (2) the web's busy behaviour lands natively: the pressed door hosts its own spinner. Apple's control can't host one, so a same-geometry stand-in overlays it while its verify runs — the real button stays in the hierarchy, hidden, or the in-flight authorization would be torn down with the view. The email step's Send code / Sign in match too. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios+mac: quieter first-run — 'Secure group chat', no footer; sealed says it with the button Paul's copy pass on the device session: the tagline becomes 'Secure group chat' and the privacy footer goes. The sealed landing sheds every word that isn't the action — no tagline, no 'Signed in — your spaces are locked…' status (if you can't see your spaces, you're not signed in) — leaving 'Unlock with your passkey' with the passkey glyph, and the quiet sign-in-a-different-way escape. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ios+mac: the sealed landing holds through the ceremony — keyed on session, not phase Tapping 'Unlock with your passkey' flipped phase to .working, which bounced the body back to the doors mid-ceremony (and a cancelled sheet, landing on .idle, did the same). The sealed landing now renders from SESSION state — sessionSealed (a stored anchor session with no DEK), added to the iOS model to mirror the Mac's — so the screen holds steady until the unlock actually resolves. The escape link disables while a ceremony is in flight, and the Mac's unlock label catches up to the shorter iOS copy. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> ---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2 files changed,
+183
−111
ios/LChat/Views/SignInWithEleven.swift+103 −64ios/LChatMac/MacHomeSignIn.swift+80 −47
diff --git a/ios/LChat/Views/SignInWithEleven.swift b/ios/LChat/Views/SignInWithEleven.swift| index d4714ea..4ec4862 100644 |
| --- a/ios/LChat/Views/SignInWithEleven.swift |
| +++ b/ios/LChat/Views/SignInWithEleven.swift |
| @@ -63,6 +63,12 @@ final class HomeSignInModel: ObservableObject { |
| /// A stale one-shot copy here once left "Back up this device" visible |
| /// after sign-in, a path to accidentally minting a second account. |
| @Published private(set) var sessionExists = HomeSessionStore.load() != nil |
| + /// True when the stored session is an anchor's (Apple / email): signed |
| + /// in, but no DEK — the vault stays sealed until a passkey unlocks it. |
| + /// SESSION state, deliberately not phase: the sealed landing must hold |
| + /// steady through a ceremony's transient phases (.working after the tap, |
| + /// .idle after a cancelled sheet) instead of bouncing to the doors. |
| + @Published private(set) var sessionSealed = HomeSessionStore.load().map { $0.dek == nil } ?? false |
| init() {} |
| @@ -102,7 +108,9 @@ final class HomeSignInModel: ObservableObject { |
| /// Re-read the Keychain session (the dev reset wipes it out from under the |
| /// model) and drop any terminal status back to the idle pitch. |
| func refreshSession() { |
| - sessionExists = HomeSessionStore.load() != nil |
| + let session = HomeSessionStore.load() |
| + sessionExists = session != nil |
| + sessionSealed = session.map { $0.dek == nil } ?? false |
| phase = .idle |
| } |
| @@ -112,6 +120,7 @@ final class HomeSignInModel: ObservableObject { |
| func switchAccount() { |
| HomeSessionStore.clear() |
| sessionExists = false |
| + sessionSealed = false |
| phase = .idle |
| } |
| @@ -124,6 +133,7 @@ final class HomeSignInModel: ObservableObject { |
| private func adopt(_ session: HomeSession, method: SignInMethod) { |
| HomeSessionStore.save(session) |
| sessionExists = true |
| + sessionSealed = session.dek == nil |
| UserDefaults.standard.set(method.rawValue, forKey: Self.lastSignInKey) |
| } |
| @@ -417,11 +427,17 @@ struct SignInFirstRun: View { |
| .accessibilityHidden(true) |
| Text("Welcome to Eleven") |
| .font(.title2.weight(.semibold)) |
| - Text("The group chat of dreams") |
| - .font(.callout) |
| - .foregroundStyle(.secondary) |
| - .multilineTextAlignment(.center) |
| - .padding(.horizontal, 8) |
| + // The tagline is the welcome pitch — the sealed landing is an |
| + // unlock moment, not a welcome, and earns no marketing words |
| + // (Paul, 2026-07-19: the button says everything this state |
| + // needs). |
| + if !model.sessionSealed { |
| + Text("Secure group chat") |
| + .font(.callout) |
| + .foregroundStyle(.secondary) |
| + .multilineTextAlignment(.center) |
| + .padding(.horizontal, 8) |
| + } |
| } |
| // Status / result folds in under the pitch, occupying layout space |
| @@ -445,7 +461,7 @@ struct SignInFirstRun: View { |
| .padding(.bottom, 48) |
| } else if let step = emailStep { |
| emailDoor(step) |
| - } else if model.phase.signedIn { |
| + } else if model.phase.signedIn || model.sessionSealed { |
| landed |
| } else { |
| doors |
| @@ -485,12 +501,13 @@ struct SignInFirstRun: View { |
| // below it covers the wrong-account landing either way. |
| private var landed: some View { |
| VStack(spacing: 12) { |
| - if model.phase == .sealed { |
| + if model.sessionSealed { |
| door(.passkey, leading: true) |
| } |
| Button("Sign in a different way") { model.switchAccount() } |
| .font(.subheadline.weight(.medium)) |
| .foregroundStyle(.secondary) |
| + .disabled(model.phase.busy) // never mid-ceremony |
| } |
| .padding(.horizontal, 32) |
| .padding(.bottom, 8) |
| @@ -509,12 +526,6 @@ struct SignInFirstRun: View { |
| .font(.subheadline.weight(.medium)) |
| .foregroundStyle(.secondary) |
| } |
| - |
| - // The home page's privacy pitch, word for word. |
| - Text("No signup required. We don't store any personal details. We can't spam what we don't have.") |
| - .font(.footnote) |
| - .foregroundStyle(.tertiary) |
| - .multilineTextAlignment(.center) |
| } |
| .padding(.horizontal, 32) |
| .padding(.bottom, 8) |
| @@ -525,6 +536,12 @@ struct SignInFirstRun: View { |
| case .apple: |
| // Apple's own button (HIG); the model owns the nonce + verify so |
| // the session-adoption seam is shared with every other door. |
| + // While its trip is in flight Apple's control can't host a |
| + // spinner (the web's pressed door spins — home.js's .loading), |
| + // so a same-geometry stand-in covers it; the real button stays |
| + // in the hierarchy, hidden, or the in-flight authorization |
| + // would be torn down with the view. |
| + let appleBusy = model.phase.busy && tapped == .apple |
| SignInWithAppleButton(.continue) { request in |
| tapped = .apple |
| model.prepareApple(request) |
| @@ -532,8 +549,23 @@ struct SignInFirstRun: View { |
| Task { await model.handleApple(result) } |
| } |
| .signInWithAppleButtonStyle(scheme == .dark ? .white : .black) |
| - .frame(height: 50) |
| + .frame(height: DoorButtonStyle.height) |
| .disabled(model.phase.busy) |
| + .opacity(appleBusy ? 0 : 1) |
| + .overlay { |
| + if appleBusy { |
| + HStack(spacing: 8) { |
| + ProgressView() |
| + .tint(scheme == .dark ? .black : .white) |
| + Text("Signing in…").fontWeight(.semibold) |
| + } |
| + .frame(maxWidth: .infinity, minHeight: DoorButtonStyle.height) |
| + .foregroundStyle(scheme == .dark ? Color.black : .white) |
| + .background(scheme == .dark ? Color.white : .black, |
| + in: RoundedRectangle(cornerRadius: DoorButtonStyle.radius, |
| + style: .continuous)) |
| + } |
| + } |
| case .email: |
| Button { |
| tapped = .email |
| @@ -541,50 +573,38 @@ struct SignInFirstRun: View { |
| focus = .address |
| } label: { |
| Text("Continue with email") |
| - .fontWeight(.semibold) |
| - .frame(maxWidth: .infinity) |
| } |
| - .buttonStyle(.bordered) |
| - .controlSize(.large) |
| + .buttonStyle(DoorButtonStyle(prominent: leading)) |
| .disabled(model.phase.busy) |
| case .passkey: |
| - if leading { |
| - // Promoted: the hero form (last-used passkey, or a sealed |
| - // anchor sign-in pointing at the door that opens the backup). |
| - Button { |
| - tapped = .passkey |
| - Task { await model.signIn(into: app) } |
| - } label: { |
| - HStack(spacing: 8) { |
| - if model.phase == .working && tapped == .passkey { |
| - ProgressView().controlSize(.small) |
| - } |
| - Text(passkeyLeadLabel) |
| - .fontWeight(.semibold) |
| + // Promoted or listed, the passkey door is the same row shape — |
| + // only the fill marks the lead (no mixed button types in the |
| + // list; the Apple control fixes the geometry). |
| + Button { |
| + tapped = .passkey |
| + Task { await model.signIn(into: app) } |
| + } label: { |
| + HStack(spacing: 8) { |
| + if model.phase == .working && tapped == .passkey { |
| + ProgressView() |
| + .controlSize(.small) |
| + .tint(leading ? .white : nil) |
| + } else if leading && model.sessionSealed { |
| + // The unlock moment gets the passkey glyph — the icon |
| + // carries what the trimmed copy no longer spells out. |
| + Image(systemName: "person.badge.key.fill") |
| } |
| - .frame(maxWidth: .infinity) |
| + Text(leading ? passkeyLeadLabel : "Sign in with a passkey") |
| } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| - .disabled(model.phase.busy) |
| - } else { |
| - // Demoted: the quiet text button under the anchor doors. |
| - Button("Sign in with a passkey") { |
| - tapped = .passkey |
| - Task { await model.signIn(into: app) } |
| - } |
| - .buttonStyle(.plain) |
| - .font(.subheadline.weight(.medium)) |
| - .foregroundStyle(.secondary) |
| - .padding(.vertical, 4) |
| - .disabled(model.phase.busy) |
| } |
| + .buttonStyle(DoorButtonStyle(prominent: leading)) |
| + .disabled(model.phase.busy) |
| } |
| } |
| private var passkeyLeadLabel: String { |
| if model.phase == .working && tapped == .passkey { return "Signing in…" } |
| - if model.phase == .sealed { return "Unlock your spaces with your passkey" } |
| + if model.sessionSealed { return "Unlock with your passkey" } |
| return "Sign in with a passkey" |
| } |
| @@ -626,13 +646,11 @@ struct SignInFirstRun: View { |
| .accessibilityLabel("Your email address") |
| Button(action: sendCode) { |
| HStack(spacing: 8) { |
| - if model.phase == .working { ProgressView().controlSize(.small) } |
| - Text("Send code").fontWeight(.semibold) |
| + if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } |
| + Text("Send code") |
| } |
| - .frame(maxWidth: .infinity) |
| } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| + .buttonStyle(DoorButtonStyle(prominent: true)) |
| .disabled(model.phase.busy || !emailValid) |
| // The web's note, word for word — under the address field. |
| Text("We never keep your email address. No spam, ever.") |
| @@ -659,13 +677,11 @@ struct SignInFirstRun: View { |
| .accessibilityLabel("The 6-digit code from your email") |
| Button(action: verifyCode) { |
| HStack(spacing: 8) { |
| - if model.phase == .working { ProgressView().controlSize(.small) } |
| - Text("Sign in").fontWeight(.semibold) |
| + if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } |
| + Text("Sign in") |
| } |
| - .frame(maxWidth: .infinity) |
| } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| + .buttonStyle(DoorButtonStyle(prominent: true)) |
| .disabled(model.phase.busy || code.count != 6) |
| } |
| } |
| @@ -708,19 +724,42 @@ struct SignInFirstRun: View { |
| case .backedUp(let n): |
| Label("Saved \(n) space\(n == 1 ? "" : "s") to Eleven.", systemImage: "checkmark.circle.fill") |
| .font(.callout).foregroundStyle(.green) |
| - case .sealed: |
| - // The identity-vs-key split, said plainly: the anchor signed you |
| - // in; the passkey (promoted below) is what opens your spaces. |
| - Text("Signed in — your spaces are locked with your passkey. Unlock them below.") |
| - .font(.callout).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| case .working: |
| Text("Confirming it’s you…") |
| .font(.callout).foregroundStyle(.secondary) |
| case .failed(let msg): |
| Label(msg, systemImage: "exclamationmark.triangle") |
| .font(.footnote).foregroundStyle(.secondary).multilineTextAlignment(.center) |
| - case .idle, .syncing: |
| + case .idle, .syncing, .sealed: |
| + // Sealed deliberately says NOTHING here: "signed in but locked" |
| + // reads as noise when no spaces are visible (Paul: if you can't |
| + // see your spaces, you're not signed in). The unlock button — a |
| + // passkey icon and five words — is the whole story. |
| EmptyView() |
| } |
| } |
| } |
| + |
| +// 🤖 One geometry for every door (Paul, 2026-07-19): the Apple control fixes |
| +// the shape — full-width, 50pt, low-radius continuous corners — and no other |
| +// button type may sit beside it in the list. Prominent = the promoted door |
| +// (or a step's main action); plain = a demoted row. The pressed door hosts |
| +// its own spinner (the web's .loading rule), so busy state lives in the |
| +// button, not beside it. |
| +private struct DoorButtonStyle: ButtonStyle { |
| + var prominent = false |
| + @Environment(\.isEnabled) private var enabled |
| + |
| + static let radius: CGFloat = 12 |
| + static let height: CGFloat = 50 |
| + |
| + func makeBody(configuration: Configuration) -> some View { |
| + configuration.label |
| + .fontWeight(.semibold) |
| + .frame(maxWidth: .infinity, minHeight: Self.height) |
| + .foregroundStyle(prominent ? Color.white : Color.accentColor) |
| + .background(prominent ? Color.accentColor : Color(.secondarySystemFill), |
| + in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous)) |
| + .opacity((configuration.isPressed ? 0.75 : 1) * (enabled ? 1 : 0.55)) |
| + } |
| +} |
diff --git a/ios/LChatMac/MacHomeSignIn.swift b/ios/LChatMac/MacHomeSignIn.swift| index 221319e..3037d10 100644 |
| --- a/ios/LChatMac/MacHomeSignIn.swift |
| +++ b/ios/LChatMac/MacHomeSignIn.swift |
| @@ -545,8 +545,13 @@ struct MacFirstRun: View { |
| .accessibilityHidden(true) |
| Text("Welcome to Eleven") |
| .font(.title2.weight(.semibold)) |
| - Text("The group chat of dreams") |
| - .foregroundStyle(.secondary) |
| + // The tagline is the welcome pitch — the sealed landing is an |
| + // unlock moment, not a welcome, and earns no marketing words |
| + // (Paul, 2026-07-19: the button says everything this state needs). |
| + if !model.sessionSealed { |
| + Text("Secure group chat") |
| + .foregroundStyle(.secondary) |
| + } |
| // Status folds in under the pitch, occupying layout space (the |
| // inline-feedback rule — nothing floats). |
| @@ -564,7 +569,7 @@ struct MacFirstRun: View { |
| .padding(.bottom, 56) |
| } else if let step = emailStep { |
| emailDoor(step) |
| - } else if model.phase.signedIn { |
| + } else if model.phase.signedIn || model.sessionSealed { |
| landed |
| } else { |
| doors |
| @@ -605,7 +610,7 @@ struct MacFirstRun: View { |
| // below it covers the wrong-account landing either way. |
| private var landed: some View { |
| VStack(spacing: 10) { |
| - if model.phase == .sealed { |
| + if model.sessionSealed { |
| door(.passkey, leading: true) |
| } |
| Button("Sign in a different way") { model.switchAccount() } |
| @@ -613,6 +618,7 @@ struct MacFirstRun: View { |
| .font(.subheadline.weight(.medium)) |
| .foregroundStyle(.secondary) |
| .padding(.vertical, 2) |
| + .disabled(model.phase.busy) // never mid-ceremony |
| } |
| .frame(width: 280) |
| .padding(.bottom, 48) |
| @@ -633,14 +639,6 @@ struct MacFirstRun: View { |
| .foregroundStyle(.secondary) |
| .padding(.vertical, 2) |
| } |
| - |
| - // The home page's privacy pitch, word for word. |
| - Text("No signup required. We don't store any personal details. We can't spam what we don't have.") |
| - .font(.footnote) |
| - .foregroundStyle(.tertiary) |
| - .multilineTextAlignment(.center) |
| - .frame(maxWidth: 300) |
| - .padding(.top, 2) |
| } |
| .frame(width: 280) |
| .padding(.bottom, 48) |
| @@ -652,24 +650,41 @@ struct MacFirstRun: View { |
| // Apple's branded control (HIG, glyph) over OUR web-session |
| // flow — see MacHomeSignIn.signInWithApple for why not the native |
| // sheet. Recreated on scheme flips (.id) — the style is init-only. |
| + // While its trip is in flight the control can't host a spinner |
| + // (the web's pressed door spins), so a same-geometry stand-in |
| + // covers it; the real button stays in the hierarchy, hidden. |
| + let appleBusy = model.phase.busy && tapped == .apple |
| AppleDoorButton(style: scheme == .dark ? .white : .black) { |
| tapped = .apple |
| Task { await model.signInWithApple() } |
| } |
| .id(scheme) |
| - .frame(height: 32) |
| + .frame(height: MacDoorButtonStyle.height) |
| .disabled(model.phase.busy) |
| + .opacity(appleBusy ? 0 : 1) |
| + .overlay { |
| + if appleBusy { |
| + HStack(spacing: 6) { |
| + ProgressView() |
| + .controlSize(.small) |
| + .tint(scheme == .dark ? .black : .white) |
| + Text("Signing in…").fontWeight(.medium) |
| + } |
| + .frame(maxWidth: .infinity, minHeight: MacDoorButtonStyle.height) |
| + .foregroundStyle(scheme == .dark ? Color.black : .white) |
| + .background(scheme == .dark ? Color.white : .black, |
| + in: RoundedRectangle(cornerRadius: MacDoorButtonStyle.radius, |
| + style: .continuous)) |
| + } |
| + } |
| case .email: |
| Button { |
| tapped = .email |
| emailStep = .address |
| } label: { |
| Text("Continue with email") |
| - .fontWeight(.medium) |
| - .frame(maxWidth: .infinity) |
| } |
| - .buttonStyle(.bordered) |
| - .controlSize(.large) |
| + .buttonStyle(MacDoorButtonStyle(prominent: leading)) |
| .disabled(model.phase.busy) |
| case .passkey: |
| if leading { |
| @@ -679,31 +694,31 @@ struct MacFirstRun: View { |
| tapped = .passkey |
| Task { await model.signIn(into: app) } |
| } label: { |
| - HStack(spacing: 8) { |
| + HStack(spacing: 6) { |
| if model.phase == .working && tapped == .passkey { |
| - ProgressView().controlSize(.small) |
| + ProgressView().controlSize(.small).tint(.white) |
| + } else if model.sessionSealed { |
| + // The unlock moment gets the passkey glyph — the |
| + // icon carries what the trimmed copy no longer |
| + // spells out. |
| + Image(systemName: "person.badge.key.fill") |
| } |
| Text(passkeyLeadLabel) |
| - .fontWeight(.semibold) |
| } |
| - .frame(maxWidth: .infinity) |
| } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| + .buttonStyle(MacDoorButtonStyle(prominent: true)) |
| .disabled(model.phase.busy) |
| } else { |
| - // Demoted: the quiet text path under the anchor doors. The |
| - // model still routes it — native ceremony where it can run, |
| + // Demoted, but the same row shape — no mixed button types in |
| + // the list; the Apple control fixes the geometry. The model |
| + // still routes it — native ceremony where it can run, |
| // automatic browser failover everywhere else (no entitlement, |
| // 1Password's PRF mismatch, pre-15). |
| Button("Sign in with a passkey") { |
| tapped = .passkey |
| Task { await model.signIn(into: app) } |
| } |
| - .buttonStyle(.plain) |
| - .font(.subheadline.weight(.medium)) |
| - .foregroundStyle(.secondary) |
| - .padding(.vertical, 2) |
| + .buttonStyle(MacDoorButtonStyle()) |
| .disabled(model.phase.busy) |
| } |
| } |
| @@ -711,7 +726,7 @@ struct MacFirstRun: View { |
| private var passkeyLeadLabel: String { |
| if model.phase == .working && tapped == .passkey { return "Signing in…" } |
| - if model.phase == .sealed { return "Unlock your spaces with your passkey" } |
| + if model.sessionSealed { return "Unlock with your passkey" } |
| return "Sign in with a passkey" |
| } |
| @@ -752,14 +767,12 @@ struct MacFirstRun: View { |
| .onSubmit { if emailValid { sendCode() } } // Return advances |
| .accessibilityLabel("Your email address") |
| Button(action: sendCode) { |
| - HStack(spacing: 8) { |
| - if model.phase == .working { ProgressView().controlSize(.small) } |
| - Text("Send code").fontWeight(.semibold) |
| + HStack(spacing: 6) { |
| + if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } |
| + Text("Send code") |
| } |
| - .frame(maxWidth: .infinity) |
| } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| + .buttonStyle(MacDoorButtonStyle(prominent: true)) |
| .disabled(model.phase.busy || !emailValid) |
| // The web's note, word for word — under the address field. |
| Text("We never keep your email address. No spam, ever.") |
| @@ -785,14 +798,12 @@ struct MacFirstRun: View { |
| } |
| .accessibilityLabel("The 6-digit code from your email") |
| Button(action: verifyCode) { |
| - HStack(spacing: 8) { |
| - if model.phase == .working { ProgressView().controlSize(.small) } |
| - Text("Sign in").fontWeight(.semibold) |
| + HStack(spacing: 6) { |
| + if model.phase == .working { ProgressView().controlSize(.small).tint(.white) } |
| + Text("Sign in") |
| } |
| - .frame(maxWidth: .infinity) |
| } |
| - .buttonStyle(.borderedProminent) |
| - .controlSize(.large) |
| + .buttonStyle(MacDoorButtonStyle(prominent: true)) |
| .disabled(model.phase.busy || code.count != 6) |
| } |
| } |
| @@ -835,11 +846,11 @@ struct MacFirstRun: View { |
| .font(.callout).foregroundStyle(.secondary) |
| .multilineTextAlignment(.center) |
| case .sealed: |
| - // The identity-vs-key split, said plainly: the anchor signed you |
| - // in; the passkey (promoted below) is what opens your spaces. |
| - Text("Signed in — your spaces are locked with your passkey. Unlock them below.") |
| - .font(.callout).foregroundStyle(.secondary) |
| - .multilineTextAlignment(.center) |
| + // Deliberately NOTHING: "signed in but locked" reads as noise |
| + // when no spaces are visible (Paul: if you can't see your |
| + // spaces, you're not signed in). The unlock button — a passkey |
| + // icon and five words — is the whole story. |
| + EmptyView() |
| case .restored(let n): |
| // Visible only in the beat before the rail fills and RootView |
| // swaps this screen out. |
| @@ -886,3 +897,25 @@ private struct AppleDoorButton: NSViewRepresentable { |
| @objc func tap() { action() } |
| } |
| } |
| + |
| +// 🤖 One geometry for every door — the Mac sizing of the iOS DoorButtonStyle |
| +// (Paul, 2026-07-19: the Apple control fixes the shape; no mixed button |
| +// types in the list). Prominent = the promoted door / a step's main action. |
| +// The pressed door hosts its own spinner, so busy state lives in the button. |
| +private struct MacDoorButtonStyle: ButtonStyle { |
| + var prominent = false |
| + @Environment(\.isEnabled) private var enabled |
| + |
| + static let radius: CGFloat = 8 |
| + static let height: CGFloat = 32 |
| + |
| + func makeBody(configuration: Configuration) -> some View { |
| + configuration.label |
| + .fontWeight(.medium) |
| + .frame(maxWidth: .infinity, minHeight: Self.height) |
| + .foregroundStyle(prominent ? Color.white : Color.accentColor) |
| + .background(prominent ? Color.accentColor : Color.primary.opacity(0.07), |
| + in: RoundedRectangle(cornerRadius: Self.radius, style: .continuous)) |
| + .opacity((configuration.isPressed ? 0.75 : 1) * (enabled ? 1 : 0.55)) |
| + } |
| +} |