From 5ad5e67cd2ff1371d4387d2740b90a70c40c805b Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 12 Jun 2026 07:48:58 -0700 Subject: [PATCH 1/2] feat(onboarding): redesign the first-run flow as a branded, animated experience Onboarding was functionally solid but visually flat: identical gray cards on every step, none of the brand blue from the app icon, no motion, and a window that changed size in both dimensions on almost every step. - New OnboardingStyle design system: brand-blue backdrop wash, tinted gradient icon tiles, unified card chrome, one-shot staggered reveals, progress pips - Welcome hero: a self-playing ghost-text demo (type, ghost, Tab, accept) staged in a mock app window, so the product sells itself before the flow asks for permissions; feature chips state the on-device/open-source promise - Steps slide horizontally like Setup Assistant pages; the window keeps one 640pt width and only morphs vertically - Permission cards get per-permission tinted tiles, a springing Done state, and a privacy footnote at the moment of the ask - The gated writing-style screen folds into a single personalize step (Make it yours), so the counted flow is always 4 steps - Keys step renders bindings as physical keycaps with a self-pressing hero key; recording, reset, and clear flows are unchanged - Done step: green seal, a menu-bar discovery callout (the one thing users must remember), the existing feature showcase, and the model status - WelcomeStep extracted to Support/ as a pure, tested enum; the resume key moves to cotabbyOnboardingProgressStep2 because step indices changed - Permission reminder window restyled with the same system, keeping its orange required-and-missing urgency semantics --- Cotabby.xcodeproj/project.pbxproj | 82 +- .../App/Coordinators/WelcomeCoordinator.swift | 23 +- Cotabby/Support/OnboardingFlowSteps.swift | 82 ++ Cotabby/UI/Onboarding/OnboardingStyle.swift | 278 ++++++ .../PermissionReminderView.swift | 103 +- Cotabby/UI/Onboarding/WelcomeHeroDemo.swift | 211 +++++ .../Onboarding/WelcomeKeybindStepView.swift | 252 +++++ .../WelcomePermissionStepView.swift | 125 +-- .../WelcomePersonalizeStepView.swift | 70 ++ .../WelcomeTemplateStepView.swift | 158 ++-- Cotabby/UI/Onboarding/WelcomeView.swift | 623 ++++++++++++ Cotabby/UI/WelcomeView.swift | 894 ------------------ CotabbyTests/OnboardingFlowStepTests.swift | 71 ++ 13 files changed, 1872 insertions(+), 1100 deletions(-) create mode 100644 Cotabby/Support/OnboardingFlowSteps.swift create mode 100644 Cotabby/UI/Onboarding/OnboardingStyle.swift rename Cotabby/UI/{ => Onboarding}/PermissionReminderView.swift (53%) create mode 100644 Cotabby/UI/Onboarding/WelcomeHeroDemo.swift create mode 100644 Cotabby/UI/Onboarding/WelcomeKeybindStepView.swift rename Cotabby/UI/{ => Onboarding}/WelcomePermissionStepView.swift (54%) create mode 100644 Cotabby/UI/Onboarding/WelcomePersonalizeStepView.swift rename Cotabby/UI/{ => Onboarding}/WelcomeTemplateStepView.swift (76%) create mode 100644 Cotabby/UI/Onboarding/WelcomeView.swift delete mode 100644 Cotabby/UI/WelcomeView.swift create mode 100644 CotabbyTests/OnboardingFlowStepTests.swift diff --git a/Cotabby.xcodeproj/project.pbxproj b/Cotabby.xcodeproj/project.pbxproj index 22a02434..ebfe417d 100644 --- a/Cotabby.xcodeproj/project.pbxproj +++ b/Cotabby.xcodeproj/project.pbxproj @@ -29,6 +29,7 @@ 07D046D406411ED85AC5758A /* InputMonitorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = BAC01317B0B68E3C4125E421 /* InputMonitorTests.swift */; }; 07E50A9ECCE55072DA311F8F /* BundledRuntimeLocator.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA33F5FFAC5B99384E15CE3E /* BundledRuntimeLocator.swift */; }; 08208001C5439ED68EA2C28D /* SettingsAttentionEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2A02336442BB735EE2E8D064 /* SettingsAttentionEvaluator.swift */; }; + 085C64A7C11A728CFA2A6D57 /* WelcomePersonalizeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4548AF1ED25F574CCFA680 /* WelcomePersonalizeStepView.swift */; }; 087EAFD68591401E870EFEC3 /* OnboardingTemplateFeatureList.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */; }; 08A161D7775D09D116E48F6D /* SuggestionEngineModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = ADBE3E6CC585C1683787C877 /* SuggestionEngineModels.swift */; }; 08A76C831813F9D5CEC578E0 /* frequency_dictionary_en_82_765.txt in Resources */ = {isa = PBXBuildFile; fileRef = 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */; }; @@ -75,6 +76,7 @@ 18382D1919D90E3C1EE143C2 /* AppSurfaceClassifierTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C451E144D220D5C63372A8C0 /* AppSurfaceClassifierTests.swift */; }; 18680D0D66469A2954A50B6C /* SuggestionQualityMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81718CA62FBC775A6CEBCED1 /* SuggestionQualityMetricsStore.swift */; }; 1899BC5A35DC96B4D04B18A5 /* es.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0B6816DF5D33863F966240B4 /* es.txt */; }; + 18F8E078D0CE6B3AF2C6AFFC /* WelcomeHeroDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FE1277859A1402F8FB7E4E /* WelcomeHeroDemo.swift */; }; 19386985A3A91D0843092086 /* AboutPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */; }; 19CA1BF8B508E0E219EF4485 /* SuggestionEngineModelsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470A7DAE3D6A2C873B395AE3 /* SuggestionEngineModelsTests.swift */; }; 19CB55B62977376E9AE8D428 /* VisualContextStartCoalescer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2F01FAC4F57EB08471521196 /* VisualContextStartCoalescer.swift */; }; @@ -104,6 +106,7 @@ 22DF59FD6F3B83B092A6F5ED /* WordCountFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 815F2ABAF6AB75DA3AFBBCEF /* WordCountFormatter.swift */; }; 2314C82FAAA81EB58BFE204D /* ClipboardRelevanceFilter.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3A2AC525DC664DB540D4F19 /* ClipboardRelevanceFilter.swift */; }; 231905D5C463FA994CACFA77 /* AppsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D9C1C921A1CDA2ADFC39EA01 /* AppsPaneView.swift */; }; + 23377D6411E1E5F09056285D /* OnboardingFlowSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2578311F4ABCB3CDFF795A86 /* OnboardingFlowSteps.swift */; }; 23676A1A7099AC04091FD6A3 /* EmojiPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */; }; 2402DC57AE2BCF6A686D30ED /* FocusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C383AE85B971A9605787358 /* FocusModels.swift */; }; 2420363A5F652F51693A018B /* ScreenshotContextGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */; }; @@ -124,7 +127,6 @@ 279F017530A86AF62EB17918 /* EmojiSynonymCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0846DE4E0293AF13890620D3 /* EmojiSynonymCatalog.swift */; }; 27D4F5CACADE171F142178B4 /* SettingsSidebarView.swift in Sources */ = {isa = PBXBuildFile; fileRef = BADB38D0160B47637572FC5E /* SettingsSidebarView.swift */; }; 28198855DD83CDABC02A780C /* AXTextGeometryResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = CB58035EFFD65B767949BAE6 /* AXTextGeometryResolver.swift */; }; - 286B7022E2A2774275004447 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9199B9CEAB320982CA333B8 /* WelcomeTemplateStepView.swift */; }; 2872D907299F79A9A69BBFCB /* EmojiPickerPanelController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 764659D09C3F0E8FBD267102 /* EmojiPickerPanelController.swift */; }; 287FC5401537014F688D92E2 /* SettingsIconTile.swift in Sources */ = {isa = PBXBuildFile; fileRef = E17AAA3C022A8AE39FACAAD5 /* SettingsIconTile.swift */; }; 28D217A96946A2005FCBEBFD /* emoji.json in Resources */ = {isa = PBXBuildFile; fileRef = C379D77029D6E88C8C1B9AF7 /* emoji.json */; }; @@ -139,6 +141,7 @@ 2DF5A3826AAB99C279EBB8DE /* InputMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B81DD30EB657368AACE9625A /* InputMonitor.swift */; }; 2E0FC35AA7D717B978033ED1 /* LlamaRuntimeModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = A804F4DB6FD9BC8C27B2B65F /* LlamaRuntimeModels.swift */; }; 2E3DEB7E89D0146274596F2E /* SettingsContainerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB0CE9AB1286367BA2E82392 /* SettingsContainerView.swift */; }; + 2E79F18B84807EFEAE20FA35 /* WelcomeKeybindStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D1F76146AC25F3B077B1F6D /* WelcomeKeybindStepView.swift */; }; 2E972FB7E0CF14EE03AA55A3 /* SpellingDictionaryCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = F4D9DF8723AF32C058BFACDE /* SpellingDictionaryCatalog.swift */; }; 2EDFE6F33018D2D43D5CB813 /* MacroEngine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 974A8708D2006767BD76862A /* MacroEngine.swift */; }; 2EE05B312C990104BE934772 /* GhostFontSizeStabilizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 335BF59EE80F3A0143B79740 /* GhostFontSizeStabilizerTests.swift */; }; @@ -156,8 +159,8 @@ 31DCCE980B6708401256D4D1 /* FoundationModelDriftEvalTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D49F3B597374208594861B9B /* FoundationModelDriftEvalTests.swift */; }; 323500F336AF70C520926383 /* MacroReferenceSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 64442042F5B57CB0A701DA85 /* MacroReferenceSheet.swift */; }; 32A2915FAE21CD9CE818A9D9 /* SuggestionSettingsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 86460C747AA883FDE756BDBA /* SuggestionSettingsModel.swift */; }; + 331208BF9383479A4C596F2B /* OnboardingFlowSteps.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2578311F4ABCB3CDFF795A86 /* OnboardingFlowSteps.swift */; }; 333C09921443BDDF21A9753D /* SuggestionAvailabilityEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3609CC88A5280B3AA40414DF /* SuggestionAvailabilityEvaluator.swift */; }; - 344B9BF352C97CFA830853D6 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */; }; 35F6F62A299713660CFB4797 /* SettingsPaneScaffold.swift in Sources */ = {isa = PBXBuildFile; fileRef = 19BE12C28A4AB8A4A58C2FF7 /* SettingsPaneScaffold.swift */; }; 36312821AEE03E3E62845958 /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; 36651BFF4917A1E80C667B64 /* SuggestionCoordinatorPredictionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 361D34F219C46FF21AC09B62 /* SuggestionCoordinatorPredictionTests.swift */; }; @@ -201,6 +204,7 @@ 41DD807E251E1DC653540EFD /* InlinePreviewView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 033A468451259A3214EECBE5 /* InlinePreviewView.swift */; }; 429CE592897D8A952F2916C3 /* ConfidenceSuppressionPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BD71ECC2AE4821B643E0935 /* ConfidenceSuppressionPolicy.swift */; }; 42D40F37086294D0E58200C5 /* GhostFontSizeStabilizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */; }; + 437F391EBF32AA088A89F710 /* WelcomeKeybindStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D1F76146AC25F3B077B1F6D /* WelcomeKeybindStepView.swift */; }; 43DED8ABEFF9894ED54097A9 /* DeviceInfoTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C49F67B3EEB2F2A577A54085 /* DeviceInfoTests.swift */; }; 449218D0646AB3745B7E4F30 /* SuggestionEngineRouterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3BDA36955CCCFA87C1F67268 /* SuggestionEngineRouterTests.swift */; }; 4531645066A73971EB2A5FA1 /* EmojiCatalog.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0AC3BF78835C8F2C315932F1 /* EmojiCatalog.swift */; }; @@ -213,7 +217,6 @@ 47654BDCFD2DE6D4DE85D7FE /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */; }; 4767E0C7B4997069EA7ADBD7 /* GhostFontSizeStabilizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9458F0820B3161FE9CF1DDAF /* GhostFontSizeStabilizer.swift */; }; 47EBA122ABE99932326D9E4A /* CompletionRenderMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5A03E565A11581FD2150B142 /* CompletionRenderMode.swift */; }; - 47FDBE21C9A49BBDF79BECB1 /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */; }; 47FDCB1CFF8FA171F5EE1A01 /* SelfCaptureGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = E68BE6A22BA0D42C8DD9868C /* SelfCaptureGate.swift */; }; 4882DF865737ECDC4925F44B /* GeneralPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 07480CE96ED0EBD94817C6B1 /* GeneralPaneView.swift */; }; 49C91DE326A590708D76102A /* BrowserAppDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = B997EC69E1C65B1E18234221 /* BrowserAppDetector.swift */; }; @@ -236,11 +239,11 @@ 4F8EBC5FAE109058D3D2722C /* FocusModels.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0C383AE85B971A9605787358 /* FocusModels.swift */; }; 4FC42808D61BF110425801ED /* MenuBarPopoverDismisser.swift in Sources */ = {isa = PBXBuildFile; fileRef = 44595B534DD7323F0AD60825 /* MenuBarPopoverDismisser.swift */; }; 4FC52FB28AFC013F000D8FF9 /* SecureFieldDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474560E524C1D74BAB1570DA /* SecureFieldDetectorTests.swift */; }; - 4FEA3AC7ABB1982063BC0041 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = A9199B9CEAB320982CA333B8 /* WelcomeTemplateStepView.swift */; }; 5009AF59DE8D40A45C0A5C2F /* ControlTokenMarkersTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 22707E26E2106DF0E826D32D /* ControlTokenMarkersTests.swift */; }; 507E7BCCD189A64C3F8ECB79 /* HomePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D1123AB515110BD0CBA39490 /* HomePaneView.swift */; }; 50AABC3B54A9CF2879232276 /* HuggingFaceModelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */; }; 50E1247BE6A952AB1B12C444 /* PowerSourceMonitor.swift in Sources */ = {isa = PBXBuildFile; fileRef = DB235F0DEA53295DAF8B4FA0 /* PowerSourceMonitor.swift */; }; + 513066CA00F0E9A5E7358A4E /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */; }; 51C069603DA16830868F1628 /* LanguageTagsEditor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */; }; 52518CF0760DFEE9AF7C786C /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; 52F3962FA9F424576D7DB5B8 /* CotabbyDebugOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = C7B2D34A6F3AC9DFD61350F7 /* CotabbyDebugOptions.swift */; }; @@ -258,6 +261,7 @@ 5614E22EAA5F5C37A9E4F7B6 /* LlamaRuntimeManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */; }; 56611BA0087710277140E9E6 /* DisplayCoordinateConverterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C1C5DE0F3FF63545000E2453 /* DisplayCoordinateConverterTests.swift */; }; 5687320132AD97B4086260DF /* SuggestionQualityMetricsStore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81718CA62FBC775A6CEBCED1 /* SuggestionQualityMetricsStore.swift */; }; + 57511FF4840471BFF3C1E534 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A2CEA7B4E0CDACBC986C2 /* WelcomePermissionStepView.swift */; }; 576B3FF30FB457EF04F9A715 /* SuggestionTextColorCodec.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1CE61E74928C221B8BB261C6 /* SuggestionTextColorCodec.swift */; }; 586B36CD813E1432D0AB1380 /* DecodeStopPolicy.swift in Sources */ = {isa = PBXBuildFile; fileRef = D12ABBCE23A946C22894945B /* DecodeStopPolicy.swift */; }; 58AC3193D846FDE88513377D /* BundledRuntimeLocatorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */; }; @@ -273,6 +277,7 @@ 5C119807B84F84B0B1B1C2D5 /* MarkerSelectionSynthesizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = A863F41C0C03D7B4AC5DC002 /* MarkerSelectionSynthesizer.swift */; }; 5C5A2751F848F8A11DCF64B2 /* it-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2D8AA55C2B730110E8598F91 /* it-100k.txt */; }; 5C6B59C2E56A3C4260591095 /* TypoCaseTransferTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D2D0FE44138BCA8B2EE05AFE /* TypoCaseTransferTests.swift */; }; + 5CB91F89B0E4ACA351621C51 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AC1E3CC4048829E78E3E1B /* WelcomeTemplateStepView.swift */; }; 5CED06E89FBEF557DCD6C684 /* SuggestionFocusFreshnessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45A896811745673061AF3612 /* SuggestionFocusFreshnessTests.swift */; }; 5CF34E01F13FB242817057E2 /* Sparkle in Frameworks */ = {isa = PBXBuildFile; productRef = 6F134BAB5BC7015B9B2354AF /* Sparkle */; }; 5D083F544CF36CF6DFA34CB1 /* GhostTextColorPreset.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E44E393DD58B978B1EAB6CF /* GhostTextColorPreset.swift */; }; @@ -286,7 +291,9 @@ 6014B31E2570EFFE45557E33 /* TickMarkSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */; }; 60636D92D12FED132250D8D2 /* PerformancePaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = DEBD6113A3C1038BECC99245 /* PerformancePaneView.swift */; }; 60773158D86A0D2F0EB3FF34 /* SettingsQuickLinkCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = BE0A565A2AD007EBE9D70697 /* SettingsQuickLinkCard.swift */; }; + 610650A14AC5E8EDDC81B4E9 /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */; }; 6106B16C0DBA94EBF838D93E /* PermissionOverlayTracker.swift in Sources */ = {isa = PBXBuildFile; fileRef = E6423D6CC8CC371D2DA899DE /* PermissionOverlayTracker.swift */; }; + 613C60F5D664655CB4D8CC27 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D27A2CEA7B4E0CDACBC986C2 /* WelcomePermissionStepView.swift */; }; 61635150B8004F6CB2FACE65 /* AppSurfaceClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 94B0830FBE4F2E239F670DBA /* AppSurfaceClassifier.swift */; }; 61EC9D635D416115E7C96E0F /* PermissionOverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */; }; 62DBCF429B7F464A6B467725 /* OnboardingFeatureShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */; }; @@ -309,6 +316,7 @@ 68DA5F93B7185B4F5E6DB4C3 /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; 6955C3A4D7AB3EEF7FA7C469 /* InputSuppressionController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2D1F9CEBAB0F330F8E7B61D8 /* InputSuppressionController.swift */; }; 695E431AC3FF79769E2C5EEF /* SuggestionQualityMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4CC566AC1DE33FD0CD30E1E /* SuggestionQualityMetricsStoreTests.swift */; }; + 6A0AC8C06FAF0F52493A6EE5 /* WelcomeTemplateStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E3AC1E3CC4048829E78E3E1B /* WelcomeTemplateStepView.swift */; }; 6A4E62EC9B7B970695F87136 /* TextDirectionDetector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 328847A0F494360033366791 /* TextDirectionDetector.swift */; }; 6A8454A989104AE150308BCF /* it-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 2D8AA55C2B730110E8598F91 /* it-100k.txt */; }; 6AE0B46FB52D189D94E1F79A /* WordCountFormatterTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1E0513E3B23937B099A3CFF2 /* WordCountFormatterTests.swift */; }; @@ -322,7 +330,6 @@ 6E49ADEB31D04DC77A47DEB0 /* FileLogHandler.swift in Sources */ = {isa = PBXBuildFile; fileRef = CE8C2569A8217EE9BD3B197F /* FileLogHandler.swift */; }; 6F2FE689BCA50BEAE80AC6F4 /* ShortcutsPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = EB630F9814388203DD1CA2EC /* ShortcutsPaneView.swift */; }; 709F365A846B908D953FA92D /* FoundationModelPromptRenderer.swift in Sources */ = {isa = PBXBuildFile; fileRef = FE7BF162A12703249726F20A /* FoundationModelPromptRenderer.swift */; }; - 70D6F9480DA4104AD5669569 /* WelcomePermissionStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */; }; 7179FB0EC6411166CCD79F6B /* CompositionInputModeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4F283E5B403F9FEBFB4BA04A /* CompositionInputModeClassifier.swift */; }; 7324B18578C646B1ADFF0C3F /* InsertedTextAdvanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C46024A6D18B1AD9D04B3BD /* InsertedTextAdvanceTests.swift */; }; 735C2E64CA51F58098B30A0D /* it.txt in Resources */ = {isa = PBXBuildFile; fileRef = 0397F1DACB094A0F6A66BC0E /* it.txt */; }; @@ -386,14 +393,13 @@ 8B26F7B26358438D6EB88C2E /* PerformanceMetricsStoreTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7C0FCC5CCF6AE528E3C4DDA7 /* PerformanceMetricsStoreTests.swift */; }; 8B2DFC860803C0A7C4D34A36 /* ContextBuffer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 54EF3C7F5D9D6F3FA50FD51C /* ContextBuffer.swift */; }; 8D0F66DD1E6C988368A4545D /* DownloadFileRescuer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B4A2E2DD6733658EC05BD8 /* DownloadFileRescuer.swift */; }; + 8D9F33F49E880956D0571574 /* OnboardingFlowStepTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = A4B220D9A25174A43E7A258B /* OnboardingFlowStepTests.swift */; }; 8DA36F1521B6A59D8C20AC59 /* Logging in Frameworks */ = {isa = PBXBuildFile; productRef = 5A60D1467BBFECB3DFEB39C2 /* Logging */; }; 902B83CCB82E286FBEB9DAAD /* EmojiPickerPanelLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */; }; 907A0BF56C3BB0CBAF2649AB /* SettingsCategory.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5D0AEFF86F8210CBE7CFCBAD /* SettingsCategory.swift */; }; 908D99B538976C07833157E8 /* OnboardingFeatureShowcase.swift in Sources */ = {isa = PBXBuildFile; fileRef = 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */; }; 909EBE545CE644C6C57F1B5D /* SuggestionCoordinator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8F961F5DF2A392F6F5F94F8A /* SuggestionCoordinator.swift */; }; 90CD3F7238E223DEBA2B4D92 /* TagChip.swift in Sources */ = {isa = PBXBuildFile; fileRef = FB317C82CE2CBC69056BA4B8 /* TagChip.swift */; }; - 90CDBC04A1419F22E5A7CE41 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 264CA64B2AB1611F82E5B760 /* WelcomeView.swift */; }; - 90DC9508F27F712EB61EEB06 /* PermissionReminderView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */; }; 90F287ED3B23FB2AB3EF8CCE /* SuggestionTextNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = B424E2AC97C99D335B0D5751 /* SuggestionTextNormalizer.swift */; }; 914266D314BBBCE2EAB5CBA7 /* OCRTextHygiene.swift in Sources */ = {isa = PBXBuildFile; fileRef = B22FDEB3B1DCC9ADE906CC73 /* OCRTextHygiene.swift */; }; 91ADE463EE72D77E0D3EBBCA /* TickMarkSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */; }; @@ -402,6 +408,7 @@ 91D8189EFCD1BA992EA6F038 /* ConfidenceSuppressionPolicyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 06FF2B0A3094A952A8EBA9B5 /* ConfidenceSuppressionPolicyTests.swift */; }; 924489CEE8171F7AD8579D71 /* FocusDebugOverlayController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0F5E263AB69029D5E13D5EE8 /* FocusDebugOverlayController.swift */; }; 924E6C74380F9289AA721518 /* he.txt in Resources */ = {isa = PBXBuildFile; fileRef = C9C000E46A1E404932F89C81 /* he.txt */; }; + 9301B309462E4CF7F554578F /* OnboardingStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ABC808DE01AF9AD04E38943 /* OnboardingStyle.swift */; }; 930BA578E742D96FD9D340ED /* ContextPaneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 89497C35D1825BAE9625EE06 /* ContextPaneView.swift */; }; 934885ACC2DEA20B27F10948 /* PromptContextSanitizerTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0D80CC2CCAAFE3F23FB8C37A /* PromptContextSanitizerTests.swift */; }; 93524F3B861292206EE635CD /* FocusSessionScopedCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E504DABB83411B3FA0B8DC5 /* FocusSessionScopedCache.swift */; }; @@ -423,7 +430,6 @@ 9973763A9F4EA4D8B4AE59EB /* SuggestionEngineRouter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 384FBCF5D7A3A446C5BE2B8D /* SuggestionEngineRouter.swift */; }; 9A419D0704C95920CB71D3B1 /* RandomMacroEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E3EC87078D3A4C21DB3252C /* RandomMacroEvaluator.swift */; }; 9A82FB431A61719F275623B8 /* PermissionOverlayWindowController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 92C6EB9FDA48ADF425A116A9 /* PermissionOverlayWindowController.swift */; }; - 9ABF75CDA78B27453C3F5B34 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 264CA64B2AB1611F82E5B760 /* WelcomeView.swift */; }; 9ADFFF634912F638D079E1C7 /* SentenceBoundaryClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4B56C250DDEF3E81F9DCBD7 /* SentenceBoundaryClassifier.swift */; }; 9B0CE2B00695EC13A6E6CCF3 /* CurrencyEvaluator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0537986794554F5FABE6EFF3 /* CurrencyEvaluator.swift */; }; 9BA9E52F33F00E65692EE6CD /* he-100k.txt in Resources */ = {isa = PBXBuildFile; fileRef = 7C9BB65FA5FC42B89766B037 /* he-100k.txt */; }; @@ -496,6 +502,7 @@ B6A84E11643F88D88B602F3D /* SuggestionAnchorCacheTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BC3BE51EFE0A1B2B13BD02B /* SuggestionAnchorCacheTests.swift */; }; B6BA7DF77DCA55F9EF090AEE /* ScreenTextExtractor.swift in Sources */ = {isa = PBXBuildFile; fileRef = 59E299BE2E9D42A33D5D2F5D /* ScreenTextExtractor.swift */; }; B709B362B786AA6ED548C673 /* TypoCaseTransfer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 08CE63B8725EBD71A4C024E1 /* TypoCaseTransfer.swift */; }; + B71A95428B4003A29685FD5D /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F7C6D15674B3043C4CEEAC /* WelcomeView.swift */; }; B782EC08B7516791BDB21172 /* FieldStyleCache.swift in Sources */ = {isa = PBXBuildFile; fileRef = B7FBF2B766E728F25899B64E /* FieldStyleCache.swift */; }; B7A98BC225304E4DFED9E622 /* OnboardingTemplateRecommender.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */; }; B816C6191738AB616F2E8D2D /* SuggestionCoordinatorTestSupport.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4C174D8294858BF9DF3D361D /* SuggestionCoordinatorTestSupport.swift */; }; @@ -592,6 +599,7 @@ DC84D6A6A2F9A1060CD20ABB /* TokenCountEstimator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1BA30E71C21C77BB6EA4C166 /* TokenCountEstimator.swift */; }; DCABB8D2B391C7820D6CA5FF /* InsertionSafetyGate.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7D472F9F396672E57873303B /* InsertionSafetyGate.swift */; }; DD065767BC8B557C839464FE /* ModelFileValidator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5C4E5869D103865486AAAEEC /* ModelFileValidator.swift */; }; + DD12A23468C18D37D4C64D88 /* WelcomeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 63F7C6D15674B3043C4CEEAC /* WelcomeView.swift */; }; DD7FA343F1C21C4569F6D181 /* ScreenshotContextGenerator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */; }; DDEDCBAA2196303455F6926A /* AcceptanceModePickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */; }; DE236C9285635C686D66A2F6 /* TerminalAppDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 43E37A7E835D3BDE6265843C /* TerminalAppDetectorTests.swift */; }; @@ -613,7 +621,9 @@ E54F5F03E16859D5A1E3437A /* MacroController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4638C74239D1DE2DC4D87975 /* MacroController.swift */; }; E5CB34ED76BAE87E8A858112 /* WebContentFieldDetectorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 210F9AD332273FE2EB3A9A01 /* WebContentFieldDetectorTests.swift */; }; E64AE96DF2A80A368FDE522D /* LlamaSuggestionEnginePrewarmTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 26EF16C7439BEB156BD9FB03 /* LlamaSuggestionEnginePrewarmTests.swift */; }; + E6DD9EAAFF4001E2922B5F55 /* OnboardingStyle.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9ABC808DE01AF9AD04E38943 /* OnboardingStyle.swift */; }; E6EE3C13FA31F261CD734C69 /* DownloadOutcomeClassifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3DE1975F3B5F4A70478DBF41 /* DownloadOutcomeClassifier.swift */; }; + E74FC77608C8000C5E81B28E /* WelcomeHeroDemo.swift in Sources */ = {isa = PBXBuildFile; fileRef = B6FE1277859A1402F8FB7E4E /* WelcomeHeroDemo.swift */; }; E853B9C7AF93FA595DC417B2 /* EmojiVariantResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1A8414BEB7E34F57607E37FE /* EmojiVariantResolver.swift */; }; E912D4617AE1376061DF1F00 /* LanguageSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4793D4EA5D36D7E5CC216C27 /* LanguageSupportTests.swift */; }; E95888E76AA68A18A88AD8E6 /* EmojiTriggerStateMachine.swift in Sources */ = {isa = PBXBuildFile; fileRef = 312C7306D916963F519CE0D9 /* EmojiTriggerStateMachine.swift */; }; @@ -626,6 +636,7 @@ EB9B5E5F7326AB72E0E44C70 /* SuggestionCaretLayoutRepairTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = EC04832FBD5311352F35241B /* SuggestionCaretLayoutRepairTests.swift */; }; EC4ED03BE4C7DD0E6319F310 /* SuggestionCoordinator+Acceptance.swift in Sources */ = {isa = PBXBuildFile; fileRef = 72B13136DF7318F3E96DF0D3 /* SuggestionCoordinator+Acceptance.swift */; }; ED0843752B297D7E9DB2C468 /* EmojiTriggerStateMachineTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 723E1EFA85D2E61B6C5F33E8 /* EmojiTriggerStateMachineTests.swift */; }; + ED51AE49BAD6DD5BD177F4F1 /* WelcomePersonalizeStepView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AB4548AF1ED25F574CCFA680 /* WelcomePersonalizeStepView.swift */; }; ED9C51B0D7056F0753AADF2D /* GhostSuggestionLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = 043E8AA850F930222DD112C0 /* GhostSuggestionLayout.swift */; }; EE2C9177CE615298595215A8 /* SuggestionOverlayPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */; }; EE87886AC1BFC8BB3DE09762 /* HuggingFaceModelBrowserView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 78E49BDA7F3A42455C4C5350 /* HuggingFaceModelBrowserView.swift */; }; @@ -718,6 +729,7 @@ 12DD19BCE610808F1E38702D /* PermissionOverlayTrackerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionOverlayTrackerTests.swift; sourceTree = ""; }; 12E1E72C09460390583D98D1 /* SymSpellCorrector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SymSpellCorrector.swift; sourceTree = ""; }; 1441B2D89DAE6878DAD11F17 /* EmojiMatcher.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiMatcher.swift; sourceTree = ""; }; + 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReminderView.swift; sourceTree = ""; }; 1827565F4FAD3E4E61CA65C3 /* SecureFieldDetector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureFieldDetector.swift; sourceTree = ""; }; 18D990E515E1AE4F312F4E95 /* BundledRuntimeLocatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocatorTests.swift; sourceTree = ""; }; 1909DF39C47A113382BB53B6 /* RequestIDTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RequestIDTests.swift; sourceTree = ""; }; @@ -745,8 +757,8 @@ 23CFCE3EB3F41DAC0202E9D0 /* HardwareCapabilityProbeTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HardwareCapabilityProbeTests.swift; sourceTree = ""; }; 247561C626843957CFB4B632 /* CurrentWordExtractor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentWordExtractor.swift; sourceTree = ""; }; 24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateFeatureList.swift; sourceTree = ""; }; + 2578311F4ABCB3CDFF795A86 /* OnboardingFlowSteps.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFlowSteps.swift; sourceTree = ""; }; 262BE2F1E97389FE8D7A5FB9 /* Cotabby.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Cotabby.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 264CA64B2AB1611F82E5B760 /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 26EF16C7439BEB156BD9FB03 /* LlamaSuggestionEnginePrewarmTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEnginePrewarmTests.swift; sourceTree = ""; }; 273B4DC844F79B4BE2C8910F /* FocusPollBackoffTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusPollBackoffTests.swift; sourceTree = ""; }; 27A5D63F390E9B7A7FE343FE /* SystemResourceSampler.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SystemResourceSampler.swift; sourceTree = ""; }; @@ -828,8 +840,8 @@ 620D393D3B7E687A08FA9446 /* es-100l.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = "es-100l.txt"; sourceTree = ""; }; 62BD2ADED33249F5BA53D0AD /* EmojiPickerControllerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerControllerTests.swift; sourceTree = ""; }; 62EDF1199CC5E18BD7651661 /* EmojiPickerPanelLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayout.swift; sourceTree = ""; }; + 63F7C6D15674B3043C4CEEAC /* WelcomeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeView.swift; sourceTree = ""; }; 64442042F5B57CB0A701DA85 /* MacroReferenceSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroReferenceSheet.swift; sourceTree = ""; }; - 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionReminderView.swift; sourceTree = ""; }; 66B53214C3842F78B202D498 /* AXHelperTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXHelperTests.swift; sourceTree = ""; }; 66CF2A70D4699421AC9BD849 /* NOTICE.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = NOTICE.md; sourceTree = ""; }; 671689F289D45A124639C9C6 /* EmojiRecentsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiRecentsTests.swift; sourceTree = ""; }; @@ -910,6 +922,7 @@ 99FBB636008490B66CF26772 /* frequency_dictionary_en_82_765.txt */ = {isa = PBXFileReference; lastKnownFileType = text; path = frequency_dictionary_en_82_765.txt; sourceTree = ""; }; 9A7CDA90E128350BFF1A9D66 /* LanguageTagsEditor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LanguageTagsEditor.swift; sourceTree = ""; }; 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MenuBarView.swift; sourceTree = ""; }; + 9ABC808DE01AF9AD04E38943 /* OnboardingStyle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingStyle.swift; sourceTree = ""; }; 9B3179B40A81DF121D1221C6 /* StaticTextRunWalkThrottleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTextRunWalkThrottleTests.swift; sourceTree = ""; }; 9B55A4362AB7F0528C661C4C /* SuggestionTextNormalizerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionTextNormalizerTests.swift; sourceTree = ""; }; 9B84BAE361626891F19DC9DB /* ScreenshotContextGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScreenshotContextGenerator.swift; sourceTree = ""; }; @@ -918,6 +931,7 @@ 9C8F07AC52C7A482F5FE34C5 /* SuggestionSessionReconcilerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionSessionReconcilerTests.swift; sourceTree = ""; }; 9CC2D6472ACD377FD73A5801 /* ControlTokenMarkers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ControlTokenMarkers.swift; sourceTree = ""; }; 9CF4FB0EC6C1BEB4EA74910A /* ClipboardContextProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardContextProvider.swift; sourceTree = ""; }; + 9D1F76146AC25F3B077B1F6D /* WelcomeKeybindStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeKeybindStepView.swift; sourceTree = ""; }; 9D598CC3134879999D567455 /* SuggestionOverlayPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SuggestionOverlayPresenter.swift; sourceTree = ""; }; 9D77C99769239E4B33D6B2C9 /* InlineCommandCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlineCommandCoordinator.swift; sourceTree = ""; }; 9D82FFC568527700EC17C07D /* PermissionModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionModels.swift; sourceTree = ""; }; @@ -929,6 +943,7 @@ A28B4A4368DB33C25E3AB5F3 /* EmojiPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPaneView.swift; sourceTree = ""; }; A3E8E86A14090BC7BD13BA76 /* AppDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AppDelegate.swift; sourceTree = ""; }; A3FA53BBC3D81503C1D17477 /* AboutPaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AboutPaneView.swift; sourceTree = ""; }; + A4B220D9A25174A43E7A258B /* OnboardingFlowStepTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingFlowStepTests.swift; sourceTree = ""; }; A520809E71697E3BB9A8139C /* HuggingFaceModels.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HuggingFaceModels.swift; sourceTree = ""; }; A52D0B550E00EF173A5D157E /* LlamaRuntimeManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaRuntimeManager.swift; sourceTree = ""; }; A594D02B0EF3C2DD62EFE69A /* GhostTextPreview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GhostTextPreview.swift; sourceTree = ""; }; @@ -937,10 +952,10 @@ A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ModelFileValidatorTests.swift; sourceTree = ""; }; A854CAFB1F557BC4CAED8819 /* VisualContextCoordinator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VisualContextCoordinator.swift; sourceTree = ""; }; A863F41C0C03D7B4AC5DC002 /* MarkerSelectionSynthesizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MarkerSelectionSynthesizer.swift; sourceTree = ""; }; - A9199B9CEAB320982CA333B8 /* WelcomeTemplateStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeTemplateStepView.swift; sourceTree = ""; }; AA33F5FFAC5B99384E15CE3E /* BundledRuntimeLocator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledRuntimeLocator.swift; sourceTree = ""; }; AABCC3FD99B1824A81E665F3 /* LlamaSuggestionEngineCancellationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LlamaSuggestionEngineCancellationTests.swift; sourceTree = ""; }; AAEFF8BAEC9168CADEDD4757 /* InlinePreviewPanelController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InlinePreviewPanelController.swift; sourceTree = ""; }; + AB4548AF1ED25F574CCFA680 /* WelcomePersonalizeStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePersonalizeStepView.swift; sourceTree = ""; }; AB84C175EA5B59E118F58C49 /* llama-eval-cases.json */ = {isa = PBXFileReference; lastKnownFileType = text.json; path = "llama-eval-cases.json"; sourceTree = ""; }; AC70775535A3428991025AB8 /* AXHelper.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AXHelper.swift; sourceTree = ""; }; AD752451330486FE270018B0 /* CustomRulesTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRulesTests.swift; sourceTree = ""; }; @@ -966,6 +981,7 @@ B6ACCB12E4DB32D2F2BEA567 /* PermissionHostApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PermissionHostApp.swift; sourceTree = ""; }; B6D36DB66629CF22C1783945 /* CompletionSeamGuardTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionSeamGuardTests.swift; sourceTree = ""; }; B6D42CD456B4B3C988B148A6 /* FocusTrackingModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FocusTrackingModel.swift; sourceTree = ""; }; + B6FE1277859A1402F8FB7E4E /* WelcomeHeroDemo.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeHeroDemo.swift; sourceTree = ""; }; B78AA11B52A6588119ABF76F /* TokenCountEstimatorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenCountEstimatorTests.swift; sourceTree = ""; }; B7B185BA246A526CBA85E581 /* EmojiPickerPanelLayoutTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPickerPanelLayoutTests.swift; sourceTree = ""; }; B7EB66904C35A7D8BEF5D2A5 /* SpeculativeAcceptanceContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpeculativeAcceptanceContext.swift; sourceTree = ""; }; @@ -1015,6 +1031,7 @@ D1123AB515110BD0CBA39490 /* HomePaneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HomePaneView.swift; sourceTree = ""; }; D12ABBCE23A946C22894945B /* DecodeStopPolicy.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DecodeStopPolicy.swift; sourceTree = ""; }; D1AA6A6F4C3A54B5DA2A0022 /* StreamedGhostTextPolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StreamedGhostTextPolicyTests.swift; sourceTree = ""; }; + D27A2CEA7B4E0CDACBC986C2 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = ""; }; D2D0FE44138BCA8B2EE05AFE /* TypoCaseTransferTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TypoCaseTransferTests.swift; sourceTree = ""; }; D2F46767D9D1F0D44E239CA8 /* DownloadFileRescuerTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadFileRescuerTests.swift; sourceTree = ""; }; D3A2AC525DC664DB540D4F19 /* ClipboardRelevanceFilter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClipboardRelevanceFilter.swift; sourceTree = ""; }; @@ -1027,7 +1044,6 @@ D504BEB224E0C176F5FCFF6E /* CompletionRenderModePolicyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CompletionRenderModePolicyTests.swift; sourceTree = ""; }; D562A73C7C680F2AA65F9F7F /* SpellingDictionaryResourceTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpellingDictionaryResourceTests.swift; sourceTree = ""; }; D5A5591BEB9EE7B6E9064412 /* SelfCaptureGateTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelfCaptureGateTests.swift; sourceTree = ""; }; - D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomePermissionStepView.swift; sourceTree = ""; }; D77364C1AF183EF1C0A4074D /* CaretLinePosition.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretLinePosition.swift; sourceTree = ""; }; D8083D44ABCDCFA68A4CD497 /* MacroEngineTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MacroEngineTests.swift; sourceTree = ""; }; D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OnboardingTemplateFeatureListTests.swift; sourceTree = ""; }; @@ -1051,6 +1067,7 @@ E217A66717D78E1E49350EC8 /* DownloadOutcomeClassifierTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DownloadOutcomeClassifierTests.swift; sourceTree = ""; }; E260C4D08C786CDBD527B329 /* PromptSectionBudgetTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PromptSectionBudgetTests.swift; sourceTree = ""; }; E27B962C66727776D00069DE /* EmojiPopularity.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EmojiPopularity.swift; sourceTree = ""; }; + E3AC1E3CC4048829E78E3E1B /* WelcomeTemplateStepView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WelcomeTemplateStepView.swift; sourceTree = ""; }; E3C84377F352140759B448C9 /* CaretGeometrySelector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CaretGeometrySelector.swift; sourceTree = ""; }; E43E587E421AF544A8300CE4 /* CustomRulesCatalog.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CustomRulesCatalog.swift; sourceTree = ""; }; E5DAF68AEBFE334F68A65E82 /* AcceptanceModePickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AcceptanceModePickerView.swift; sourceTree = ""; }; @@ -1378,6 +1395,14 @@ isa = PBXGroup; children = ( 926B332E7B4CFEE42C4CAA75 /* OnboardingFeatureShowcase.swift */, + 9ABC808DE01AF9AD04E38943 /* OnboardingStyle.swift */, + 168A0DBF62BEF674B96CEBD2 /* PermissionReminderView.swift */, + B6FE1277859A1402F8FB7E4E /* WelcomeHeroDemo.swift */, + 9D1F76146AC25F3B077B1F6D /* WelcomeKeybindStepView.swift */, + D27A2CEA7B4E0CDACBC986C2 /* WelcomePermissionStepView.swift */, + AB4548AF1ED25F574CCFA680 /* WelcomePersonalizeStepView.swift */, + E3AC1E3CC4048829E78E3E1B /* WelcomeTemplateStepView.swift */, + 63F7C6D15674B3043C4CEEAC /* WelcomeView.swift */, ); path = Onboarding; sourceTree = ""; @@ -1478,6 +1503,7 @@ 03766F6253FF17639230C0F6 /* ModelAndPresentationValueTests.swift */, A829F28F01FAE76CA7244BBC /* ModelFileValidatorTests.swift */, 5EED3CD2BC7B48DF35DEE562 /* OCRTextHygieneTests.swift */, + A4B220D9A25174A43E7A258B /* OnboardingFlowStepTests.swift */, D814BBA41CF29E8DD9954651 /* OnboardingTemplateFeatureListTests.swift */, 01B72736E416910878E8E493 /* OnboardingTemplateRecommenderTests.swift */, 28B7EB84781C0ED57844585E /* OnboardingTemplateTests.swift */, @@ -1591,14 +1617,10 @@ 83A810F9D28A18BA6F2066C7 /* MenuBarSections.swift */, BD42C7E2852F59BEF7972663 /* MenuBarStatusLabelView.swift */, 9AA0117B322C625F6D4BBEAB /* MenuBarView.swift */, - 656F58E56FE9BC087B6F1D33 /* PermissionReminderView.swift */, 5484C8A04B9C00CF79D589EB /* ScreenFrameReader.swift */, DF3A73EB848780061FC162C0 /* SpellingDictionaryPicker.swift */, FB317C82CE2CBC69056BA4B8 /* TagChip.swift */, 67586807ACE8EB13C9014535 /* TickMarkSlider.swift */, - D5D6C2318E405AA717D1C256 /* WelcomePermissionStepView.swift */, - A9199B9CEAB320982CA333B8 /* WelcomeTemplateStepView.swift */, - 264CA64B2AB1611F82E5B760 /* WelcomeView.swift */, ); path = UI; sourceTree = ""; @@ -1711,6 +1733,7 @@ 357C18383B047F24A531BDCD /* MidWordContinuationPolicy.swift */, 54150A507B03221F137D539B /* MirrorOverlayLayout.swift */, B22FDEB3B1DCC9ADE906CC73 /* OCRTextHygiene.swift */, + 2578311F4ABCB3CDFF795A86 /* OnboardingFlowSteps.swift */, 24F613F0E2F7046E6532A09C /* OnboardingTemplateFeatureList.swift */, FA878B447441BB4F3E327CC8 /* OnboardingTemplateRecommender.swift */, B25C3087D4A9F4DC52FD5A69 /* PerDomainDisableSettings.swift */, @@ -2085,6 +2108,8 @@ DD065767BC8B557C839464FE /* ModelFileValidator.swift in Sources */, 914266D314BBBCE2EAB5CBA7 /* OCRTextHygiene.swift in Sources */, 908D99B538976C07833157E8 /* OnboardingFeatureShowcase.swift in Sources */, + 331208BF9383479A4C596F2B /* OnboardingFlowSteps.swift in Sources */, + E6DD9EAAFF4001E2922B5F55 /* OnboardingStyle.swift in Sources */, FE4191A7C84E547DCD4F8B44 /* OnboardingTemplate.swift in Sources */, 087EAFD68591401E870EFEC3 /* OnboardingTemplateFeatureList.swift in Sources */, B7A98BC225304E4DFED9E622 /* OnboardingTemplateRecommender.swift in Sources */, @@ -2099,7 +2124,7 @@ D603EC842D9D7A1A81899050 /* PermissionModels.swift in Sources */, 4B47C5DF1EF4276E0B143AF5 /* PermissionOverlayTracker.swift in Sources */, 9A82FB431A61719F275623B8 /* PermissionOverlayWindowController.swift in Sources */, - 47FDBE21C9A49BBDF79BECB1 /* PermissionReminderView.swift in Sources */, + 513066CA00F0E9A5E7358A4E /* PermissionReminderView.swift in Sources */, D553BAA6C9F478533BD4A221 /* PermissionsPaneView.swift in Sources */, C607A624A0FB697486C56B8E /* PowerSourceMonitor.swift in Sources */, FEC24B9C23274B9FA1F0072E /* PromptContextSanitizer.swift in Sources */, @@ -2180,9 +2205,12 @@ 641A9FAF3009A3E2AA06D74B /* VisualContextStartCoalescer.swift in Sources */, 86AC625B4DD14EF807002FA2 /* WebContentFieldDetector.swift in Sources */, 3AB45217DFC86AFC98C374D6 /* WelcomeCoordinator.swift in Sources */, - 70D6F9480DA4104AD5669569 /* WelcomePermissionStepView.swift in Sources */, - 4FEA3AC7ABB1982063BC0041 /* WelcomeTemplateStepView.swift in Sources */, - 90CDBC04A1419F22E5A7CE41 /* WelcomeView.swift in Sources */, + 18F8E078D0CE6B3AF2C6AFFC /* WelcomeHeroDemo.swift in Sources */, + 2E79F18B84807EFEAE20FA35 /* WelcomeKeybindStepView.swift in Sources */, + 613C60F5D664655CB4D8CC27 /* WelcomePermissionStepView.swift in Sources */, + ED51AE49BAD6DD5BD177F4F1 /* WelcomePersonalizeStepView.swift in Sources */, + 5CB91F89B0E4ACA351621C51 /* WelcomeTemplateStepView.swift in Sources */, + B71A95428B4003A29685FD5D /* WelcomeView.swift in Sources */, A6DAD9EB9AE88A319EADAC7B /* WindowScreenshotService.swift in Sources */, AD635E15149E7266BC309F34 /* WordCountFormatter.swift in Sources */, D2BB9B47E0E1619021931B49 /* WritingPaneView.swift in Sources */, @@ -2324,6 +2352,8 @@ 317883210D1D1D5CD654E562 /* ModelFileValidator.swift in Sources */, 93EBF0366891222B7DD6C38D /* OCRTextHygiene.swift in Sources */, 62DBCF429B7F464A6B467725 /* OnboardingFeatureShowcase.swift in Sources */, + 23377D6411E1E5F09056285D /* OnboardingFlowSteps.swift in Sources */, + 9301B309462E4CF7F554578F /* OnboardingStyle.swift in Sources */, FC6B0524B774F20C18BD6889 /* OnboardingTemplate.swift in Sources */, 64DA031AEAC20AC6C852A24A /* OnboardingTemplateFeatureList.swift in Sources */, DF8794793110A8ED234CBA96 /* OnboardingTemplateRecommender.swift in Sources */, @@ -2338,7 +2368,7 @@ CCC83DC5AE51C17F153D5A6A /* PermissionModels.swift in Sources */, 6106B16C0DBA94EBF838D93E /* PermissionOverlayTracker.swift in Sources */, 61EC9D635D416115E7C96E0F /* PermissionOverlayWindowController.swift in Sources */, - 90DC9508F27F712EB61EEB06 /* PermissionReminderView.swift in Sources */, + 610650A14AC5E8EDDC81B4E9 /* PermissionReminderView.swift in Sources */, 39571AB31481959CD5C223AE /* PermissionsPaneView.swift in Sources */, 50E1247BE6A952AB1B12C444 /* PowerSourceMonitor.swift in Sources */, 98E2E14A069384C1088CDB44 /* PromptContextSanitizer.swift in Sources */, @@ -2419,9 +2449,12 @@ 19CB55B62977376E9AE8D428 /* VisualContextStartCoalescer.swift in Sources */, D75626719A38F535D475C675 /* WebContentFieldDetector.swift in Sources */, 4AC255BE2D0CCC67B8882C7A /* WelcomeCoordinator.swift in Sources */, - 344B9BF352C97CFA830853D6 /* WelcomePermissionStepView.swift in Sources */, - 286B7022E2A2774275004447 /* WelcomeTemplateStepView.swift in Sources */, - 9ABF75CDA78B27453C3F5B34 /* WelcomeView.swift in Sources */, + E74FC77608C8000C5E81B28E /* WelcomeHeroDemo.swift in Sources */, + 437F391EBF32AA088A89F710 /* WelcomeKeybindStepView.swift in Sources */, + 57511FF4840471BFF3C1E534 /* WelcomePermissionStepView.swift in Sources */, + 085C64A7C11A728CFA2A6D57 /* WelcomePersonalizeStepView.swift in Sources */, + 6A0AC8C06FAF0F52493A6EE5 /* WelcomeTemplateStepView.swift in Sources */, + DD12A23468C18D37D4C64D88 /* WelcomeView.swift in Sources */, 1F8CC88AFFE67C08944CF506 /* WindowScreenshotService.swift in Sources */, 22DF59FD6F3B83B092A6F5ED /* WordCountFormatter.swift in Sources */, E51FA12B690428CA431328FC /* WritingPaneView.swift in Sources */, @@ -2516,6 +2549,7 @@ 25D4FC8D191A50F63E6391F9 /* ModelAndPresentationValueTests.swift in Sources */, 65478B0DABF5460C32D4C458 /* ModelFileValidatorTests.swift in Sources */, 3F5630CFB7BA40B900E832A1 /* OCRTextHygieneTests.swift in Sources */, + 8D9F33F49E880956D0571574 /* OnboardingFlowStepTests.swift in Sources */, DA23422A2CF77CFD3B1283C8 /* OnboardingTemplateFeatureListTests.swift in Sources */, D648DD70AD847F67B77CE052 /* OnboardingTemplateRecommenderTests.swift in Sources */, 0160F9D9929465E6B6A3385F /* OnboardingTemplateTests.swift in Sources */, diff --git a/Cotabby/App/Coordinators/WelcomeCoordinator.swift b/Cotabby/App/Coordinators/WelcomeCoordinator.swift index 959dc380..3bd82246 100644 --- a/Cotabby/App/Coordinators/WelcomeCoordinator.swift +++ b/Cotabby/App/Coordinators/WelcomeCoordinator.swift @@ -10,10 +10,6 @@ import SwiftUI @MainActor final class WelcomeCoordinator: NSObject, NSWindowDelegate { private enum Layout { - /// Match the first welcome step so the window does not flash at an oversized default before - /// SwiftUI has a chance to report its preferred content size. - static let initialContentSize = NSSize(width: 500, height: 360) - /// Keep a margin between the onboarding window and the screen edges when a step's preferred /// height would otherwise exceed the visible screen. The SwiftUI content scrolls to absorb /// the difference, so clamping here only ever shrinks the window, never clips the footer. @@ -51,7 +47,13 @@ final class WelcomeCoordinator: NSObject, NSWindowDelegate { /// relaunches Cotabby, which lands before the final "Start Using Cotabby" tap that stamps /// completion. Without this, that relaunch drops them back at step one and onboarding repeats /// every time (issue #314). Cleared on completion so a future version bump starts clean. - private static let onboardingProgressStepKey = "cotabbyOnboardingProgressStep" + /// + /// The "2" suffix marks the second step-numbering scheme (the redesign folded the writing-style + /// step into "personalize", shifting every later raw index). Reading the old key would resume a + /// mid-flow user onto the wrong step, so the old key is abandoned rather than reinterpreted; + /// anyone caught mid-flow across the update restarts at the welcome step, which is the safe + /// outcome. Any future change to `WelcomeStep`'s numbering must bump this suffix again. + private static let onboardingProgressStepKey = "cotabbyOnboardingProgressStep2" init( permissionManager: PermissionManager, @@ -117,6 +119,8 @@ final class WelcomeCoordinator: NSObject, NSWindowDelegate { return } + let resumeStepIndex = userDefaults.integer(forKey: Self.onboardingProgressStepKey) + let hostingController = NSHostingController( rootView: WelcomeView( permissionManager: permissionManager, @@ -131,7 +135,7 @@ final class WelcomeCoordinator: NSObject, NSWindowDelegate { onDismiss: { [weak self] in self?.completeOnboarding() }, - initialStepIndex: userDefaults.integer(forKey: Self.onboardingProgressStepKey), + initialStepIndex: resumeStepIndex, isReturningUser: userDefaults.integer(forKey: Self.onboardingCompletedVersionKey) > 0, onStepChange: { [weak self] stepIndex in self?.recordProgress(stepIndex: stepIndex) @@ -139,8 +143,13 @@ final class WelcomeCoordinator: NSObject, NSWindowDelegate { ) ) + // Size the window for the step the wizard actually opens on (the resume point, mirroring + // WelcomeView's own fallback for out-of-range indices) so it never flashes at one size and + // immediately animates to another. + let initialStep = WelcomeStep(rawValue: resumeStepIndex) ?? .welcome + let window = NSWindow( - contentRect: CGRect(origin: .zero, size: Layout.initialContentSize), + contentRect: CGRect(origin: .zero, size: initialStep.preferredWindowSize), styleMask: [.titled, .closable, .fullSizeContentView], backing: .buffered, defer: false diff --git a/Cotabby/Support/OnboardingFlowSteps.swift b/Cotabby/Support/OnboardingFlowSteps.swift new file mode 100644 index 00000000..98c582ec --- /dev/null +++ b/Cotabby/Support/OnboardingFlowSteps.swift @@ -0,0 +1,82 @@ +import Foundation + +/// File overview: +/// Pure sequencing model for the first-run onboarding wizard: the ordered steps, which of them +/// count toward the progress indicator, and the window size each one prefers. Extracted from +/// `WelcomeView` so the flow's shape is unit-testable without SwiftUI. +/// +/// Raw values are persisted by `WelcomeCoordinator` as the wizard's resume point, so reordering or +/// inserting cases is a breaking change for stored progress. If the numbering scheme changes, +/// `WelcomeCoordinator` must move to a fresh UserDefaults key rather than reinterpret old indices +/// (see `onboardingProgressStepKey` there). +enum WelcomeStep: Int, CaseIterable, Comparable, Sendable { + case welcome + case permissions + case template + case personalize + case keybind + case done + + static func < (lhs: WelcomeStep, rhs: WelcomeStep) -> Bool { + lhs.rawValue < rhs.rawValue + } + + /// The flow is strictly linear, so navigation is derived from case order instead of hand-wired + /// per step. `nil` past either end keeps the terminal steps terminal. + var next: WelcomeStep? { + WelcomeStep(rawValue: rawValue + 1) + } + + var previous: WelcomeStep? { + WelcomeStep(rawValue: rawValue - 1) + } + + /// Number of steps shown in the progress indicator (the middle, non-terminal steps). + static let totalProgressSteps = 4 + + /// 1-based position within the progress indicator, or `nil` for the intro/outro steps that + /// intentionally sit outside the counted flow. + var progressIndex: Int? { + switch self { + case .welcome, .done: + return nil + case .permissions: + return 1 + case .template: + return 2 + case .personalize: + return 3 + case .keybind: + return 4 + } + } + + /// Product-chosen window sizes. The coordinator clamps the height to the visible screen, and + /// the scrolling content absorbs any overflow, so these are targets rather than hard + /// guarantees. Width is constant across the flow on purpose: the window only ever morphs + /// vertically between steps, which reads as one calm surface instead of a window that jumps + /// around in both dimensions. + var preferredWindowSize: NSSize { + NSSize(width: WelcomeStep.windowWidth, height: preferredWindowHeight) + } + + /// Single width shared by every step (see `preferredWindowSize`). + static let windowWidth: CGFloat = 640 + + private var preferredWindowHeight: CGFloat { + switch self { + case .welcome: + return 640 + case .permissions: + return 600 + case .template: + return 720 + case .personalize: + return 620 + case .keybind: + return 580 + case .done: + return 740 + } + } +} diff --git a/Cotabby/UI/Onboarding/OnboardingStyle.swift b/Cotabby/UI/Onboarding/OnboardingStyle.swift new file mode 100644 index 00000000..9bab9528 --- /dev/null +++ b/Cotabby/UI/Onboarding/OnboardingStyle.swift @@ -0,0 +1,278 @@ +import SwiftUI + +/// File overview: +/// The shared design system for the first-run onboarding flow: brand palette, window backdrop, +/// icon tiles, card chrome, entrance choreography, and the step header / navigation / progress +/// components every step composes. Keeping the vocabulary in one file is what keeps the six steps +/// reading as one designed surface instead of six separately-styled screens. +/// +/// Two constraints shape everything here: +/// 1. Energy. The backdrop and chrome are static; the only continuous animations in onboarding +/// are the explicitly-looping product demos, and every entrance effect is a one-shot spring. +/// 2. Reduce Motion. Each animated component checks `accessibilityReduceMotion` and collapses to +/// its resting frame, mirroring the convention in `OnboardingFeatureShowcase`. +enum OnboardingTheme { + /// Cotabby's brand blue, sampled from the app icon's background (#007AFF). Defined explicitly + /// rather than via `Color.accentColor` so onboarding stays on-brand even when the user has + /// picked a different system accent color, and identical in both appearances. + static let accent = Color(red: 0.0, green: 0.478, blue: 1.0) + + /// Lighter companion to `accent`, used as the top stop of icon-tile gradients so tiles read as + /// lit from above (the System Settings icon treatment). + static let accentSoft = Color(red: 0.33, green: 0.63, blue: 1.0) + + /// Horizontal content inset shared by every step so text columns line up across transitions. + static let horizontalPadding: CGFloat = 36 +} + +// MARK: - Backdrop + +/// Window-filling backdrop: the standard translucent material with a soft brand-blue glow washing +/// down from the top edge. Static by design (no `TimelineView`, no looping animation) so the +/// onboarding window costs nothing while it idles. +struct OnboardingBackdrop: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + ZStack { + Rectangle() + .fill(.ultraThinMaterial) + + // Two offset radial washes rather than one centered one: the asymmetry keeps the + // gradient from reading as a spotlight and gives the titlebar region gentle color. + RadialGradient( + colors: [OnboardingTheme.accent.opacity(colorScheme == .dark ? 0.26 : 0.14), .clear], + center: UnitPoint(x: 0.15, y: -0.1), + startRadius: 10, + endRadius: 460 + ) + + RadialGradient( + colors: [OnboardingTheme.accentSoft.opacity(colorScheme == .dark ? 0.16 : 0.10), .clear], + center: UnitPoint(x: 0.95, y: 0.0), + startRadius: 10, + endRadius: 420 + ) + } + .ignoresSafeArea() + } +} + +// MARK: - Icon tiles + +/// Tinted gradient squircle with a white SF Symbol, the System Settings icon idiom. Used for +/// permission rows, template tiers, and step headers so every icon in the flow shares one shape +/// language. The gradient brightens toward the top to read as lit from above. +struct OnboardingIconTile: View { + let systemImage: String + let tint: Color + var size: CGFloat = 36 + + var body: some View { + ZStack { + RoundedRectangle(cornerRadius: size * 0.26, style: .continuous) + .fill( + LinearGradient( + colors: [tint.opacity(0.85), tint], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: tint.opacity(0.35), radius: size * 0.14, y: size * 0.06) + + Image(systemName: systemImage) + .font(.system(size: size * 0.46, weight: .semibold)) + .foregroundStyle(.white) + } + .frame(width: size, height: size) + } +} + +// MARK: - Card chrome + +/// The standard onboarding card surface: translucent material, continuous corners, a hairline +/// stroke that survives both appearances, and a whisper of depth. One modifier so a future tweak +/// (radius, stroke, shadow) lands on every card at once. +private struct OnboardingCardChrome: ViewModifier { + var cornerRadius: CGFloat = 16 + + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .fill(.regularMaterial) + .shadow(color: .black.opacity(0.07), radius: 3, y: 1) + ) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(Color.primary.opacity(0.07), lineWidth: 0.5) + ) + } +} + +extension View { + func onboardingCard(cornerRadius: CGFloat = 16) -> some View { + modifier(OnboardingCardChrome(cornerRadius: cornerRadius)) + } +} + +// MARK: - Entrance choreography + +/// One-shot staggered entrance: fade in while rising a few points, delayed by the element's index +/// so a step's content settles top-to-bottom. Collapses to the resting frame under Reduce Motion. +/// +/// `@State` (not a transition) so the effect plays exactly once per appearance of the step's view +/// tree and never replays on unrelated re-renders such as download-progress ticks. +private struct OnboardingReveal: ViewModifier { + let index: Int + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var revealed = false + + func body(content: Content) -> some View { + content + .opacity(revealed ? 1 : 0) + .offset(y: revealed ? 0 : 14) + .onAppear { + guard !reduceMotion else { + revealed = true + return + } + withAnimation(.spring(response: 0.55, dampingFraction: 0.85).delay(Double(index) * 0.07)) { + revealed = true + } + } + } +} + +extension View { + /// Staggered entrance for onboarding content. `index` is the element's top-to-bottom position + /// within its step (0 for the first element), which sets its share of the stagger delay. + func onboardingReveal(_ index: Int) -> some View { + modifier(OnboardingReveal(index: index)) + } +} + +// MARK: - Step header + +/// Centered title block shared by every middle step: optional icon tile, large rounded title, +/// secondary subtitle. One component so typography can never drift between steps. +struct OnboardingStepHeader: View { + var systemImage: String? + var tint: Color = OnboardingTheme.accent + let title: String + let subtitle: String + + var body: some View { + VStack(spacing: 10) { + if let systemImage { + OnboardingIconTile(systemImage: systemImage, tint: tint, size: 44) + .padding(.bottom, 4) + } + + Text(title) + .font(.system(size: 26, weight: .bold, design: .rounded)) + + Text(subtitle) + .font(.system(size: 14, design: .rounded)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + .fixedSize(horizontal: false, vertical: true) + } + .frame(maxWidth: .infinity) + } +} + +// MARK: - Progress + +/// Capsule progress pips for the middle steps. Completed pips fill with the brand color, the +/// current pip stretches into a gradient lozenge, and future pips stay quiet. The textual +/// "Step X of Y" lives only in the accessibility label; sighted users get position from the pips. +struct OnboardingProgressPips: View { + let current: Int + let total: Int + + var body: some View { + HStack(spacing: 7) { + ForEach(1...total, id: \.self) { index in + Capsule() + .fill(fillStyle(for: index)) + .frame(width: index == current ? 26 : 7, height: 7) + } + } + .animation(.spring(response: 0.4, dampingFraction: 0.8), value: current) + .accessibilityElement(children: .ignore) + .accessibilityLabel("Step \(current) of \(total)") + } + + private func fillStyle(for index: Int) -> AnyShapeStyle { + if index == current { + return AnyShapeStyle( + LinearGradient( + colors: [OnboardingTheme.accentSoft, OnboardingTheme.accent], + startPoint: .leading, + endPoint: .trailing + ) + ) + } + if index < current { + return AnyShapeStyle(OnboardingTheme.accent.opacity(0.55)) + } + return AnyShapeStyle(Color.secondary.opacity(0.22)) + } +} + +// MARK: - Buttons + +/// Primary call-to-action used on the welcome and done steps. Brand-tinted and width-capped so it +/// reads as a deliberate, centered action rather than an edge-to-edge bar. +struct WelcomeButton: View { + let title: String + let action: () -> Void + + var body: some View { + Button(action: action) { + Text(title) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .frame(maxWidth: 260) + .padding(.vertical, 2) + } + .buttonStyle(.borderedProminent) + .tint(OnboardingTheme.accent) + .controlSize(.large) + } +} + +/// Back/Continue navigation bar for the 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 + + var body: some View { + HStack { + if canGoBack, let onBack { + Button("Back") { + onBack() + } + .controlSize(.large) + } + + Spacer(minLength: 0) + + Button(continueTitle) { + onContinue() + } + .buttonStyle(.borderedProminent) + .tint(OnboardingTheme.accent) + .controlSize(.large) + .disabled(!canContinue) + .help(canContinue ? "" : (disabledHint ?? "")) + } + } +} diff --git a/Cotabby/UI/PermissionReminderView.swift b/Cotabby/UI/Onboarding/PermissionReminderView.swift similarity index 53% rename from Cotabby/UI/PermissionReminderView.swift rename to Cotabby/UI/Onboarding/PermissionReminderView.swift index f09218ce..e6add187 100644 --- a/Cotabby/UI/PermissionReminderView.swift +++ b/Cotabby/UI/Onboarding/PermissionReminderView.swift @@ -5,8 +5,9 @@ import SwiftUI /// permissions are missing. This happens after a permission-prompted restart or if the user /// revokes a permission later in System Settings. /// -/// Reuses the same PermissionCard-style layout as onboarding but with contextual copy and a -/// simple dismiss button instead of the full wizard navigation. +/// Shares onboarding's design system (`OnboardingStyle`) so the two windows read as one product, +/// but with urgency semantics layered on: a required-and-missing permission shows an orange tile +/// and an orange Allow button, while granted rows relax back to their identity tint. struct PermissionReminderView: View { @ObservedObject var permissionManager: PermissionManager @@ -14,55 +15,59 @@ struct PermissionReminderView: View { let onDismiss: () -> Void var body: some View { - VStack(spacing: 28) { - VStack(spacing: 8) { - Image(systemName: "exclamationmark.shield.fill") - .font(.system(size: 36)) - .foregroundStyle(.orange) - - Text("Permissions needed") - .font(.system(size: 24, weight: .semibold, design: .rounded)) - - Text("Cotabby needs these permissions to work.\nGrant them in System Settings, then come back here.") - .font(.system(size: 14, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } + VStack(spacing: 26) { + OnboardingStepHeader( + systemImage: "exclamationmark.shield.fill", + tint: .orange, + title: "Permissions needed", + subtitle: "Cotabby needs these permissions to work.\nGrant them in System Settings, then come back here." + ) + .onboardingReveal(0) VStack(spacing: 10) { - ForEach(CotabbyPermissionKind.allCases.filter(\.isRequiredForAutocomplete)) { permission in + ForEach( + Array(CotabbyPermissionKind.allCases.filter(\.isRequiredForAutocomplete).enumerated()), + id: \.element + ) { index, permission in ReminderPermissionCard( permission: permission, granted: permissionManager.isGranted(permission), permissionGuidanceController: permissionGuidanceController ) + .onboardingReveal(1 + index) } - // Optional enhancements (Screen Recording) render after the required cards, tagged so - // they read as a discoverable extra rather than a blocker. The "I'll do this later" / - // "Done" button is gated on required permissions only, so these never hold it up. - ForEach(CotabbyPermissionKind.allCases.filter(\.isOptionalEnhancement)) { permission in + // Optional enhancements (Screen Recording) render after the required cards, tagged + // so they read as a discoverable extra rather than a blocker. The "I'll do this + // later" / "Done" button is gated on required permissions only, so these never hold + // it up. + ForEach( + Array(CotabbyPermissionKind.allCases.filter(\.isOptionalEnhancement).enumerated()), + id: \.element + ) { index, permission in ReminderPermissionCard( permission: permission, granted: permissionManager.isGranted(permission), isOptional: true, permissionGuidanceController: permissionGuidanceController ) + .onboardingReveal(3 + index) } } WelcomeButton(title: permissionManager.requiredPermissionsGranted ? "Done" : "I'll do this later") { onDismiss() } + .onboardingReveal(4) } .padding(36) .frame(width: 540) - .background(.ultraThinMaterial) + .background(OnboardingBackdrop()) } } -/// Permission card for the reminder view. Same glass-material style as onboarding but shows -/// "Granted" for already-granted permissions so the user sees their progress. +/// Permission card for the reminder view. Same card chrome as onboarding, but a missing required +/// permission goes orange so the row reads as "broken, fix me" rather than a neutral setup task. private struct ReminderPermissionCard: View { let permission: CotabbyPermissionKind let granted: Bool @@ -71,28 +76,23 @@ private struct ReminderPermissionCard: View { @State private var actionButtonFrame = CGRect.zero - /// Tint for the ungranted state. Optional permissions stay neutral so they never look like the - /// broken/required state the orange treatment signals in this "Permissions needed" window. - private var ungrantedTint: Color { - isOptional ? .secondary : .orange + /// Tile tint: orange flags the broken/required state; optional and granted rows keep the same + /// identity tints used during onboarding so the surfaces stay recognizably one system. + private var tileTint: Color { + if granted || isOptional { + return permission.onboardingTint + } + return .orange } var body: some View { HStack(spacing: 14) { - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(granted ? Color.green.opacity(0.12) : ungrantedTint.opacity(0.12)) - - Image(systemName: permission.systemImageName) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(granted ? .green : ungrantedTint) - } - .frame(width: 32, height: 32) + OnboardingIconTile(systemImage: permission.systemImageName, tint: tileTint) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(permission.title) - .font(.system(size: 14, weight: .medium)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) if isOptional { Text("Optional") @@ -112,17 +112,12 @@ private struct ReminderPermissionCard: View { Spacer(minLength: 0) if granted { - HStack(spacing: 4) { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .semibold)) - Text("Done") - .font(.system(size: 12, weight: .medium)) - } - .foregroundStyle(.green) + PermissionDoneBadge() + .transition(.scale(scale: 0.6).combined(with: .opacity)) } else if isOptional { - // Same "Allow" verb as the required rows (never a "feature toggle" like Enable), but a - // lower-emphasis bordered button so the optional row never competes visually with the - // required Allow buttons above it in this "Permissions needed" modal. + // Same "Allow" verb as the required rows (never a "feature toggle" like Enable), + // but a lower-emphasis bordered button so the optional row never competes visually + // with the required Allow buttons above it in this "Permissions needed" modal. Button("Allow") { permissionGuidanceController.requestAccess( for: permission, @@ -140,20 +135,14 @@ private struct ReminderPermissionCard: View { ) } .buttonStyle(.borderedProminent) + .tint(.orange) .controlSize(.regular) .background(ScreenFrameReader(frameInScreen: $actionButtonFrame)) } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.regularMaterial) - .shadow(color: .black.opacity(0.06), radius: 2, y: 1) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(.white.opacity(0.08), lineWidth: 0.5) - ) + .onboardingCard() + .animation(.spring(response: 0.35, dampingFraction: 0.7), value: granted) } } diff --git a/Cotabby/UI/Onboarding/WelcomeHeroDemo.swift b/Cotabby/UI/Onboarding/WelcomeHeroDemo.swift new file mode 100644 index 00000000..373251d8 --- /dev/null +++ b/Cotabby/UI/Onboarding/WelcomeHeroDemo.swift @@ -0,0 +1,211 @@ +import SwiftUI + +/// File overview: +/// The welcome step's hero: a self-playing demo of Cotabby's core loop (type, ghost text appears, +/// Tab accepts) staged inside a miniature mock app window. It exists so a brand-new user sees what +/// the product does in the first five seconds, before the flow asks them for permissions. +/// +/// Like `OnboardingFeatureShowcase`, the demo is inert content: hardcoded strings and local +/// `@State`, no Accessibility, no event tap, nothing from the real suggestion pipeline. The loop is +/// driven from a `.task`, which SwiftUI cancels when the view leaves the hierarchy, and Reduce +/// Motion rests on a completed frame instead of looping. The card keeps a fixed height so the +/// welcome window never resizes mid-animation. +struct WelcomeHeroDemo: View { + @Environment(\.colorScheme) private var colorScheme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + /// Index into `examples` of the phrase currently playing; advances each loop for breadth. + @State private var exampleIndex = 0 + /// Number of base characters revealed so far (the "typed" portion). + @State private var typedCount = 0 + /// Whether the gray ghost continuation and its keycap are visible. + @State private var showGhost = false + /// Whether the ghost has been "accepted" and turned solid. + @State private var accepted = false + /// Insertion-point blink, driven by its own short-period task while the demo is on screen. + @State private var caretVisible = true + + private struct Example { + let base: String + let ghost: String + } + + /// Rotating phrases so the loop conveys "any writing, anywhere" rather than one canned email. + private let examples = [ + Example(base: "Thanks for the", ghost: " quick reply!"), + Example(base: "Let's meet", ghost: " tomorrow at 10."), + Example(base: "Here's the", ghost: " final draft.") + ] + + private var example: Example { + examples[exampleIndex % examples.count] + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + windowControlDots + mockTextField + } + .padding(16) + .onboardingCard(cornerRadius: 14) + .task(id: reduceMotion) { + await runLoop() + } + .task(id: reduceMotion) { + await runCaretBlink() + } + // Purely decorative: hide from VoiceOver so mid-animation fragments are never read out. + .accessibilityHidden(true) + } + + /// Three traffic-light dots that frame the demo as "someone else's app window", which is the + /// fastest way to say "this works everywhere" without a caption. + private var windowControlDots: some View { + HStack(spacing: 6) { + Circle().fill(Color(red: 1.0, green: 0.37, blue: 0.34)) + Circle().fill(Color(red: 1.0, green: 0.74, blue: 0.18)) + Circle().fill(Color(red: 0.16, green: 0.78, blue: 0.25)) + } + .frame(height: 7) + .opacity(0.85) + } + + private var mockTextField: some View { + HStack(alignment: .center, spacing: 6) { + HStack(alignment: .center, spacing: 0) { + Text(String(example.base.prefix(typedCount))) + .foregroundStyle(.primary) + + // The insertion point sits between the typed text and the ghost, exactly where the + // real caret stays while a suggestion is displayed. + Capsule() + .fill(OnboardingTheme.accent) + .frame(width: 2, height: 18) + .opacity(caretVisible ? 1 : 0) + .padding(.horizontal, 1) + + if showGhost { + Text(example.ghost) + .foregroundStyle(accepted ? Color.primary : ghostColor) + } + } + .font(.system(size: 16)) + .lineLimit(1) + + if showGhost && !accepted { + HeroTabKeycap() + .transition(.opacity.combined(with: .scale(scale: 0.9))) + } + + Spacer(minLength: 0) + } + .padding(.horizontal, 12) + .frame(height: 44) + .background( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .fill(Color(nsColor: .textBackgroundColor)) + ) + // A focused-field ring: hairline accent border plus a soft outer halo, matching how macOS + // marks the focused text field. Sells "Cotabby is live in this field right now." + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(OnboardingTheme.accent.opacity(0.65), lineWidth: 1) + ) + .overlay( + RoundedRectangle(cornerRadius: 9, style: .continuous) + .inset(by: -2.5) + .strokeBorder(OnboardingTheme.accent.opacity(0.22), lineWidth: 3) + ) + } + + /// The default inline ghost-text color, matching `GhostSuggestionView`'s fallback (replicated + /// the same way `OnboardingFeatureShowcase` does, since the original is private). + private var ghostColor: Color { + colorScheme == .dark ? Color(white: 0.65) : Color(white: 0.45) + } + + private func runLoop() async { + guard !reduceMotion else { + typedCount = example.base.count + showGhost = true + accepted = true + caretVisible = true + return + } + + while !Task.isCancelled { + typedCount = 0 + showGhost = false + accepted = false + try? await Task.sleep(nanoseconds: 450 * nsPerMillisecond) + if Task.isCancelled { return } + + for index in 1...example.base.count { + typedCount = index + try? await Task.sleep(nanoseconds: 52 * nsPerMillisecond) + if Task.isCancelled { return } + } + + try? await Task.sleep(nanoseconds: 280 * nsPerMillisecond) + if Task.isCancelled { return } + + withAnimation(.easeInOut(duration: 0.18)) { showGhost = true } + try? await Task.sleep(nanoseconds: 1500 * nsPerMillisecond) + if Task.isCancelled { return } + + withAnimation(.easeInOut(duration: 0.22)) { accepted = true } + try? await Task.sleep(nanoseconds: 1100 * nsPerMillisecond) + if Task.isCancelled { return } + + withAnimation(.easeInOut(duration: 0.3)) { + showGhost = false + accepted = false + typedCount = 0 + } + exampleIndex += 1 + try? await Task.sleep(nanoseconds: 500 * nsPerMillisecond) + } + } + + /// Standard insertion-point blink. Separate from the typing loop so the cadence stays steady + /// across phases; the task dies with the view, and Reduce Motion holds the caret solid. + private func runCaretBlink() async { + guard !reduceMotion else { + caretVisible = true + return + } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 520 * nsPerMillisecond) + if Task.isCancelled { return } + withAnimation(.easeInOut(duration: 0.12)) { + caretVisible.toggle() + } + } + } +} + +/// One millisecond in nanoseconds, so the demo timings above read in milliseconds. +private let nsPerMillisecond: UInt64 = 1_000_000 + +/// Replica of the inline overlay's "Tab" keycap (the original in `OverlayController` is private), +/// color-matched to the ghost text exactly like `OnboardingFeatureShowcase`'s copy. +private struct HeroTabKeycap: View { + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Text("Tab") + .font(.system(size: 10, weight: .medium, design: .rounded)) + .foregroundStyle(colorScheme == .dark ? Color(white: 0.65) : Color(white: 0.45)) + .padding(.horizontal, 5) + .padding(.vertical, 2) + .background( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(colorScheme == .dark ? Color(white: 0.18) : Color(white: 0.95)) + ) + .overlay( + RoundedRectangle(cornerRadius: 6, style: .continuous) + .stroke(colorScheme == .dark ? Color(white: 0.3) : Color(white: 0.8), lineWidth: 1) + ) + .fixedSize() + } +} diff --git a/Cotabby/UI/Onboarding/WelcomeKeybindStepView.swift b/Cotabby/UI/Onboarding/WelcomeKeybindStepView.swift new file mode 100644 index 00000000..6bd37816 --- /dev/null +++ b/Cotabby/UI/Onboarding/WelcomeKeybindStepView.swift @@ -0,0 +1,252 @@ +import SwiftUI + +/// File overview: +/// The onboarding "learn your keys" step: a hero keycap that demonstrates the accept gesture, plus +/// one row per rebindable shortcut (accept word, accept entire suggestion, toggle Cotabby). Rows +/// reuse the real `KeyRecorderView` and write through `SuggestionSettingsModel`, so a binding +/// recorded here is exactly the one Settings shows later. +/// +/// Only one row can record at a time (`recordingAction` is a single optional rather than per-row +/// flags), which prevents two recorders from competing for the same keystroke. +struct WelcomeKeybindStepView: View { + @ObservedObject var suggestionSettings: SuggestionSettingsModel + + @State private var recordingAction: ShortcutAction? + + var body: some View { + VStack(spacing: 24) { + OnboardingKeycapHero(label: suggestionSettings.acceptanceKeyLabel) + .onboardingReveal(0) + + VStack(spacing: 8) { + Text("Learn your keys") + .font(.system(size: 26, weight: .bold, design: .rounded)) + + Text("Accept suggestions without leaving the keyboard.\nYou can change these anytime in Settings.") + .font(.system(size: 14, design: .rounded)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .onboardingReveal(1) + + VStack(spacing: 10) { + keybindRow( + title: "Accept word", + keyLabel: suggestionSettings.acceptanceKeyLabel, + action: .acceptWord, + onKeyRecorded: { keyCode, modifiers, label in + suggestionSettings.setAcceptanceKey( + keyCode: keyCode, + modifiers: modifiers, + label: label + ) + }, + onReset: ( + suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.defaultAcceptanceKeyCode + || !suggestionSettings.acceptanceKeyModifiers.isEmpty + ) ? { + suggestionSettings.setAcceptanceKey( + keyCode: SuggestionSettingsModel.defaultAcceptanceKeyCode, + modifiers: [], + label: SuggestionSettingsModel.defaultAcceptanceKeyLabel + ) + } : nil, + onClear: suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode + ? { suggestionSettings.clearAcceptanceKey() } : nil + ) + .onboardingReveal(2) + + keybindRow( + title: "Accept entire suggestion", + keyLabel: suggestionSettings.fullAcceptanceKeyLabel, + action: .acceptEntireSuggestion, + onKeyRecorded: { keyCode, modifiers, label in + suggestionSettings.setFullAcceptanceKey( + keyCode: keyCode, + modifiers: modifiers, + label: label + ) + }, + onReset: ( + suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.defaultFullAcceptanceKeyCode + || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty + ) ? { + suggestionSettings.setFullAcceptanceKey( + keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, + modifiers: [], + label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel + ) + } : nil, + onClear: suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode + ? { suggestionSettings.clearFullAcceptanceKey() } : nil + ) + .onboardingReveal(3) + + // No `onReset` here: the toggle hotkey is opt-in and has no factory default, so the + // only meaningful "reset" is unbind, which the Clear button already covers. + keybindRow( + title: "Toggle Cotabby", + keyLabel: suggestionSettings.globalToggleKeyLabel, + action: .toggleTabby, + onKeyRecorded: { keyCode, modifiers, label in + suggestionSettings.setGlobalToggleKey( + keyCode: keyCode, + modifiers: modifiers, + label: label + ) + }, + onReset: nil, + onClear: suggestionSettings.globalToggleKeyCode != SuggestionSettingsModel.disabledKeyCode + ? { suggestionSettings.clearGlobalToggleKey() } : nil + ) + .onboardingReveal(4) + } + .frame(maxWidth: 480) + } + } + + @ViewBuilder + private func keybindRow( + title: String, + keyLabel: String, + action: ShortcutAction, + onKeyRecorded: @escaping (CGKeyCode, ShortcutModifierMask, String) -> Void, + onReset: (() -> Void)? = nil, + onClear: (() -> Void)? = nil + ) -> some View { + let isRecording = recordingAction == action + HStack(spacing: 10) { + Text(title) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .frame(maxWidth: .infinity, alignment: .leading) + + OnboardingKeycap(label: keyLabel) + + if isRecording { + KeyRecorderView( + onKeyRecorded: { keyCode, modifiers, label in + onKeyRecorded(keyCode, modifiers, label) + recordingAction = nil + }, + onCancelled: { + recordingAction = nil + }, + conflictChecker: { keyCode, modifiers in + suggestionSettings.conflictingShortcutName( + keyCode: keyCode, + modifiers: modifiers, + excluding: action + ) + } + ) + } else { + Button("Change") { + recordingAction = action + } + .controlSize(.small) + } + + if let onReset { + Button("Reset") { + onReset() + recordingAction = nil + } + .controlSize(.small) + } + + // Mirror the Settings "Shortcuts" pane, which offers Clear here too: unbinding a + // shortcut mid-setup shouldn't force the user to finish onboarding and then dig through + // Settings to undo it. Call sites pass nil while the key is already unbound, so the + // button only appears when there is a binding to clear. + if let onClear { + Button("Clear") { + onClear() + recordingAction = nil + } + .controlSize(.small) + } + } + .padding(.horizontal, 14) + .padding(.vertical, 11) + .onboardingCard(cornerRadius: 12) + } +} + +// MARK: - Keycaps + +/// A binding rendered as a physical keycap: top-lit gradient, hairline edge, and a hard one-point +/// ledge shadow that gives the key its height. Pure chrome; the recording flow never enters here. +struct OnboardingKeycap: View { + let label: String + var fontSize: CGFloat = 13 + var minWidth: CGFloat = 44 + + @Environment(\.colorScheme) private var colorScheme + + var body: some View { + Text(label) + .font(.system(size: fontSize, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + .padding(.horizontal, 12) + .padding(.vertical, 7) + .frame(minWidth: minWidth) + .background( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .fill( + LinearGradient( + colors: colorScheme == .dark + ? [Color(white: 0.26), Color(white: 0.19)] + : [Color.white, Color(white: 0.92)], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow( + color: .black.opacity(colorScheme == .dark ? 0.55 : 0.22), + radius: 0.5, + y: 1.5 + ) + ) + .overlay( + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(Color.primary.opacity(0.12), lineWidth: 0.5) + ) + .fixedSize() + } +} + +/// The step's hero: the user's current accept key as a large keycap that presses itself every few +/// seconds. The press is a two-frame dip (translate down, ledge shadow collapses) driven by a +/// `.task` loop; Reduce Motion holds the key at rest. +private struct OnboardingKeycapHero: View { + let label: String + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + @State private var pressed = false + + var body: some View { + OnboardingKeycap(label: label, fontSize: 17, minWidth: 84) + .scaleEffect(pressed ? 0.96 : 1.0) + .offset(y: pressed ? 2 : 0) + .shadow( + color: OnboardingTheme.accent.opacity(pressed ? 0.1 : 0.25), + radius: pressed ? 6 : 14, + y: pressed ? 2 : 6 + ) + .task(id: reduceMotion) { + guard !reduceMotion else { + pressed = false + return + } + while !Task.isCancelled { + try? await Task.sleep(nanoseconds: 2_300_000_000) + if Task.isCancelled { return } + withAnimation(.easeIn(duration: 0.1)) { pressed = true } + try? await Task.sleep(nanoseconds: 160_000_000) + if Task.isCancelled { return } + withAnimation(.spring(response: 0.28, dampingFraction: 0.55)) { pressed = false } + } + } + .accessibilityHidden(true) + } +} diff --git a/Cotabby/UI/WelcomePermissionStepView.swift b/Cotabby/UI/Onboarding/WelcomePermissionStepView.swift similarity index 54% rename from Cotabby/UI/WelcomePermissionStepView.swift rename to Cotabby/UI/Onboarding/WelcomePermissionStepView.swift index 9fd64bb2..02baa0b3 100644 --- a/Cotabby/UI/WelcomePermissionStepView.swift +++ b/Cotabby/UI/Onboarding/WelcomePermissionStepView.swift @@ -1,18 +1,18 @@ import SwiftUI /// File overview: -/// Renders the onboarding permission step's content (header + permission cards). -/// -/// Each permission is a glass-material card with an icon badge, title, short description, and -/// an Allow button or Done state. The view stays subscribed to live permission state so cards -/// update in real time as the user grants access through System Settings. +/// Renders the onboarding permission step: a header, one card per permission, and a privacy +/// footnote. Each permission is a card with a tinted icon tile, title, short description, and an +/// Allow button that springs into a green Done state the moment macOS reports the grant. The view +/// stays subscribed to live permission state so cards update in real time as the user grants +/// access through System Settings. /// /// Navigation (Back/Continue) is owned by `WelcomeView`'s pinned footer rather than this view, so /// the Continue button can never scroll off-screen behind tall content. /// -/// The onboarding list is derived from `CotabbyPermissionKind` (required cards from -/// `isRequiredForAutocomplete`, then optional-enhancement cards from `isOptionalEnhancement`) so the -/// product's permission model and first-run UI cannot drift apart. +/// The list is derived from `CotabbyPermissionKind` (required cards from +/// `isRequiredForAutocomplete`, then optional-enhancement cards from `isOptionalEnhancement`) so +/// the product's permission model and first-run UI cannot drift apart. struct WelcomePermissionStepView: View { @ObservedObject var permissionManager: PermissionManager @@ -30,37 +30,49 @@ struct WelcomePermissionStepView: View { } var body: some View { - VStack(spacing: 28) { - VStack(spacing: 8) { - Text("Enable Cotabby") - .font(.system(size: 24, weight: .semibold, design: .rounded)) - - Text("Grant permissions so Cotabby can\nread text and accept completions.") - .font(.system(size: 14, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } + VStack(spacing: 24) { + OnboardingStepHeader( + systemImage: "lock.shield.fill", + title: "Two quick permissions", + subtitle: "Cotabby needs to read the field you're typing in and watch for the accept key.\n" + + "The optional one unlocks smarter, screen-aware suggestions." + ) + .onboardingReveal(0) VStack(spacing: 10) { - ForEach(requiredPermissions) { permission in + ForEach(Array(requiredPermissions.enumerated()), id: \.element) { index, permission in PermissionCard( permission: permission, granted: permissionManager.isGranted(permission), permissionGuidanceController: permissionGuidanceController ) + .onboardingReveal(1 + index) } // Optional cards render after the required ones, tagged so the user can skip them // without thinking they've left setup unfinished. The Continue gate ignores them. - ForEach(optionalPermissions) { permission in + ForEach(Array(optionalPermissions.enumerated()), id: \.element) { index, permission in PermissionCard( permission: permission, granted: permissionManager.isGranted(permission), isOptional: true, permissionGuidanceController: permissionGuidanceController ) + .onboardingReveal(1 + requiredPermissions.count + index) } } + + // The trust line carries onboarding's core privacy promise; it sits with the + // permission asks because this is the moment the user is deciding whether to trust us. + HStack(spacing: 6) { + Image(systemName: "lock.fill") + .font(.system(size: 10, weight: .medium)) + + Text("Everything Cotabby reads stays on your Mac. Nothing is ever uploaded.") + .font(.system(size: 12, design: .rounded)) + } + .foregroundStyle(.tertiary) + .onboardingReveal(2 + requiredPermissions.count + optionalPermissions.count) } .onDisappear { permissionGuidanceController.dismiss() @@ -68,9 +80,26 @@ struct WelcomePermissionStepView: View { } } +// MARK: - Permission tint + +extension CotabbyPermissionKind { + /// Per-permission tile tint, defined here in the UI layer so `PermissionModels` stays free of + /// SwiftUI. Distinct hues per row is the System Settings idiom and makes the step scannable. + var onboardingTint: Color { + switch self { + case .accessibility: + OnboardingTheme.accent + case .inputMonitoring: + .indigo + case .screenRecording: + .teal + } + } +} + // MARK: - Permission Card -/// One permission row rendered as a glass-material card. +/// One permission row rendered as a card with a tinted icon tile. /// /// The card measures its own button frame in screen coordinates because the permission guidance /// controller needs a global rect to anchor its drag-helper animation. That screen-space concern @@ -85,15 +114,12 @@ private struct PermissionCard: View { var body: some View { HStack(spacing: 14) { - PermissionIconBadge( - systemImage: permission.systemImageName, - granted: granted - ) + OnboardingIconTile(systemImage: permission.systemImageName, tint: permission.onboardingTint) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 6) { Text(permission.title) - .font(.system(size: 14, weight: .medium)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) if isOptional { Text("Optional") @@ -112,8 +138,11 @@ private struct PermissionCard: View { Spacer(minLength: 0) + // The grant springs in rather than swapping in place: macOS granting a permission is + // the step's payoff moment, and the bounce makes it land as one. if granted { PermissionDoneBadge() + .transition(.scale(scale: 0.6).combined(with: .opacity)) } else { Button("Allow") { permissionGuidanceController.requestAccess( @@ -122,53 +151,31 @@ private struct PermissionCard: View { ) } .buttonStyle(.borderedProminent) + .tint(OnboardingTheme.accent) .controlSize(.regular) .background(ScreenFrameReader(frameInScreen: $actionButtonFrame)) + .transition(.opacity) } } .padding(18) .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.regularMaterial) - .shadow(color: .black.opacity(0.06), radius: 2, y: 1) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(.white.opacity(0.08), lineWidth: 0.5) - ) + .onboardingCard() + .animation(.spring(response: 0.35, dampingFraction: 0.7), value: granted) } } // MARK: - Small Components -/// Tinted SF Symbol inside a rounded badge, similar to Apple's settings icon style. -private struct PermissionIconBadge: View { - let systemImage: String - let granted: Bool - - var body: some View { - ZStack { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(granted ? Color.green.opacity(0.12) : Color.accentColor.opacity(0.12)) - - Image(systemName: systemImage) - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(granted ? .green : .accentColor) - } - .frame(width: 32, height: 32) - } -} - -/// Green checkmark with "Done" label shown after a permission is granted. -private struct PermissionDoneBadge: View { +/// Green checkmark with "Done" label shown after a permission is granted. Shared with the +/// permission reminder window so the two surfaces stay visually in lock-step. +struct PermissionDoneBadge: View { var body: some View { - HStack(spacing: 4) { - Image(systemName: "checkmark") - .font(.system(size: 11, weight: .semibold)) + HStack(spacing: 5) { + Image(systemName: "checkmark.circle.fill") + .font(.system(size: 16, weight: .semibold)) Text("Done") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 12, weight: .semibold, design: .rounded)) } .foregroundStyle(.green) } diff --git a/Cotabby/UI/Onboarding/WelcomePersonalizeStepView.swift b/Cotabby/UI/Onboarding/WelcomePersonalizeStepView.swift new file mode 100644 index 00000000..f3491224 --- /dev/null +++ b/Cotabby/UI/Onboarding/WelcomePersonalizeStepView.swift @@ -0,0 +1,70 @@ +import SwiftUI + +/// File overview: +/// The onboarding personalization step. Merges what used to be two wizard screens (the "about you" +/// name-and-languages step and the gated "writing style" custom-rules step) into one, so the flow +/// stays four counted steps whether or not custom rules are user-facing +/// (`CustomRulesCatalog.isUserFacingEnabled`). +/// +/// The editors are the real settings components (`LanguageTagsEditor`, `CustomRulesEditor`) +/// writing through `SuggestionSettingsModel`; this view only supplies onboarding's card framing. +struct WelcomePersonalizeStepView: View { + @ObservedObject var suggestionSettings: SuggestionSettingsModel + + var body: some View { + VStack(spacing: 24) { + OnboardingStepHeader( + systemImage: "person.crop.circle.fill", + title: "Make it yours", + subtitle: "Cotabby writes in your languages and can address you by name." + ) + .onboardingReveal(0) + + VStack(spacing: 12) { + personalizeCard(icon: "textformat", title: "Your name", index: 1) { + TextField("What should Cotabby call you? (Optional)", text: Binding( + get: { suggestionSettings.userName }, + set: { suggestionSettings.setUserName($0) } + )) + .textFieldStyle(.roundedBorder) + .controlSize(.large) + } + + personalizeCard(icon: "globe", title: "Languages", index: 2) { + LanguageTagsEditor(suggestionSettings: suggestionSettings, showsTitleHeader: false) + } + + if CustomRulesCatalog.isUserFacingEnabled { + personalizeCard(icon: "character.cursor.ibeam", title: "Writing style", index: 3) { + CustomRulesEditor(suggestionSettings: suggestionSettings, showsTitleHeader: false) + } + } + } + } + } + + private func personalizeCard( + icon: String, + title: String, + index: Int, + @ViewBuilder content: () -> some View + ) -> some View { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 7) { + Image(systemName: icon) + .font(.system(size: 12, weight: .semibold)) + .foregroundStyle(OnboardingTheme.accent) + + Text(title) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.primary) + } + + content() + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .onboardingCard() + .onboardingReveal(index) + } +} diff --git a/Cotabby/UI/WelcomeTemplateStepView.swift b/Cotabby/UI/Onboarding/WelcomeTemplateStepView.swift similarity index 76% rename from Cotabby/UI/WelcomeTemplateStepView.swift rename to Cotabby/UI/Onboarding/WelcomeTemplateStepView.swift index 24c4eb7e..030f9660 100644 --- a/Cotabby/UI/WelcomeTemplateStepView.swift +++ b/Cotabby/UI/Onboarding/WelcomeTemplateStepView.swift @@ -3,12 +3,13 @@ import SwiftUI /// File overview: /// The onboarding step where the user picks one of three starting points (Quick / Everyday / /// Powerful). Selecting a card applies its settings and, for local-model templates, kicks off the -/// model download in the background so it can finish while the user completes the rest of onboarding. +/// model download in the background so it can finish while the user completes the rest of +/// onboarding. /// /// This view is intentionally render-only: it reports the chosen template upward via `onSelect` and -/// the parent (`WelcomeView`) owns applying settings and starting downloads. Per-card recommendation, -/// gating, and warning copy come from `OnboardingTemplateRecommender` so the product rules live in -/// one testable place. +/// the parent (`WelcomeView`) owns applying settings and starting downloads. Per-card +/// recommendation, gating, and warning copy come from `OnboardingTemplateRecommender` so the +/// product rules live in one testable place. struct WelcomeTemplateStepView: View { @ObservedObject var modelDownloadManager: ModelDownloadManager @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService @@ -21,20 +22,18 @@ struct WelcomeTemplateStepView: View { var body: some View { VStack(spacing: 24) { - VStack(spacing: 8) { - Text("Choose a starting point") - .font(.system(size: 24, weight: .semibold, design: .rounded)) - - Text("Pick one to get set up. You can fine-tune everything later in Settings.") - .font(.system(size: 14, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } + OnboardingStepHeader( + systemImage: "wand.and.stars", + title: "Choose a starting point", + subtitle: "Pick one to get set up. You can fine-tune everything later in Settings." + ) + .onboardingReveal(0) engineSelector + .onboardingReveal(1) VStack(spacing: 10) { - ForEach(OnboardingTemplate.curatedTiers) { template in + ForEach(Array(OnboardingTemplate.curatedTiers.enumerated()), id: \.element) { index, template in let availability = OnboardingTemplateRecommender.availability( for: template, hardware: hardware, @@ -53,6 +52,7 @@ struct WelcomeTemplateStepView: View { downloadState: downloadState(for: plan), onTap: { onSelect(template) } ) + .onboardingReveal(2 + index) } } } @@ -78,7 +78,7 @@ struct WelcomeTemplateStepView: View { EngineChoiceCard( title: SuggestionEngineKind.llamaOpenSource.displayLabel, subtitle: "Local models on your Mac", - systemImageName: "internaldrive", + systemImageName: "internaldrive.fill", isSelected: selectedEngine == .llamaOpenSource, isDisabled: false, onTap: { onSelectEngine(.llamaOpenSource) } @@ -96,6 +96,25 @@ struct WelcomeTemplateStepView: View { } } +// MARK: - Tier tint + +extension OnboardingTemplate { + /// Per-tier tile tint, defined in the UI layer so the model type stays free of SwiftUI. + /// Green / brand blue / purple gives the three cards distinct, scannable identities. + var onboardingTint: Color { + switch self { + case .quick: + .green + case .everyday: + OnboardingTheme.accent + case .powerful: + .purple + case .custom: + .gray + } + } +} + // MARK: - Template Card private struct TemplateCard: View { @@ -111,25 +130,42 @@ private struct TemplateCard: View { /// row list, and short cards preserve the "pick one" feel of this step. @State private var isFeatureListExpanded = false + private var isActive: Bool { + isSelected && !availability.isDisabled + } + var body: some View { VStack(spacing: 0) { selectionButton featureDisclosure } .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.regularMaterial) - .shadow(color: .black.opacity(0.06), radius: 2, y: 1) + ZStack { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.regularMaterial) + .shadow( + color: isActive ? OnboardingTheme.accent.opacity(0.18) : .black.opacity(0.07), + radius: isActive ? 7 : 3, + y: 1 + ) + + // A faint brand wash over the material marks the chosen card even at a glance from + // across the room; the stroke alone is too subtle once three cards are stacked. + if isActive { + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(OnboardingTheme.accent.opacity(0.06)) + } + } ) .overlay( RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke( - isSelected && !availability.isDisabled - ? Color.accentColor.opacity(0.45) : Color.white.opacity(0.08), - lineWidth: isSelected && !availability.isDisabled ? 1.5 : 0.5 + .strokeBorder( + isActive ? OnboardingTheme.accent.opacity(0.55) : Color.primary.opacity(0.07), + lineWidth: isActive ? 1.5 : 0.5 ) ) .opacity(availability.isDisabled ? 0.55 : 1.0) + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isActive) } /// Main card surface that selects the template. The feature disclosure is rendered as a sibling @@ -138,12 +174,16 @@ private struct TemplateCard: View { Button(action: onTap) { VStack(alignment: .leading, spacing: 10) { HStack(spacing: 14) { - iconBadge + OnboardingIconTile( + systemImage: template.systemImageName, + tint: availability.isDisabled ? .gray : template.onboardingTint, + size: 38 + ) VStack(alignment: .leading, spacing: 2) { HStack(spacing: 8) { Text(template.title) - .font(.system(size: 15, weight: .semibold)) + .font(.system(size: 15, weight: .semibold, design: .rounded)) .foregroundStyle(availability.isDisabled ? .tertiary : .primary) if availability.isRecommended { @@ -158,10 +198,11 @@ private struct TemplateCard: View { Spacer(minLength: 0) - if isSelected && !availability.isDisabled { + if isActive { Image(systemName: "checkmark.circle.fill") - .font(.system(size: 18)) - .foregroundColor(.accentColor) + .font(.system(size: 19)) + .foregroundStyle(OnboardingTheme.accent) + .transition(.scale(scale: 0.6).combined(with: .opacity)) } } @@ -267,7 +308,7 @@ private struct TemplateCard: View { case .enabled: Image(systemName: "checkmark") .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(OnboardingTheme.accent) case .disabled: Image(systemName: "minus") .font(.system(size: 10, weight: .semibold)) @@ -279,20 +320,6 @@ private struct TemplateCard: View { } } - private var iconBadge: some View { - ZStack { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(isSelected - ? AnyShapeStyle(Color.accentColor.opacity(0.12)) - : AnyShapeStyle(.quaternary.opacity(0.5))) - - Image(systemName: template.systemImageName) - .font(.system(size: 16, weight: .medium)) - .foregroundStyle(isSelected && !availability.isDisabled ? Color.accentColor : .secondary) - } - .frame(width: 38, height: 38) - } - private var engineSymbol: String { plan.engine == .appleIntelligence ? "apple.logo" : "internaldrive" } @@ -320,6 +347,7 @@ private struct TemplateCard: View { if let progress { ProgressView(value: progress) .progressViewStyle(.linear) + .tint(OnboardingTheme.accent) } else { // No fraction reported yet: fall back to the default (circular) spinner, since // macOS's linear style renders nothing for an indeterminate ProgressView. @@ -354,24 +382,28 @@ private struct EngineChoiceCard: View { let isDisabled: Bool let onTap: () -> Void + private var isActive: Bool { + isSelected && !isDisabled + } + var body: some View { Button(action: onTap) { VStack(alignment: .leading, spacing: 6) { HStack(spacing: 8) { Image(systemName: systemImageName) .font(.system(size: 14, weight: .medium)) - .foregroundStyle(iconStyle) + .foregroundStyle(isActive ? AnyShapeStyle(OnboardingTheme.accent) : AnyShapeStyle(.secondary)) Text(title) - .font(.system(size: 14, weight: .semibold)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) .foregroundStyle(isDisabled ? .tertiary : .primary) Spacer(minLength: 0) - if isSelected && !isDisabled { + if isActive { Image(systemName: "checkmark.circle.fill") .font(.system(size: 15)) - .foregroundColor(.accentColor) + .foregroundStyle(OnboardingTheme.accent) } } @@ -384,26 +416,28 @@ private struct EngineChoiceCard: View { .padding(12) .frame(maxWidth: .infinity, alignment: .leading) .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.regularMaterial) + ZStack { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.regularMaterial) + + if isActive { + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(OnboardingTheme.accent.opacity(0.07)) + } + } ) .overlay( RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke( - isSelected && !isDisabled - ? Color.accentColor.opacity(0.45) : Color.white.opacity(0.08), - lineWidth: isSelected && !isDisabled ? 1.5 : 0.5 + .strokeBorder( + isActive ? OnboardingTheme.accent.opacity(0.55) : Color.primary.opacity(0.07), + lineWidth: isActive ? 1.5 : 0.5 ) ) .opacity(isDisabled ? 0.55 : 1.0) } .buttonStyle(.plain) .disabled(isDisabled) - } - - private var iconStyle: HierarchicalShapeStyle { - // `.primary` when selected reads as active without introducing a second accent color. - isSelected && !isDisabled ? .primary : .secondary + .animation(.spring(response: 0.3, dampingFraction: 0.8), value: isActive) } } @@ -413,11 +447,17 @@ private struct RecommendedBadge: View { var body: some View { Text("Recommended") .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(Color.accentColor) + .foregroundStyle(.white) .padding(.horizontal, 7) .padding(.vertical, 2) .background( - Capsule().fill(Color.accentColor.opacity(0.12)) + Capsule().fill( + LinearGradient( + colors: [OnboardingTheme.accentSoft, OnboardingTheme.accent], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) ) } } diff --git a/Cotabby/UI/Onboarding/WelcomeView.swift b/Cotabby/UI/Onboarding/WelcomeView.swift new file mode 100644 index 00000000..a91aa9aa --- /dev/null +++ b/Cotabby/UI/Onboarding/WelcomeView.swift @@ -0,0 +1,623 @@ +import AppKit +import SwiftUI + +/// File overview: +/// Renders the first-run onboarding wizard as a guided flow: +/// welcome -> permissions -> choose starting point -> personalize -> keys -> done. +/// +/// Design intent: this is the first thing a new user sees, so it leads with the product (a live +/// ghost-text demo on the very first screen) before it asks for anything, and every step shares +/// one visual vocabulary from `OnboardingStyle` (brand-blue backdrop, tinted icon tiles, card +/// chrome, staggered reveals). Steps slide horizontally like Setup Assistant pages; the window +/// keeps one width and only morphs vertically (see `WelcomeStep.preferredWindowSize`). +/// +/// Two layout invariants this file protects: +/// 1. The Back/Continue footer is pinned outside the scrolling content, so a tall step can never +/// push its own Continue button off-screen (the failure that previously stranded users on the +/// profile step). +/// 2. Each middle step shows a progress indicator so the flow reads as finite and "where am I" +/// stays answerable. +/// +/// Picking a template applies a curated settings bundle and starts the recommended model download +/// in the background, so it can finish while the user fills out the remaining steps. +struct WelcomeView: View { + @ObservedObject var permissionManager: PermissionManager + @ObservedObject var runtimeModel: RuntimeBootstrapModel + @ObservedObject var modelDownloadManager: ModelDownloadManager + @ObservedObject var suggestionSettings: SuggestionSettingsModel + @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService + + let permissionGuidanceController: PermissionGuidanceController + let onPreferredWindowSizeChange: (NSSize) -> Void + let onDismiss: () -> Void + /// 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. 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 + + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var step: WelcomeStep + /// Whether the most recent navigation moved deeper into the flow. Read by `pageTransition` so + /// pages slide in from the side the user is heading toward, and back out the way they came. + @State private var navigatesForward = true + @State private var selectedTemplate: OnboardingTemplate? + /// The engine chosen at the top of the template step. Seeded in `init` from Apple Intelligence + /// availability (Apple Intelligence when the Mac supports it, otherwise Open Source) so the + /// template step's first render already shows the right card instead of flashing the wrong one; + /// the tier cards resolve their plan against this. + @State private var selectedEngine: SuggestionEngineKind + + /// Probed once for the view's lifetime: installed memory and architecture don't change during + /// onboarding. `@State` (not a stored `let`) ensures `ProcessInfo` is read a single time rather + /// than on every struct re-creation that an `@ObservedObject` publish (e.g. a download tick) + /// causes. + @State private var hardware = HardwareCapabilityProbe.current() + + init( + permissionManager: PermissionManager, + runtimeModel: RuntimeBootstrapModel, + modelDownloadManager: ModelDownloadManager, + suggestionSettings: SuggestionSettingsModel, + foundationModelAvailabilityService: FoundationModelAvailabilityService, + permissionGuidanceController: PermissionGuidanceController, + onPreferredWindowSizeChange: @escaping (NSSize) -> Void, + onDismiss: @escaping () -> Void, + initialStepIndex: Int = 0, + isReturningUser: Bool = false, + onStepChange: @escaping (Int) -> Void = { _ in } + ) { + self.isReturningUser = isReturningUser + _permissionManager = ObservedObject(wrappedValue: permissionManager) + _runtimeModel = ObservedObject(wrappedValue: runtimeModel) + _modelDownloadManager = ObservedObject(wrappedValue: modelDownloadManager) + _suggestionSettings = ObservedObject(wrappedValue: suggestionSettings) + _foundationModelAvailabilityService = ObservedObject(wrappedValue: foundationModelAvailabilityService) + self.permissionGuidanceController = permissionGuidanceController + self.onPreferredWindowSizeChange = onPreferredWindowSizeChange + self.onDismiss = onDismiss + self.onStepChange = onStepChange + // Resume at the furthest step the user previously reached. An out-of-range or absent value + // (0) falls back to `.welcome`, so brand-new users still start at the beginning. + _step = State(initialValue: WelcomeStep(rawValue: initialStepIndex) ?? .welcome) + // Seed the engine before the first render so the template step never shows a frame of "Open + // Source" selected on an Apple Intelligence-capable Mac and then snaps to it. Availability + // is resolved well before onboarding appears, so reading it here is reliable. + _selectedEngine = State( + initialValue: foundationModelAvailabilityService.isAvailable ? .appleIntelligence : .llamaOpenSource + ) + } + + private var preferredWindowSize: NSSize { + step.preferredWindowSize + } + + var body: some View { + VStack(spacing: 0) { + if let progressIndex = step.progressIndex { + OnboardingProgressPips(current: progressIndex, total: WelcomeStep.totalProgressSteps) + .padding(.top, 26) + .transition(.opacity) + } + + ZStack { + page + .id(step) + .transition(pageTransition) + } + } + .frame(width: WelcomeStep.windowWidth) + .background(OnboardingBackdrop()) + .onAppear { + onPreferredWindowSizeChange(preferredWindowSize) + // Stamp the resume point for the step we open on. Matters when resuming directly onto + // a later step: without this, quitting again before advancing would not re-persist it. + onStepChange(step.rawValue) + } + .onChange(of: step) { _, newStep in + onStepChange(newStep.rawValue) + } + .onChange(of: preferredWindowSize) { _, newValue in + onPreferredWindowSizeChange(newValue) + } + // When the selected template's model finishes downloading, re-scan disk so the runtime + // can discover and load it. + .onChange(of: selectedModelDownloadState) { _, newState in + guard newState == .downloaded else { + return + } + modelDownloadManager.refreshModelStates() + runtimeModel.refreshAvailableModels() + } + // Once the chosen model appears in the available list, make it the active runtime model. + // Doing this reactively (rather than right after kicking the download) avoids racing the + // asynchronous disk re-scan. + .onChange(of: runtimeModel.availableModels) { _, models in + activateChosenModelIfAvailable(in: models) + } + } +} + +// MARK: - Navigation + +extension WelcomeView { + /// All step changes flow through here so the slide direction and the spring are decided in one + /// place. Reduce Motion swaps pages with no animation at all (the transition never plays). + fileprivate func go(to newStep: WelcomeStep) { + navigatesForward = newStep > step + guard !reduceMotion else { + step = newStep + return + } + withAnimation(.spring(response: 0.45, dampingFraction: 0.88)) { + step = newStep + } + } + + /// Pages push in from the direction of travel, Setup Assistant style. `navigatesForward` is + /// set before the animated `step` write, so both the inserted and removed page agree on it. + fileprivate var pageTransition: AnyTransition { + .asymmetric( + insertion: .move(edge: navigatesForward ? .trailing : .leading).combined(with: .opacity), + removal: .move(edge: navigatesForward ? .leading : .trailing).combined(with: .opacity) + ) + } +} + +// MARK: - Pages + +extension WelcomeView { + @ViewBuilder + fileprivate var page: some View { + switch step { + case .welcome: + welcomePage + case .done: + donePage + case .permissions, .template, .personalize, .keybind: + // Scaffold for middle steps: scrolling content above a pinned footer. The footer stays + // put while the content scrolls, which is the core fix for "I can't find Continue." + VStack(spacing: 0) { + ScrollView { + stepContent + .padding(.horizontal, OnboardingTheme.horizontalPadding) + .padding(.top, 18) + .padding(.bottom, 16) + .frame(maxWidth: .infinity) + } + + stepFooter + .padding(.horizontal, OnboardingTheme.horizontalPadding) + .padding(.top, 8) + .padding(.bottom, 26) + } + } + } + + @ViewBuilder + fileprivate var stepContent: some View { + switch step { + case .permissions: + WelcomePermissionStepView( + permissionManager: permissionManager, + permissionGuidanceController: permissionGuidanceController + ) + case .template: + WelcomeTemplateStepView( + modelDownloadManager: modelDownloadManager, + foundationModelAvailabilityService: foundationModelAvailabilityService, + hardware: hardware, + selectedEngine: selectedEngine, + selectedTemplate: $selectedTemplate, + onSelectEngine: selectEngine, + onSelect: applyTemplate + ) + case .personalize: + WelcomePersonalizeStepView(suggestionSettings: suggestionSettings) + case .keybind: + WelcomeKeybindStepView(suggestionSettings: suggestionSettings) + case .welcome, .done: + EmptyView() + } + } + + @ViewBuilder + fileprivate var stepFooter: some View { + switch step { + case .permissions: + WelcomeNavigation( + canGoBack: true, + canContinue: permissionManager.requiredPermissionsGranted, + disabledHint: "Grant all permissions to continue.", + onBack: { go(to: .welcome) }, + onContinue: { go(to: .template) } + ) + case .template: + 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: { go(to: .permissions) }, + onContinue: { + if selectedTemplate == nil { + applyTemplate(.custom) + } + go(to: .personalize) + } + ) + case .personalize: + WelcomeNavigation( + canGoBack: true, + canContinue: !suggestionSettings.responseLanguages.isEmpty, + disabledHint: "Add at least one language so Cotabby knows what to write in.", + onBack: { go(to: .template) }, + onContinue: { go(to: .keybind) } + ) + case .keybind: + WelcomeNavigation( + canGoBack: true, + canContinue: true, + onBack: { go(to: .personalize) }, + onContinue: { go(to: .done) } + ) + case .welcome, .done: + EmptyView() + } + } +} + +// MARK: - Step 1: Welcome + +extension WelcomeView { + fileprivate var welcomePage: some View { + VStack(spacing: 0) { + Spacer(minLength: 0) + + VStack(spacing: 24) { + Image("CotabbyLogo") + .resizable() + .scaledToFit() + .frame(width: 84, height: 84) + .clipShape(RoundedRectangle(cornerRadius: 19, style: .continuous)) + .shadow(color: OnboardingTheme.accent.opacity(0.45), radius: 22, y: 8) + .onboardingReveal(0) + + VStack(spacing: 8) { + Text("Welcome to Cotabby") + .font(.system(size: 30, weight: .bold, design: .rounded)) + + Text("Ghost-text autocomplete in every app,\ngenerated entirely on your Mac.") + .font(.system(size: 15, design: .rounded)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .onboardingReveal(1) + + WelcomeHeroDemo() + .frame(maxWidth: 440) + .onboardingReveal(2) + + HStack(spacing: 8) { + WelcomeFeatureChip(systemImage: "lock.fill", label: "100% on-device") + WelcomeFeatureChip(systemImage: "chevron.left.forwardslash.chevron.right", label: "Open source") + WelcomeFeatureChip(systemImage: "macwindow", label: "Works everywhere") + } + .onboardingReveal(3) + + WelcomeButton(title: "Get Started") { + go(to: .permissions) + } + .padding(.top, 4) + .onboardingReveal(4) + } + + Spacer(minLength: 0) + } + .padding(36) + } +} + +/// Small capsule highlighting one of Cotabby's differentiators on the welcome screen. +private struct WelcomeFeatureChip: View { + let systemImage: String + let label: String + + var body: some View { + HStack(spacing: 5) { + Image(systemName: systemImage) + .font(.system(size: 10, weight: .semibold)) + .foregroundStyle(OnboardingTheme.accent) + + Text(label) + .font(.system(size: 11, weight: .medium, design: .rounded)) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 10) + .padding(.vertical, 5) + .background(Capsule().fill(.quaternary.opacity(0.5))) + .overlay(Capsule().strokeBorder(Color.primary.opacity(0.06), lineWidth: 0.5)) + } +} + +// MARK: - Step: Done + +extension WelcomeView { + fileprivate var donePage: some View { + VStack(spacing: 0) { + ScrollView { + VStack(spacing: 24) { + doneHero + .onboardingReveal(0) + + VStack(spacing: 8) { + Text("You're all set") + .font(.system(size: 28, weight: .bold, design: .rounded)) + + Text(doneStepSubtitle) + .font(.system(size: 15, design: .rounded)) + .foregroundStyle(.secondary) + .multilineTextAlignment(.center) + } + .onboardingReveal(1) + + menuBarCallout + .onboardingReveal(2) + + OnboardingFeatureShowcase() + .onboardingReveal(3) + } + .padding(.horizontal, OnboardingTheme.horizontalPadding) + .padding(.top, 40) + .padding(.bottom, 16) + } + + VStack(spacing: 12) { + doneStepModelStatus + + WelcomeButton(title: "Start Using Cotabby") { + onDismiss() + } + } + .padding(.horizontal, OnboardingTheme.horizontalPadding) + .padding(.top, 8) + .padding(.bottom, 26) + .onboardingReveal(4) + } + } + + /// The completion mark: a lit-from-above green seal, sized to land as the step's reward. + private var doneHero: some View { + ZStack { + Circle() + .fill( + LinearGradient( + colors: [Color.green.opacity(0.8), Color.green], + startPoint: .top, + endPoint: .bottom + ) + ) + .shadow(color: .green.opacity(0.4), radius: 18, y: 6) + + Image(systemName: "checkmark") + .font(.system(size: 30, weight: .bold)) + .foregroundStyle(.white) + } + .frame(width: 72, height: 72) + } + + /// Menu bar discovery is the single most important thing to leave the user with: people who + /// can't find the app after the window closes assume it isn't running. So it gets a real card, + /// not a footnote. + private var menuBarCallout: some View { + HStack(spacing: 14) { + ZStack { + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.quaternary.opacity(0.55)) + + Image("MenuBarCatIcon") + .resizable() + .scaledToFit() + .frame(height: 18) + .foregroundStyle(.primary) + } + .frame(width: 44, height: 30) + + VStack(alignment: .leading, spacing: 2) { + Text("Cotabby lives in your menu bar") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + + Text("Click the cat to pause suggestions, switch models, or open Settings.") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + + Spacer(minLength: 0) + } + .padding(14) + .onboardingCard(cornerRadius: 12) + } + + private var doneStepSubtitle: String { + let wordKey = suggestionSettings.acceptanceKeyLabel + let fullKey = suggestionSettings.fullAcceptanceKeyLabel + let hasFullAccept = suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode + + if hasFullAccept { + return "Start typing anywhere.\nPress \(wordKey) to accept a word, \(fullKey) for the full suggestion." + } + return "Start typing anywhere.\nPress \(wordKey) to accept." + } + + /// A compact reassurance line on the final step when a local model is still downloading or has + /// finished. Hidden for Apple Intelligence plans, which download nothing. + @ViewBuilder + private var doneStepModelStatus: some View { + switch selectedModelDownloadState { + case .downloading(let progress): + HStack(spacing: 6) { + ProgressView() + .controlSize(.small) + if let progress { + Text("Downloading your model… \(Int((progress * 100).rounded()))%") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } else { + Text("Downloading your model…") + .font(.system(size: 12)) + .foregroundStyle(.secondary) + } + } + case .downloaded: + Label("Your model is ready", systemImage: "checkmark.circle.fill") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.green) + case .failed, .idle, .none: + EmptyView() + } + } +} + +// MARK: - Template application + +extension WelcomeView { + /// The download state of the currently selected template's model, or `nil` for Apple + /// Intelligence templates (and before any template is chosen). + fileprivate var selectedModelDownloadState: ModelDownloadState? { + guard let template = selectedTemplate else { + return nil + } + let plan = resolvedPlan(for: template) + guard let model = plan.modelToDownload else { + return nil + } + 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 true + } + let plan = resolvedPlan(for: template) + switch plan.engine { + case .appleIntelligence: + return true + case .llamaOpenSource: + // Allow continuing once the download is at least underway; it finishes in the background. + guard let state = selectedModelDownloadState else { + return false + } + return state == .downloaded || state.isDownloading + } + } + + /// 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 { + "Hang on while your model starts downloading." + } + + fileprivate func resolvedPlan(for template: OnboardingTemplate) -> ResolvedTemplatePlan { + let base = OnboardingTemplateRecommender.resolvePlan(for: template, engine: selectedEngine) + // 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, + selectedEngine == .llamaOpenSource, + let currentFilename = runtimeModel.selectedModelFilename, + currentFilename != base.modelToDownload?.filename, + let currentModel = RuntimeModelCatalog.downloadableModels.first(where: { $0.filename == currentFilename }) + else { + return base + } + return ResolvedTemplatePlan( + template: base.template, + engine: base.engine, + modelToDownload: currentModel, + wordCountPreset: base.wordCountPreset, + enablesFastMode: base.enablesFastMode, + enablesMultiLine: base.enablesMultiLine, + enablesClipboardContext: base.enablesClipboardContext + ) + } + + /// Switches the engine. Re-applies the already-selected tier under the new engine so the + /// persisted settings and any download stay consistent; switching to Open Source after a tier + /// is chosen starts that tier's download (the tap is the user's consent), while Apple + /// Intelligence needs none. No download is started until a tier has been chosen. + fileprivate func selectEngine(_ engine: SuggestionEngineKind) { + guard selectedEngine != engine else { + return + } + selectedEngine = engine + if let template = selectedTemplate { + applyTemplate(template) + } + } + + /// 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 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 { + return + } + + let plan = resolvedPlan(for: template) + suggestionSettings.selectEngine(plan.engine) + suggestionSettings.selectWordCountPreset(plan.wordCountPreset) + suggestionSettings.setFastModeEnabled(plan.enablesFastMode) + suggestionSettings.setMultiLineEnabled(plan.enablesMultiLine) + suggestionSettings.setClipboardContextEnabled(plan.enablesClipboardContext) + + guard let model = plan.modelToDownload else { + return + } + + if modelDownloadManager.isModelInstalled(filename: model.filename) { + // Already on disk (e.g. re-running onboarding): make sure the runtime can see and load it. + modelDownloadManager.refreshModelStates() + runtimeModel.refreshAvailableModels() + } else { + modelDownloadManager.download(model) + } + } + + /// Selects the chosen template's model as the active runtime model once it shows up in the + /// available list. No-ops for Apple Intelligence plans and when it is already selected. + fileprivate func activateChosenModelIfAvailable(in models: [RuntimeModelOption]) { + guard let template = selectedTemplate else { + return + } + guard let filename = resolvedPlan(for: template).modelToDownload?.filename else { + return + } + guard models.contains(where: { $0.filename == filename }) else { + return + } + guard runtimeModel.selectedModelFilename != filename else { + return + } + + Task { + await runtimeModel.selectModel(filename) + } + } +} diff --git a/Cotabby/UI/WelcomeView.swift b/Cotabby/UI/WelcomeView.swift deleted file mode 100644 index 81f12ca5..00000000 --- a/Cotabby/UI/WelcomeView.swift +++ /dev/null @@ -1,894 +0,0 @@ -import AppKit -import SwiftUI - -/// File overview: -/// Renders the first-run onboarding wizard as a guided flow: -/// welcome -> permissions -> choose template -> about you -> writing style -> keybinds -> done. -/// -/// Two layout invariants this file protects: -/// 1. The Back/Continue footer is pinned outside the scrolling content, so a tall step can never -/// push its own Continue button off-screen (the failure that previously stranded users on the -/// profile step). -/// 2. Each middle step shows a progress indicator so the flow reads as finite and "where am I" -/// stays answerable. -/// -/// Picking a template applies a curated settings bundle and starts the recommended model download in -/// the background, so it can finish while the user fills out the remaining steps. -struct WelcomeView: View { - @ObservedObject var permissionManager: PermissionManager - @ObservedObject var runtimeModel: RuntimeBootstrapModel - @ObservedObject var modelDownloadManager: ModelDownloadManager - @ObservedObject var suggestionSettings: SuggestionSettingsModel - @ObservedObject var foundationModelAvailabilityService: FoundationModelAvailabilityService - - let permissionGuidanceController: PermissionGuidanceController - let onPreferredWindowSizeChange: (NSSize) -> Void - let onDismiss: () -> Void - /// 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. 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 - @State private var selectedTemplate: OnboardingTemplate? - /// The engine chosen at the top of the template step. Seeded in `init` from Apple Intelligence - /// availability (Apple Intelligence when the Mac supports it, otherwise Open Source) so the - /// template step's first render already shows the right card instead of flashing the wrong one; - /// the tier cards resolve their plan against this. - @State private var selectedEngine: SuggestionEngineKind - @State private var isRecordingOnboardingKeybind = false - @State private var isRecordingOnboardingFullAcceptKeybind = false - @State private var isRecordingOnboardingGlobalToggleKeybind = false - - /// Probed once for the view's lifetime: installed memory and architecture don't change during - /// onboarding. `@State` (not a stored `let`) ensures `ProcessInfo` is read a single time rather - /// than on every struct re-creation that an `@ObservedObject` publish (e.g. a download tick) causes. - @State private var hardware = HardwareCapabilityProbe.current() - - init( - permissionManager: PermissionManager, - runtimeModel: RuntimeBootstrapModel, - modelDownloadManager: ModelDownloadManager, - suggestionSettings: SuggestionSettingsModel, - foundationModelAvailabilityService: FoundationModelAvailabilityService, - permissionGuidanceController: PermissionGuidanceController, - onPreferredWindowSizeChange: @escaping (NSSize) -> Void, - onDismiss: @escaping () -> Void, - initialStepIndex: Int = 0, - isReturningUser: Bool = false, - onStepChange: @escaping (Int) -> Void = { _ in } - ) { - self.isReturningUser = isReturningUser - _permissionManager = ObservedObject(wrappedValue: permissionManager) - _runtimeModel = ObservedObject(wrappedValue: runtimeModel) - _modelDownloadManager = ObservedObject(wrappedValue: modelDownloadManager) - _suggestionSettings = ObservedObject(wrappedValue: suggestionSettings) - _foundationModelAvailabilityService = ObservedObject(wrappedValue: foundationModelAvailabilityService) - self.permissionGuidanceController = permissionGuidanceController - self.onPreferredWindowSizeChange = onPreferredWindowSizeChange - self.onDismiss = onDismiss - self.onStepChange = onStepChange - // Resume at the furthest step the user previously reached. An out-of-range or absent value - // (0) falls back to `.welcome`, so brand-new users still start at the beginning. - _step = State(initialValue: WelcomeStep(rawValue: initialStepIndex) ?? .welcome) - // Seed the engine before the first render so the template step never shows a frame of "Open - // Source" selected on an Apple Intelligence-capable Mac and then snaps to it. Availability is - // resolved well before onboarding appears, so reading it here is reliable. - _selectedEngine = State( - initialValue: foundationModelAvailabilityService.isAvailable ? .appleIntelligence : .llamaOpenSource - ) - } - - private var preferredWindowSize: NSSize { - step.preferredWindowSize - } - - var body: some View { - content - .frame(width: preferredWindowSize.width) - .background(.ultraThinMaterial) - .animation(.easeInOut(duration: 0.25), value: preferredWindowSize) - .onAppear { - onPreferredWindowSizeChange(preferredWindowSize) - // Stamp the resume point for the step we open on. Matters when resuming directly onto - // a later step: without this, quitting again before advancing would not re-persist it. - onStepChange(step.rawValue) - } - .onChange(of: step) { _, newStep in - onStepChange(newStep.rawValue) - } - .onChange(of: preferredWindowSize) { _, newValue in - onPreferredWindowSizeChange(newValue) - } - // When the selected template's model finishes downloading, re-scan disk so the runtime - // can discover and load it. - .onChange(of: selectedModelDownloadState) { _, newState in - guard newState == .downloaded else { - return - } - modelDownloadManager.refreshModelStates() - runtimeModel.refreshAvailableModels() - } - // Once the chosen model appears in the available list, make it the active runtime model. - // Doing this reactively (rather than right after kicking the download) avoids racing the - // asynchronous disk re-scan. - .onChange(of: runtimeModel.availableModels) { _, models in - activateChosenModelIfAvailable(in: models) - } - } - - @ViewBuilder - private var content: some View { - switch step { - case .welcome: - terminalLayout { welcomeStep } - case .done: - terminalLayout { doneStep } - default: - scrollLayout - } - } -} - -// MARK: - Layout scaffolds - -extension WelcomeView { - /// Compact, centered layout for the intro and outro steps, which are short and never scroll. - fileprivate func terminalLayout(@ViewBuilder _ content: () -> Content) -> some View { - VStack(spacing: 0) { - Spacer(minLength: 0) - content() - Spacer(minLength: 0) - } - .padding(36) - } - - /// Scaffold for middle steps: a progress header, scrolling content, and a pinned footer. The - /// footer stays put while the content scrolls, which is the core fix for "I can't find Continue." - fileprivate var scrollLayout: some View { - VStack(spacing: 0) { - if let progressIndex = step.progressIndex { - WelcomeStepProgress(current: progressIndex, total: WelcomeStep.totalProgressSteps) - .padding(.horizontal, 36) - .padding(.top, 28) - .padding(.bottom, 6) - } - - ScrollView { - stepContent - .padding(.horizontal, 36) - .padding(.top, step.progressIndex == nil ? 36 : 16) - .padding(.bottom, 16) - .frame(maxWidth: .infinity) - } - - stepFooter - .padding(.horizontal, 36) - .padding(.top, 8) - .padding(.bottom, 28) - } - } - - @ViewBuilder - fileprivate var stepContent: some View { - switch step { - case .permissions: - WelcomePermissionStepView( - permissionManager: permissionManager, - permissionGuidanceController: permissionGuidanceController - ) - case .template: - WelcomeTemplateStepView( - modelDownloadManager: modelDownloadManager, - foundationModelAvailabilityService: foundationModelAvailabilityService, - hardware: hardware, - selectedEngine: selectedEngine, - selectedTemplate: $selectedTemplate, - onSelectEngine: selectEngine, - onSelect: applyTemplate - ) - case .aboutYou: - aboutYouStep - case .writingStyle: - writingStyleStep - case .keybind: - keybindStep - case .welcome, .done: - EmptyView() - } - } - - @ViewBuilder - fileprivate var stepFooter: some View { - switch step { - case .permissions: - WelcomeNavigation( - canGoBack: true, - canContinue: permissionManager.requiredPermissionsGranted, - disabledHint: "Grant all permissions to continue.", - onBack: { step = .welcome }, - onContinue: { step = .template } - ) - case .template: - 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: { - if selectedTemplate == nil { - applyTemplate(.custom) - } - step = .aboutYou - } - ) - case .aboutYou: - WelcomeNavigation( - canGoBack: true, - canContinue: !suggestionSettings.responseLanguages.isEmpty, - disabledHint: "Add at least one language so Cotabby knows what to write in.", - onBack: { step = .template }, - // Skip the writing-style (custom rules) step when that feature is gated off. - onContinue: { step = CustomRulesCatalog.isUserFacingEnabled ? .writingStyle : .keybind } - ) - case .writingStyle: - WelcomeNavigation( - canGoBack: true, - canContinue: true, - onBack: { step = .aboutYou }, - onContinue: { step = .keybind } - ) - case .keybind: - WelcomeNavigation( - canGoBack: true, - canContinue: true, - onBack: { step = CustomRulesCatalog.isUserFacingEnabled ? .writingStyle : .aboutYou }, - onContinue: { step = .done } - ) - case .welcome, .done: - EmptyView() - } - } -} - -// MARK: - Steps - -private enum WelcomeStep: Int, Comparable { - case welcome - case permissions - case template - case aboutYou - case writingStyle - case keybind - case done - - static func < (lhs: WelcomeStep, rhs: WelcomeStep) -> Bool { - lhs.rawValue < rhs.rawValue - } - - /// Number of steps shown in the progress indicator (the middle, non-terminal steps). Drops to 4 - /// when the custom-rules writing-style step is gated off (CustomRulesCatalog.isUserFacingEnabled). - static var totalProgressSteps: Int { - CustomRulesCatalog.isUserFacingEnabled ? 5 : 4 - } - - /// 1-based position within the progress indicator, or `nil` for the intro/outro steps that - /// intentionally sit outside the counted flow. - var progressIndex: Int? { - switch self { - case .welcome, .done: - return nil - case .permissions: - return 1 - case .template: - return 2 - case .aboutYou: - return 3 - case .writingStyle: - return 4 - case .keybind: - // The 4th step when the writing-style step is gated off, otherwise the 5th. - return CustomRulesCatalog.isUserFacingEnabled ? 5 : 4 - } - } - - /// Product-chosen window sizes. The coordinator clamps the height to the visible screen, and the - /// scrolling content absorbs any overflow, so these are targets rather than hard guarantees. - var preferredWindowSize: NSSize { - switch self { - case .welcome: - return NSSize(width: 500, height: 360) - case .permissions: - return NSSize(width: 540, height: 540) - case .template: - return NSSize(width: 560, height: 640) - case .aboutYou: - return NSSize(width: 560, height: 560) - case .writingStyle: - return NSSize(width: 560, height: 560) - case .keybind: - return NSSize(width: 640, height: 460) - case .done: - return NSSize(width: 520, height: 672) - } - } -} - -// MARK: - Step 1: Welcome - -extension WelcomeView { - fileprivate var welcomeStep: some View { - VStack(spacing: 24) { - Image("CotabbyLogo") - .resizable() - .scaledToFit() - .frame(width: 72, height: 72) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - - VStack(spacing: 8) { - Text("Welcome to Cotabby") - .font(.system(size: 28, weight: .semibold, design: .rounded)) - - Text("AI autocomplete in any text field, all done locally.") - .font(.system(size: 15, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - WelcomeButton(title: "Get Started") { - step = .permissions - } - .padding(.top, 4) - } - } -} - -// MARK: - Step: About You (name + languages) - -extension WelcomeView { - fileprivate var aboutYouStep: some View { - VStack(spacing: 24) { - VStack(spacing: 8) { - Text("Tell Cotabby about yourself") - .font(.system(size: 24, weight: .semibold, design: .rounded)) - - Text("Pick at least one language so Cotabby knows what to write in. Your name is optional.") - .font(.system(size: 14, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 6) { - Text("Name") - .font(.system(size: 13, weight: .medium)) - - TextField("What should Cotabby call you?", text: Binding( - get: { suggestionSettings.userName }, - set: { suggestionSettings.setUserName($0) } - )) - .textFieldStyle(.roundedBorder) - .controlSize(.large) - } - - LanguageTagsEditor(suggestionSettings: suggestionSettings) - } - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - } -} - -// MARK: - Step: Writing Style (custom rules) - -extension WelcomeView { - fileprivate var writingStyleStep: some View { - VStack(spacing: 24) { - VStack(spacing: 8) { - Text("Your writing style") - .font(.system(size: 24, weight: .semibold, design: .rounded)) - - Text("Add rules to shape tone and style. Skip it and add them later if you'd rather.") - .font(.system(size: 14, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - CustomRulesEditor(suggestionSettings: suggestionSettings) - .padding(.horizontal, 8) - .padding(.vertical, 4) - } - } -} - -// MARK: - Step: Keybind - -extension WelcomeView { - fileprivate var keybindStep: some View { - VStack(spacing: 24) { - Image(systemName: "keyboard") - .resizable() - .scaledToFit() - .frame(width: 56, height: 56) - .foregroundStyle(.secondary) - - VStack(spacing: 8) { - Text("Keybinds") - .font(.system(size: 24, weight: .semibold, design: .rounded)) - - Text("You can change these later in Settings.") - .font(.system(size: 14, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - // A single vertical column of full-width rows. The previous 2x2 grid placed three - // recorder controls side by side and overflowed the window at narrow widths; a vertical - // list matches the other onboarding steps and scrolls cleanly. - VStack(spacing: 10) { - VStack(spacing: 10) { - keybindRow( - title: "Accept Word", - keyLabel: suggestionSettings.acceptanceKeyLabel, - action: .acceptWord, - isRecording: $isRecordingOnboardingKeybind, - onKeyRecorded: { keyCode, modifiers, label in - suggestionSettings.setAcceptanceKey( - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - }, - onReset: ( - suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.defaultAcceptanceKeyCode - || !suggestionSettings.acceptanceKeyModifiers.isEmpty - ) ? { - suggestionSettings.setAcceptanceKey( - keyCode: SuggestionSettingsModel.defaultAcceptanceKeyCode, - modifiers: [], - label: SuggestionSettingsModel.defaultAcceptanceKeyLabel - ) - } : nil, - onClear: suggestionSettings.acceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode - ? { suggestionSettings.clearAcceptanceKey() } : nil - ) - - keybindRow( - title: "Accept Entire Suggestion", - keyLabel: suggestionSettings.fullAcceptanceKeyLabel, - action: .acceptEntireSuggestion, - isRecording: $isRecordingOnboardingFullAcceptKeybind, - onKeyRecorded: { keyCode, modifiers, label in - suggestionSettings.setFullAcceptanceKey( - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - }, - onReset: ( - suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.defaultFullAcceptanceKeyCode - || !suggestionSettings.fullAcceptanceKeyModifiers.isEmpty - ) ? { - suggestionSettings.setFullAcceptanceKey( - keyCode: SuggestionSettingsModel.defaultFullAcceptanceKeyCode, - modifiers: [], - label: SuggestionSettingsModel.defaultFullAcceptanceKeyLabel - ) - } : nil, - onClear: suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode - ? { suggestionSettings.clearFullAcceptanceKey() } : nil - ) - } - - // No `onReset` here: the toggle hotkey is opt-in and has no factory default, so the - // only meaningful "reset" is unbind, which the Clear button already covers. - keybindRow( - title: "Toggle Cotabby", - keyLabel: suggestionSettings.globalToggleKeyLabel, - action: .toggleTabby, - isRecording: $isRecordingOnboardingGlobalToggleKeybind, - onKeyRecorded: { keyCode, modifiers, label in - suggestionSettings.setGlobalToggleKey( - keyCode: keyCode, - modifiers: modifiers, - label: label - ) - }, - onReset: nil, - onClear: suggestionSettings.globalToggleKeyCode != SuggestionSettingsModel.disabledKeyCode - ? { suggestionSettings.clearGlobalToggleKey() } : nil - ) - } - .frame(maxWidth: 440) - } - } - - @ViewBuilder - fileprivate func keybindRow( - title: String, - keyLabel: String, - action: ShortcutAction, - isRecording: Binding, - onKeyRecorded: @escaping (CGKeyCode, ShortcutModifierMask, String) -> Void, - onReset: (() -> Void)? = nil, - onClear: (() -> Void)? = nil - ) -> some View { - HStack(spacing: 10) { - Text(title) - .font(.system(size: 14, weight: .medium, design: .rounded)) - .frame(maxWidth: .infinity, alignment: .leading) - - Text(keyLabel) - .font(.system(size: 16, weight: .medium, design: .rounded)) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(.quaternary) - ) - - if isRecording.wrappedValue { - KeyRecorderView( - onKeyRecorded: { keyCode, modifiers, label in - onKeyRecorded(keyCode, modifiers, label) - isRecording.wrappedValue = false - }, - onCancelled: { - isRecording.wrappedValue = false - }, - conflictChecker: { keyCode, modifiers in - suggestionSettings.conflictingShortcutName( - keyCode: keyCode, - modifiers: modifiers, - excluding: action - ) - } - ) - } else { - Button("Change") { - isRecording.wrappedValue = true - } - .controlSize(.small) - } - - if let onReset { - Button("Reset") { - onReset() - isRecording.wrappedValue = false - } - .controlSize(.small) - } - - // Mirror the Settings "Shortcuts" pane, which offers Clear here too: unbinding a shortcut - // mid-setup shouldn't force the user to finish onboarding and then dig through Settings to - // undo it. Call sites pass nil while the key is already unbound, so the button only appears - // when there is a binding to clear (the same gate the settings pane applies). - if let onClear { - Button("Clear") { - onClear() - isRecording.wrappedValue = false - } - .controlSize(.small) - } - } - // Full-width rows in a subtle card so each binding reads as its own tappable row, consistent - // with the labeled fields on the other onboarding steps. - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(.quaternary.opacity(0.35)) - ) - } -} - -// MARK: - Step: Done - -extension WelcomeView { - fileprivate var doneStep: some View { - VStack(spacing: 24) { - ZStack { - Circle() - .fill(.green.opacity(0.12)) - .shadow(color: .green.opacity(0.08), radius: 8, y: 2) - - Image(systemName: "checkmark") - .font(.system(size: 26, weight: .semibold)) - .foregroundStyle(.green) - } - .frame(width: 64, height: 64) - - VStack(spacing: 8) { - Text("You're all set") - .font(.system(size: 28, weight: .semibold, design: .rounded)) - - Text(doneStepSubtitle) - .font(.system(size: 15, design: .rounded)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - - OnboardingFeatureShowcase() - - doneStepModelStatus - - HStack(spacing: 6) { - Image(systemName: "menubar.arrow.up.rectangle") - .foregroundStyle(.tertiary) - - Text("Find Cotabby in your menu bar.") - .font(.system(size: 13, weight: .medium, design: .rounded)) - .foregroundStyle(.tertiary) - } - - WelcomeButton(title: "Start Using Cotabby") { - onDismiss() - } - .padding(.top, 4) - } - } - - private var doneStepSubtitle: String { - let wordKey = suggestionSettings.acceptanceKeyLabel - let fullKey = suggestionSettings.fullAcceptanceKeyLabel - let hasFullAccept = suggestionSettings.fullAcceptanceKeyCode != SuggestionSettingsModel.disabledKeyCode - - if hasFullAccept { - return "Start typing anywhere.\nPress \(wordKey) to accept a word, \(fullKey) for the full suggestion." - } - return "Start typing anywhere.\nPress \(wordKey) to accept." - } - - /// A compact reassurance line on the final step when a local model is still downloading or has - /// finished. Hidden for Apple Intelligence plans, which download nothing. - @ViewBuilder - private var doneStepModelStatus: some View { - switch selectedModelDownloadState { - case .downloading(let progress): - HStack(spacing: 6) { - ProgressView() - .controlSize(.small) - if let progress { - Text("Downloading your model… \(Int((progress * 100).rounded()))%") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } else { - Text("Downloading your model…") - .font(.system(size: 12)) - .foregroundStyle(.secondary) - } - } - case .downloaded: - Label("Your model is ready", systemImage: "checkmark.circle.fill") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.green) - case .failed, .idle, .none: - EmptyView() - } - } -} - -// MARK: - Template application - -extension WelcomeView { - /// The download state of the currently selected template's model, or `nil` for Apple Intelligence - /// templates (and before any template is chosen). - fileprivate var selectedModelDownloadState: ModelDownloadState? { - guard let template = selectedTemplate else { - return nil - } - let plan = resolvedPlan(for: template) - guard let model = plan.modelToDownload else { - return nil - } - 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 true - } - let plan = resolvedPlan(for: template) - switch plan.engine { - case .appleIntelligence: - return true - case .llamaOpenSource: - // Allow continuing once the download is at least underway; it finishes in the background. - guard let state = selectedModelDownloadState else { - return false - } - return state == .downloaded || state.isDownloading - } - } - - /// 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 { - "Hang on while your model starts downloading." - } - - fileprivate func resolvedPlan(for template: OnboardingTemplate) -> ResolvedTemplatePlan { - let base = OnboardingTemplateRecommender.resolvePlan(for: template, engine: selectedEngine) - // 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, - selectedEngine == .llamaOpenSource, - let currentFilename = runtimeModel.selectedModelFilename, - currentFilename != base.modelToDownload?.filename, - let currentModel = RuntimeModelCatalog.downloadableModels.first(where: { $0.filename == currentFilename }) - else { - return base - } - return ResolvedTemplatePlan( - template: base.template, - engine: base.engine, - modelToDownload: currentModel, - wordCountPreset: base.wordCountPreset, - enablesFastMode: base.enablesFastMode, - enablesMultiLine: base.enablesMultiLine, - enablesClipboardContext: base.enablesClipboardContext - ) - } - - /// Switches the engine. Re-applies the already-selected tier under the new engine so the - /// persisted settings and any download stay consistent; switching to Open Source after a tier - /// is chosen starts that tier's download (the tap is the user's consent), while Apple - /// Intelligence needs none. No download is started until a tier has been chosen. - fileprivate func selectEngine(_ engine: SuggestionEngineKind) { - guard selectedEngine != engine else { - return - } - selectedEngine = engine - if let template = selectedTemplate { - applyTemplate(template) - } - } - - /// 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 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 { - return - } - - let plan = resolvedPlan(for: template) - suggestionSettings.selectEngine(plan.engine) - suggestionSettings.selectWordCountPreset(plan.wordCountPreset) - suggestionSettings.setFastModeEnabled(plan.enablesFastMode) - suggestionSettings.setMultiLineEnabled(plan.enablesMultiLine) - suggestionSettings.setClipboardContextEnabled(plan.enablesClipboardContext) - - guard let model = plan.modelToDownload else { - return - } - - if modelDownloadManager.isModelInstalled(filename: model.filename) { - // Already on disk (e.g. re-running onboarding): make sure the runtime can see and load it. - modelDownloadManager.refreshModelStates() - runtimeModel.refreshAvailableModels() - } else { - modelDownloadManager.download(model) - } - } - - /// Selects the chosen template's model as the active runtime model once it shows up in the - /// available list. No-ops for Apple Intelligence plans and when it is already selected. - fileprivate func activateChosenModelIfAvailable(in models: [RuntimeModelOption]) { - guard let template = selectedTemplate else { - return - } - guard let filename = resolvedPlan(for: template).modelToDownload?.filename else { - return - } - guard models.contains(where: { $0.filename == filename }) else { - return - } - guard runtimeModel.selectedModelFilename != filename else { - return - } - - Task { - await runtimeModel.selectModel(filename) - } - } -} - -// MARK: - Progress indicator - -/// A small "Step X of Y" row with filled capsule pips, shown on the middle steps so the flow reads -/// as finite and the user always knows how far along they are. -private struct WelcomeStepProgress: View { - let current: Int - let total: Int - - var body: some View { - VStack(spacing: 8) { - HStack(spacing: 6) { - ForEach(1...total, id: \.self) { index in - Capsule() - .fill(index <= current ? Color.accentColor : Color.secondary.opacity(0.25)) - .frame(width: index == current ? 22 : 16, height: 5) - .animation(.easeInOut(duration: 0.2), value: current) - } - } - - Text("Step \(current) of \(total)") - .font(.system(size: 11, weight: .medium, design: .rounded)) - .foregroundStyle(.tertiary) - } - .frame(maxWidth: .infinity) - .accessibilityElement(children: .ignore) - .accessibilityLabel("Step \(current) of \(total)") - } -} - -// MARK: - Shared Components - -/// Primary action button used on the welcome and done steps. -struct WelcomeButton: View { - let title: String - let action: () -> Void - - var body: some View { - Button(action: action) { - Text(title) - .font(.system(size: 14, weight: .medium)) - .frame(maxWidth: .infinity) - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - } -} - -/// 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 - - var body: some View { - HStack { - if canGoBack, let onBack { - Button("Back") { - onBack() - } - .controlSize(.large) - } - - Spacer(minLength: 0) - - Button(continueTitle) { - onContinue() - } - .buttonStyle(.borderedProminent) - .controlSize(.large) - .disabled(!canContinue) - .help(canContinue ? "" : (disabledHint ?? "")) - } - } -} diff --git a/CotabbyTests/OnboardingFlowStepTests.swift b/CotabbyTests/OnboardingFlowStepTests.swift new file mode 100644 index 00000000..ae3e1b9c --- /dev/null +++ b/CotabbyTests/OnboardingFlowStepTests.swift @@ -0,0 +1,71 @@ +import XCTest +@testable import Cotabby + +/// Tests for the pure onboarding flow model: step ordering, linear navigation, progress indices, +/// and window sizing. `WelcomeCoordinator` persists raw values as the wizard's resume point, so +/// several of these pin the numbering scheme itself; if one fails because steps were reordered or +/// inserted, the coordinator's progress key must move to a fresh name at the same time. +final class OnboardingFlowStepTests: XCTestCase { + func test_rawValues_pinThePersistedNumberingScheme() { + // These exact indices are what `cotabbyOnboardingProgressStep2` stores on disk. + XCTAssertEqual(WelcomeStep.welcome.rawValue, 0) + XCTAssertEqual(WelcomeStep.permissions.rawValue, 1) + XCTAssertEqual(WelcomeStep.template.rawValue, 2) + XCTAssertEqual(WelcomeStep.personalize.rawValue, 3) + XCTAssertEqual(WelcomeStep.keybind.rawValue, 4) + XCTAssertEqual(WelcomeStep.done.rawValue, 5) + XCTAssertEqual(WelcomeStep.allCases.count, 6) + } + + func test_comparable_followsCaseOrder() { + XCTAssertLessThan(WelcomeStep.welcome, .permissions) + XCTAssertLessThan(WelcomeStep.keybind, .done) + XCTAssertFalse(WelcomeStep.done < .welcome) + } + + func test_navigation_isLinearAndTerminalAtBothEnds() { + // Every step's next is the following case; every step's previous is the prior one. + for (index, step) in WelcomeStep.allCases.enumerated() { + if index + 1 < WelcomeStep.allCases.count { + XCTAssertEqual(step.next, WelcomeStep.allCases[index + 1]) + } + if index > 0 { + XCTAssertEqual(step.previous, WelcomeStep.allCases[index - 1]) + } + } + XCTAssertNil(WelcomeStep.welcome.previous) + XCTAssertNil(WelcomeStep.done.next) + } + + func test_progressIndices_coverOneThroughTotalExactlyOnce() { + let indices = WelcomeStep.allCases.compactMap(\.progressIndex) + + XCTAssertEqual(indices, Array(1...WelcomeStep.totalProgressSteps)) + } + + func test_terminalSteps_sitOutsideTheCountedFlow() { + XCTAssertNil(WelcomeStep.welcome.progressIndex) + XCTAssertNil(WelcomeStep.done.progressIndex) + } + + func test_windowWidth_isConstantAcrossEveryStep() { + // The redesign's "one calm surface" invariant: the window only ever morphs vertically. + for step in WelcomeStep.allCases { + XCTAssertEqual(step.preferredWindowSize.width, WelcomeStep.windowWidth) + } + } + + func test_windowHeights_areAlwaysPositive() { + for step in WelcomeStep.allCases { + XCTAssertGreaterThan(step.preferredWindowSize.height, 0) + } + } + + func test_resumeFallback_outOfRangeIndicesFailToInitialize() { + // `WelcomeView` falls back to `.welcome` when the persisted index doesn't resolve; this + // pins the init behavior that fallback relies on (stale or corrupt values return nil). + XCTAssertNil(WelcomeStep(rawValue: -1)) + XCTAssertNil(WelcomeStep(rawValue: WelcomeStep.allCases.count)) + XCTAssertEqual(WelcomeStep(rawValue: 0), .welcome) + } +} From f45ab1632183ff33006fa5aa601120390c2d8cd0 Mon Sep 17 00:00:00 2001 From: Jacob Fu <141651335+FuJacob@users.noreply.github.com> Date: Fri, 12 Jun 2026 08:02:51 -0700 Subject: [PATCH 2/2] fix(onboarding): derive reminder reveal indices from permission counts Greptile P2 on #701: the reminder window hardcoded the required-permission count in its stagger indices while WelcomePermissionStepView derives them; derive both surfaces the same way so adding a permission cannot desync the choreography. --- .../Onboarding/PermissionReminderView.swift | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/Cotabby/UI/Onboarding/PermissionReminderView.swift b/Cotabby/UI/Onboarding/PermissionReminderView.swift index e6add187..6eddd9f6 100644 --- a/Cotabby/UI/Onboarding/PermissionReminderView.swift +++ b/Cotabby/UI/Onboarding/PermissionReminderView.swift @@ -14,6 +14,17 @@ struct PermissionReminderView: View { let permissionGuidanceController: PermissionGuidanceController let onDismiss: () -> Void + /// Permissions that block core autocomplete; the reason this window exists. + private var requiredPermissions: [CotabbyPermissionKind] { + CotabbyPermissionKind.allCases.filter(\.isRequiredForAutocomplete) + } + + /// Optional enhancements (Screen Recording today), shown so they stay discoverable but never + /// gating the dismiss button. + private var optionalPermissions: [CotabbyPermissionKind] { + CotabbyPermissionKind.allCases.filter(\.isOptionalEnhancement) + } + var body: some View { VStack(spacing: 26) { OnboardingStepHeader( @@ -25,10 +36,7 @@ struct PermissionReminderView: View { .onboardingReveal(0) VStack(spacing: 10) { - ForEach( - Array(CotabbyPermissionKind.allCases.filter(\.isRequiredForAutocomplete).enumerated()), - id: \.element - ) { index, permission in + ForEach(Array(requiredPermissions.enumerated()), id: \.element) { index, permission in ReminderPermissionCard( permission: permission, granted: permissionManager.isGranted(permission), @@ -41,24 +49,21 @@ struct PermissionReminderView: View { // so they read as a discoverable extra rather than a blocker. The "I'll do this // later" / "Done" button is gated on required permissions only, so these never hold // it up. - ForEach( - Array(CotabbyPermissionKind.allCases.filter(\.isOptionalEnhancement).enumerated()), - id: \.element - ) { index, permission in + ForEach(Array(optionalPermissions.enumerated()), id: \.element) { index, permission in ReminderPermissionCard( permission: permission, granted: permissionManager.isGranted(permission), isOptional: true, permissionGuidanceController: permissionGuidanceController ) - .onboardingReveal(3 + index) + .onboardingReveal(1 + requiredPermissions.count + index) } } WelcomeButton(title: permissionManager.requiredPermissionsGranted ? "Done" : "I'll do this later") { onDismiss() } - .onboardingReveal(4) + .onboardingReveal(1 + requiredPermissions.count + optionalPermissions.count) } .padding(36) .frame(width: 540)