From f7f8a4242d61876fef43c9ece084fe13bbf1a01c Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Mon, 8 Jun 2026 23:45:58 -0700 Subject: [PATCH 1/2] feat(onboarding): replace Custom tier card with a "Set up later" button The starting-point step listed Custom as a fourth tier card, but for new users it resolved to the same plan as Everyday and its real purpose was just "let me move on without committing to a curated tier." Surfacing that as a full card added scrolling and a redundant choice. Drop the Custom card (render only `OnboardingTemplate.curatedTiers`) and fold its behavior into the footer: when no tier is selected the primary button reads "Set up later" and applies `.custom` under the hood before advancing. With a tier selected it stays "Continue" with the existing download gating. The `.custom` case is unchanged, so returning users still keep their tuned settings and the recommender stays fully covered by tests. --- Cotabby/Models/OnboardingTemplate.swift | 13 ++++-- Cotabby/UI/WelcomeTemplateStepView.swift | 2 +- Cotabby/UI/WelcomeView.swift | 50 ++++++++++++++++-------- 3 files changed, 44 insertions(+), 21 deletions(-) diff --git a/Cotabby/Models/OnboardingTemplate.swift b/Cotabby/Models/OnboardingTemplate.swift index 78f48e42..be16e35a 100644 --- a/Cotabby/Models/OnboardingTemplate.swift +++ b/Cotabby/Models/OnboardingTemplate.swift @@ -11,9 +11,11 @@ import Foundation /// static value type should not capture. Keeping the data here and the rules in `Support/` keeps the /// resolution pure and unit-testable. -/// One of the onboarding starting points the user chooses from. Quick, Everyday, and Powerful are -/// curated tiers; Custom is the neutral "set it up yourself" option that applies lean defaults so a -/// user who does not want a curated tier can still get going and configure the rest in Settings. +/// One of the onboarding starting points. Quick, Everyday, and Powerful are the curated tiers shown +/// as selectable cards (`curatedTiers`). Custom is the neutral "set it up yourself" option that +/// applies lean defaults; it is no longer its own card. The template step's "Set up later" button +/// applies it under the hood so a user who does not want a curated tier can still move forward and +/// configure the rest in Settings. enum OnboardingTemplate: String, CaseIterable, Identifiable, Equatable, Sendable { case quick case everyday @@ -22,6 +24,11 @@ enum OnboardingTemplate: String, CaseIterable, Identifiable, Equatable, Sendable var id: String { rawValue } + /// The curated tiers shown as selectable cards in onboarding, in display order. Excludes + /// `.custom`, which is applied implicitly by the "Set up later" button rather than picked from a + /// card. Kept distinct from `allCases` so the pure recommender still reasons over every tier. + static var curatedTiers: [OnboardingTemplate] { [.quick, .everyday, .powerful] } + var title: String { switch self { case .quick: diff --git a/Cotabby/UI/WelcomeTemplateStepView.swift b/Cotabby/UI/WelcomeTemplateStepView.swift index d431ca33..24c4eb7e 100644 --- a/Cotabby/UI/WelcomeTemplateStepView.swift +++ b/Cotabby/UI/WelcomeTemplateStepView.swift @@ -34,7 +34,7 @@ struct WelcomeTemplateStepView: View { engineSelector VStack(spacing: 10) { - ForEach(OnboardingTemplate.allCases) { template in + ForEach(OnboardingTemplate.curatedTiers) { template in let availability = OnboardingTemplateRecommender.availability( for: template, hardware: hardware, diff --git a/Cotabby/UI/WelcomeView.swift b/Cotabby/UI/WelcomeView.swift index ddd66c7b..61874626 100644 --- a/Cotabby/UI/WelcomeView.swift +++ b/Cotabby/UI/WelcomeView.swift @@ -27,9 +27,9 @@ struct WelcomeView: View { /// Reports the current step's raw index up to the coordinator so it can persist a resume point. /// The wizard is re-shown from this step if the user is pulled out before finishing (see #314). let onStepChange: (Int) -> Void - /// True when this user has completed a prior onboarding version. Custom keeps the user's existing - /// settings instead of overwriting them with template defaults, since they have already tuned - /// Cotabby and chose Custom precisely to preserve that. + /// True when this user has completed a prior onboarding version. The Custom path keeps the user's + /// existing settings instead of overwriting them with template defaults, since they have already + /// tuned Cotabby; advancing via "Set up later" preserves that. let isReturningUser: Bool @State private var step: WelcomeStep @@ -216,9 +216,17 @@ extension WelcomeView { WelcomeNavigation( canGoBack: true, canContinue: canContinueFromTemplate, + // With no curated tier chosen, the primary button becomes "Set up later" and applies + // the neutral Custom path under the hood, so the user is never blocked on a card. + continueTitle: selectedTemplate == nil ? "Set up later" : "Continue", disabledHint: templateStepDisabledHint, onBack: { step = .permissions }, - onContinue: { step = .aboutYou } + onContinue: { + if selectedTemplate == nil { + applyTemplate(.custom) + } + step = .aboutYou + } ) case .aboutYou: WelcomeNavigation( @@ -663,9 +671,13 @@ extension WelcomeView { return modelDownloadManager.state(for: model) } + /// Whether the template step's primary button is enabled. With no tier chosen it is always + /// enabled: the button reads "Set up later" and applies the neutral Custom path. With a tier + /// chosen, Apple Intelligence is immediately ready, while Open Source waits until that tier's + /// model download has at least started (it finishes in the background). fileprivate var canContinueFromTemplate: Bool { guard let template = selectedTemplate else { - return false + return true } let plan = resolvedPlan(for: template) switch plan.engine { @@ -680,17 +692,18 @@ extension WelcomeView { } } + /// Tooltip for the disabled primary button. Only reachable once a tier is chosen but its Open + /// Source download hasn't started yet — with no tier chosen the button is "Set up later" and + /// always enabled, so there is no longer a "pick something" hint. fileprivate var templateStepDisabledHint: String { - selectedTemplate == nil - ? "Choose a starting point to continue." - : "Hang on while your model starts downloading." + "Hang on while your model starts downloading." } fileprivate func resolvedPlan(for template: OnboardingTemplate) -> ResolvedTemplatePlan { let base = OnboardingTemplateRecommender.resolvePlan(for: template, engine: selectedEngine) - // Returning users picking Custom on the OSS engine see their currently selected local model - // in the footer instead of the static template default, so the card matches the settings - // we will actually preserve in applyTemplate. + // Returning users on the Custom path with the OSS engine keep their currently selected local + // model instead of the static template default, so the done-step status and model activation + // reflect the settings applyTemplate actually preserves for them. guard template == .custom, isReturningUser, @@ -726,12 +739,13 @@ extension WelcomeView { } } - /// Applies a template's settings and starts its model download (if any). Selecting a card is the - /// user's explicit consent to download, so a multi-gigabyte fetch only ever starts from here. + /// Applies a template's settings and starts its model download (if any). Choosing a tier card — + /// or taking the "Set up later" path, which applies `.custom` — is the user's explicit consent to + /// download, so a multi-gigabyte fetch only ever starts from here. fileprivate func applyTemplate(_ template: OnboardingTemplate) { selectedTemplate = template - // Returning users picking Custom keep every setting they previously tuned. Skipping the + // Returning users on the Custom path keep every setting they previously tuned. Skipping the // writes here (and the model download below) preserves their engine, word count, behavior // toggles, and avoids re-triggering a multi-gigabyte fetch they already completed. if template == .custom && isReturningUser { @@ -827,11 +841,13 @@ struct WelcomeButton: View { } } -/// Continue navigation bar for middle wizard steps. -/// "Continue" can be disabled with a tooltip hint explaining what's needed. +/// Continue navigation bar for middle wizard steps. The primary button label defaults to "Continue" +/// but can be overridden (the template step shows "Set up later" when no tier is chosen). The button +/// can be disabled with a tooltip hint explaining what's needed. struct WelcomeNavigation: View { var canGoBack: Bool = false var canContinue: Bool = true + var continueTitle: String = "Continue" var disabledHint: String? var onBack: (() -> Void)? let onContinue: () -> Void @@ -847,7 +863,7 @@ struct WelcomeNavigation: View { Spacer(minLength: 0) - Button("Continue") { + Button(continueTitle) { onContinue() } .buttonStyle(.borderedProminent) From 625bb1e22f89ea0a5cc6113c28a7b3cb78922bfd Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Tue, 9 Jun 2026 00:11:21 -0700 Subject: [PATCH 2/2] refactor(onboarding): make curatedTiers a static let Addresses Greptile feedback: the array is constant, so a stored static let avoids re-allocating it on every access and signals immutability. --- Cotabby/Models/OnboardingTemplate.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Cotabby/Models/OnboardingTemplate.swift b/Cotabby/Models/OnboardingTemplate.swift index be16e35a..294ee6b8 100644 --- a/Cotabby/Models/OnboardingTemplate.swift +++ b/Cotabby/Models/OnboardingTemplate.swift @@ -27,7 +27,7 @@ enum OnboardingTemplate: String, CaseIterable, Identifiable, Equatable, Sendable /// The curated tiers shown as selectable cards in onboarding, in display order. Excludes /// `.custom`, which is applied implicitly by the "Set up later" button rather than picked from a /// card. Kept distinct from `allCases` so the pure recommender still reasons over every tier. - static var curatedTiers: [OnboardingTemplate] { [.quick, .everyday, .powerful] } + static let curatedTiers: [OnboardingTemplate] = [.quick, .everyday, .powerful] var title: String { switch self {