Showcase autocomplete + inline emoji on the final onboarding screen#459
Conversation
The "You're all set" step now demonstrates Cotabby's two flagship features with self-playing, hardcoded animations: inline autocomplete ghost text (type, gray ghost suggestion with a Tab hint, accept) and the inline :emoji: picker (type :smi, candidate popup, insert 😄). Both loop continuously and fall back to a static accepted state when Reduce Motion is on. The demos are purely decorative: a new OnboardingFeatureShowcase view built from local @State and hardcoded strings. It never touches the suggestion pipeline, settings, Accessibility, or the emoji catalog. Each card owns a .task-driven loop (auto-cancelled on disappear) and a fixed height so the onboarding window never resizes mid-animation. The .done window grows to 520x672 to fit the cards.
| struct OnboardingFeatureShowcase: View { | ||
| var body: some View { | ||
| VStack(spacing: 12) { | ||
| GhostTextDemoCard() | ||
| EmojiPickerDemoCard() | ||
| } | ||
| } | ||
| } |
There was a problem hiding this comment.
The showcase is described as "purely decorative" in both the PR description and the file doc-comment, but it has no accessibility opt-out. VoiceOver will traverse the two cards, reading out animating text fragments ("T", "Th", "Tha"…) and emoji candidate rows that have no actionable meaning for an assistive-technology user. Adding
.accessibilityHidden(true) to OnboardingFeatureShowcase suppresses the entire subtree from the accessibility tree in one place.
| struct OnboardingFeatureShowcase: View { | |
| var body: some View { | |
| VStack(spacing: 12) { | |
| GhostTextDemoCard() | |
| EmojiPickerDemoCard() | |
| } | |
| } | |
| } | |
| struct OnboardingFeatureShowcase: View { | |
| var body: some View { | |
| VStack(spacing: 12) { | |
| GhostTextDemoCard() | |
| EmojiPickerDemoCard() | |
| } | |
| .accessibilityHidden(true) | |
| } | |
| } |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
| withAnimation(.easeInOut(duration: 0.30)) { | ||
| showGhost = false | ||
| accepted = false | ||
| } | ||
| try? await Task.sleep(nanoseconds: 600 * nsPerMillisecond) |
There was a problem hiding this comment.
typedCount resets without animation between loops
At the top of each loop iteration typedCount is set to 0 instantly, so "Thanks for the" snaps to blank before the 350 ms pause. EmojiPickerDemoCard avoids this by resetting triggerCount = 0 inside its closing withAnimation(.easeInOut(duration: 0.30)) block. Adding typedCount = 0 to the GhostTextDemoCard equivalent block (lines 136–139) would make both cards fade out consistently.
| Divider() | ||
|
|
||
| VStack(spacing: 0) { | ||
| ForEach(Array(candidates.enumerated()), id: \.offset) { index, candidate in |
There was a problem hiding this comment.
Using
id: \.offset (array index) as the ForEach identity is a SwiftUI antipattern: if the array were ever reordered, SwiftUI would animate the wrong rows. Since candidates has a stable alias string per item, that makes a better stable identifier.
| ForEach(Array(candidates.enumerated()), id: \.offset) { index, candidate in | |
| ForEach(Array(candidates.enumerated()), id: \.element.alias) { index, candidate in |
Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!
Summary
The final onboarding step ("You're all set") was a static checkmark and reassurance copy that never showed what Cotabby actually does. It now demonstrates the two flagship features with self-playing animations a new user sees right before they click "Start Using Cotabby":
:smile:😄 highlighted /:smiley:😁) appears with a "Tab" keycap, then 😄 is inserted.Both loop continuously and respect the system Reduce Motion setting (falling back to a static accepted state). Layout is stacked under the existing headline; the
.donewindow grows from 500x380 to 520x672 to fit the two cards.Implementation notes
Cotabby/UI/Onboarding/OnboardingFeatureShowcase.swift: a self-contained, zero-argument view with two demo cards. It is purely decorative and built from local@State+ hardcoded strings only. It never touches the suggestion pipeline,SuggestionSettingsModel,EmojiPickerController,OverlayController, Accessibility, or the emoji catalog, so it cannot steal focus or affect real suggestions..task(id: reduceMotion)-driven loop using the repo'swhile !Task.isCancelled { ... try? await Task.sleep(...) }idiom (noTimer/TimelineView)..taskauto-cancels on disappear, so the loops can't leak.ZStackoverlay over reserved space, so the showcase block's height is constant across the animation. That keepspreferredWindowSizestable and prevents the window from pulsing taller/shorter each loop.OverlayController'sGhostKeycapandEmojiPickerView'sEmojiKeycap. Those originals areprivate, so they are replicated (not extracted) to avoid making a hot AppKit/suggestion-path fileinternalfor a decorative demo. Light/dark fidelity comes from the sameColor(white:)+colorSchemeand semantic-color mix as the originals.WelcomeView.swift: insertsOnboardingFeatureShowcase()intodoneStepand bumps the.donepreferredWindowSize.Validation
Visual pass not yet done. This is an animated UI change verified by build, strict lint, and the full test suite (no onboarding test asserts on
doneStepor the window size). I could not drive the wizard to the final step headlessly (it is gated behind thecotabbyOnboardingCompleteddefault and several click-through steps). Recommended manual check before merge: run onboarding to "You're all set" and confirm both demos animate and loop; toggle System Settings > Accessibility > Display > Reduce Motion (static accepted state, no loop); toggle Light/Dark; confirm the window does not pulse in height across loops.Linked issues
None.
Risk / rollout notes
@Environment(\.accessibilityReduceMotion)in the repo; handled per-card with a static fallback.Cotabby.xcodeprojwas regenerated with XcodeGen to pick up the new file (noproject.ymlchange).x: 84) and the per-cardcontentHeight/.donewindow height are the values most likely to want a nudge after the visual pass.Greptile Summary
This PR adds
OnboardingFeatureShowcase, a new decorative SwiftUI view placed on the final onboarding "You're all set" screen that self-plays looping animations demonstrating Cotabby's autocomplete ghost-text and inline emoji-picker features. The.donewindow height is bumped from 380 to 672 to accommodate the new cards.OnboardingFeatureShowcase.swift(343 lines): two independent demo cards (GhostTextDemoCard,EmojiPickerDemoCard), each using@State+ a.task-drivenwhile !Task.isCancelledloop;accessibilityReduceMotionis respected with a static fallback; popup and keycap widgets replicate their private originals without touching the real suggestion pipeline.WelcomeView.swift: insertsOnboardingFeatureShowcase()intodoneStepand adjustspreferredWindowSizefor.done.project.pbxproj: new file reference and build file entry added via XcodeGen.Confidence Score: 4/5
Safe to merge — change is confined to the final onboarding screen and is purely decorative with no connection to the suggestion pipeline or any shared state.
Both cards are well-structured: loops use the repo's established cancellation idiom, reduceMotion is respected, and the fixed contentHeight prevents window resizing during animation. The two findings are cosmetic: decorative content that skips accessibilityHidden(true) will surface meaningless animating text to VoiceOver users, and GhostTextDemoCard snaps its typed text to blank between loops rather than fading, inconsistent with the emoji card. Neither affects real suggestions, settings, or any other screen.
OnboardingFeatureShowcase.swift — accessibility opt-out and the loop-reset animation inconsistency between the two demo cards.
Important Files Changed
Sequence Diagram
sequenceDiagram participant SW as SwiftUI .task participant GC as GhostTextDemoCard participant EC as EmojiPickerDemoCard participant WV as WelcomeView (doneStep) WV->>GC: init (OnboardingFeatureShowcase inserted) WV->>EC: init GC->>SW: ".task(id: reduceMotion) -> runLoop()" EC->>SW: ".task(id: reduceMotion) -> runLoop() + 700ms offset" loop every ~4s (GhostText) SW->>GC: typedCount 0 to 14 (55ms/char) SW->>GC: "showGhost = true (fade in ghost + Tab keycap)" SW->>GC: "accepted = true (ghost turns solid)" SW->>GC: "showGhost/accepted = false (fade out)" end loop every ~4.4s (EmojiPicker) SW->>EC: triggerCount 0 to 4 (70ms/char) SW->>EC: "showPopup = true (popup + Tab keycap)" SW->>EC: "showPopup=false, committed=true (emoji inserted)" SW->>EC: "committed=false, triggerCount=0 (fade out)" end Note over GC,EC: reduceMotion=true -> static accepted state, no loop Note over GC,EC: view disappears -> .task auto-cancelledReviews (1): Last reviewed commit: "Showcase autocomplete + inline emoji on ..." | Re-trigger Greptile