From 996daed6e4d85c179326310c5b93bed72e8b2f3b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E9=9B=B7=E7=94=B5=E8=8A=BD=E8=A1=A3?= Date: Sun, 27 Sep 2026 14:20:45 -0400 Subject: [PATCH] =?UTF-8?q?Onboarding=20=C2=B7=20Flows=20and=20highlights:?= =?UTF-8?q?=20wizard,=20hotspot,=20highlight,=20setup=20checklist?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit OnboardingWizard runs a first run on navigation::Wizard without its numbered steps: "Step 2 of 4" over a bar in as many parts, the step's title, line and content, Back and Next once ready, Finish last, and Skip for now. Hotspot pulses on a dot that opens a tip on a press or Enter, with Got it. FeatureHighlight names something new under a New badge, with Try it and Not now, and rises in. SetupChecklist counts first things to do in words and a bar, offers Start on each left, eases a check in as one gets done, and offers Hide once all are. navigation::Wizard now keys its buttons, steps and motion by its own id and takes on_step and on_finish at render, failing loud without them. Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 1 + TASKS.md | 3 +- examples/gallery/pages/mod.rs | 2 + examples/gallery/pages/onboarding/flows.rs | 267 +++++++++++++++++++++ examples/gallery/pages/onboarding/mod.rs | 38 +++ src/lib.rs | 1 + src/navigation/steps.rs | 60 +++-- src/onboarding/checklist.rs | 219 +++++++++++++++++ src/onboarding/highlight.rs | 134 +++++++++++ src/onboarding/hotspot.rs | 124 ++++++++++ src/onboarding/mod.rs | 12 + src/onboarding/tests.rs | 235 ++++++++++++++++++ src/onboarding/wizard.rs | 184 ++++++++++++++ src/theme/mod.rs | 2 + src/theme/onboarding.rs | 18 ++ tasks/ch31-43.md | 15 +- 16 files changed, 1288 insertions(+), 27 deletions(-) create mode 100644 examples/gallery/pages/onboarding/flows.rs create mode 100644 examples/gallery/pages/onboarding/mod.rs create mode 100644 src/onboarding/checklist.rs create mode 100644 src/onboarding/highlight.rs create mode 100644 src/onboarding/hotspot.rs create mode 100644 src/onboarding/mod.rs create mode 100644 src/onboarding/tests.rs create mode 100644 src/onboarding/wizard.rs create mode 100644 src/theme/onboarding.rs diff --git a/AGENTS.md b/AGENTS.md index 388c765..8b6fbea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -78,6 +78,7 @@ Ely GPUI Component. A component library for GPUI, in light and dark. - A focus handle a component hands back lives across all its modes: take it with `tab_stop` before any early return, or keyed state drops it while the other mode draws and the handed-back focus lands nowhere. A thread's message keeps one handle folded and open, a Tab stop only while folded (`mail::MailThreadView`). - A button whose words and action change with a mode keeps one handle through `Button::focus_handle`, and the component moves focus to it or back to its field as the mode turns (`project::TimeTracker`). - A panel that opens from something other than a button goes through `overlays::Popover::with_opener`, which hands the opener its toggle; focus, placement and every way out stay in the popover (`calendar::EventPopover`'s chip). +- A flow in steps goes through `navigation::Wizard`: Back, Next, Finish and the step's motion live there. `onboarding::OnboardingWizard` leaves out its numbered steps (`headless`) and shows a count over a bar in as many parts. - A dialog goes through `overlays::Dialog`: scrim, focus trap, Escape and focus return live there. Its close runs on every way out, so a dialog with two answers marks the chosen one before it closes (`messaging::IncomingCallDialog`). AlertDialog, ConfirmDialog and PromptDialog build on it; a fullscreen dialog fills the window below its title bar. A dialog's card fits the window, a title bar's height clear at each end; its body scrolls and its actions stay. - Controls on a dark layer over pictures are `overlays::media_button`, light icons that dim when they have nowhere to go, for the lightbox and a video player's bar alike. - A component's letter keys step aside while Command or Control is held, so the app's shortcuts reach the app (`media::ImageAnnotator`, `VideoPlayer`, `VideoThumbnailStrip`). diff --git a/TASKS.md b/TASKS.md index fb5c72e..8d751d1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -128,7 +128,8 @@ Per-component lines live in `tasks/`. Tags there: - [x] T36a Account · Sign in — `tasks/ch31-43.md` - [x] T36b Account · Profile and access — `tasks/ch31-43.md` - [x] T36c Account · Billing and team — `tasks/ch31-43.md` -- [ ] T37 Onboarding & Help +- [ ] T37a Onboarding · Flows and highlights +- [ ] T37b Onboarding · Help - [ ] T38 Interaction - [ ] T39 Theme - [ ] T40 i18n & a11y diff --git a/examples/gallery/pages/mod.rs b/examples/gallery/pages/mod.rs index 484bef6..5c473a2 100644 --- a/examples/gallery/pages/mod.rs +++ b/examples/gallery/pages/mod.rs @@ -26,6 +26,7 @@ mod menus; mod messaging; mod motion; mod navigation; +mod onboarding; mod overlays; mod primitives; mod project; @@ -87,6 +88,7 @@ pub const ALL: &[Page] = &[ dashboard::PAGE, settings::PAGE, account::PAGE, + onboarding::PAGE, theme::PAGE, ]; diff --git a/examples/gallery/pages/onboarding/flows.rs b/examples/gallery/pages/onboarding/flows.rs new file mode 100644 index 0000000..6a7a614 --- /dev/null +++ b/examples/gallery/pages/onboarding/flows.rs @@ -0,0 +1,267 @@ +use ely_gpui_component::{ + buttons::{Button, ButtonVariant, IconButton}, + data_display::{Badge, Tone}, + forms::{FormField, Input, TextInput}, + lists::{List, ListItem}, + onboarding::{ + FeatureHighlight, Hotspot, OnboardingStep, OnboardingWizard, SetupChecklist, SetupTask, + }, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize}, +}; +use gpui::{App, Entity, IntoElement, ParentElement, Styled, Window, div, prelude::*, px}; + +use crate::{ + probe::probe, + ui::{change, keep, section}, +}; + +/// The wizard demo: its step, and whether it was finished. +#[derive(Default)] +struct Welcome { + step: usize, + finished: bool, +} + +fn field( + key: &'static str, + hint: &'static str, + window: &mut Window, + cx: &mut App, +) -> Entity { + window.use_keyed_state(key, cx, move |window, cx| { + TextInput::new(window, cx).placeholder(hint) + }) +} + +pub fn wizard(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("onboarding-welcome", Welcome::default, window, cx); + let name = field("onboarding-name", "Acme", window, cx); + let invite = field("onboarding-invite", "name@example.com", window, cx); + let (step, finished) = (state.read(cx).step, state.read(cx).finished); + let step_of = |key: &str, title: &str, body: &str| OnboardingStep { + key: key.to_string().into(), + title: title.to_string().into(), + body: body.to_string().into(), + }; + let steps = [ + step_of( + "name", + "Name your workspace", + "It shows in the sidebar and in every invitation.", + ), + step_of( + "invite", + "Bring your team", + "Add an address now, or later from Settings.", + ), + step_of( + "ready", + "You're set", + "Your workspace is ready. Press Finish to open it.", + ), + ]; + let ready = step != 0 || !name.read(cx).text().trim().is_empty(); + let content = match step { + 0 => FormField::new("onboarding-name-field", "Workspace name") + .child(Input::new(&name)) + .into_any_element(), + 1 => FormField::new("onboarding-invite-field", "Email") + .child(Input::new(&invite)) + .into_any_element(), + _ => div().into_any_element(), + }; + let [walk, finish, skip, again] = [(); 4].map(|_| state.clone()); + let body = match finished { + true => div() + .flex() + .flex_wrap() + .items_center() + .gap_3() + .child(format!("{} is ready.", name.read(cx).text())) + .child( + Button::new("onboarding-again", "Start over") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + change(&again, cx, |welcome| *welcome = Welcome::default()) + }), + ) + .into_any_element(), + false => OnboardingWizard::new("onboarding-wizard", steps, step) + .content(content) + .ready(ready) + .on_step(move |to, _, cx| change(&walk, cx, |welcome| welcome.step = to)) + .on_finish(move |_, cx| change(&finish, cx, |welcome| welcome.finished = true)) + .on_skip(move |_, cx| change(&skip, cx, |welcome| welcome.finished = true)) + .into_any_element(), + }; + section( + "OnboardingWizard · WelcomeTour", + "A first run in a few steps: where it stands over a bar in as many parts, each step's title and line, and Next once its fields are ready. The welcome tour that follows is overlays::Tour.", + cx, + ) + .child(probe("onboarding-wizard", div().w(px(440.)).child(body))) +} + +/// The highlights demo: what was dismissed. +#[derive(Default)] +struct Seen { + hotspot: bool, + highlight: bool, +} + +pub fn highlights(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep("onboarding-seen", Seen::default, window, cx); + let (spot_seen, card_seen) = (state.read(cx).hotspot, state.read(cx).highlight); + let [spot, card, reset] = [(); 3].map(|_| state.clone()); + let theme = cx.theme(); + let tool = + |id: &'static str, icon: IconName| IconButton::new(id, icon).variant(ButtonVariant::Ghost); + let pin = div() + .relative() + .child(tool("onboarding-pin", IconName::Pin)) + .when(!spot_seen, |pin| { + pin.child( + div().absolute().top_neg_1p5().right_neg_1p5().child(probe( + "onboarding-hotspot", + Hotspot::new( + "onboarding-hotspot", + "Pin a view", + "Keep the views you use most one press away.", + ) + .on_dismiss(move |_, cx| change(&spot, cx, |seen| seen.hotspot = true)), + )), + ) + }); + let toolbar = div() + .flex() + .items_center() + .gap_1() + .p_1() + .rounded_lg() + .border_1() + .border_color(theme.colors.border) + .child(tool("onboarding-search", IconName::Search)) + .child(pin) + .child(tool("onboarding-share", IconName::Share2)); + let highlight = (!card_seen).then(|| { + FeatureHighlight::new( + "onboarding-split", + IconName::Columns2, + "Split view", + "Work on two files side by side, each with its own history.", + ) + .on_try(|_, _| log::info!("gallery: split view tried")) + .on_dismiss(move |_, cx| change(&card, cx, |seen| seen.highlight = true)) + }); + let menu = List::new() + .child( + ListItem::new("onboarding-inbox", "Inbox") + .leading(Icon::new(IconName::Inbox).size(IconSize::Sm)), + ) + .child( + ListItem::new("onboarding-automations", "Automations") + .leading(Icon::new(IconName::Zap).size(IconSize::Sm)) + .trailing(Badge::new("New").tone(Tone::Accent)), + ); + section( + "Coachmark / Hotspot · FeatureHighlight / NewBadge", + "A hotspot's rings call out something new, and a press or Enter opens its tip. A highlight names a feature with a New badge over it; the badge alone marks a new row. Coachmarks are overlays::Spotlight.", + cx, + ) + .child( + div() + .flex() + .flex_wrap() + .items_start() + .gap_6() + .child(div().w(px(240.)).flex().flex_col().gap_6().child(toolbar).child(menu)) + .child(div().w(px(360.)).children(highlight)) + .when(spot_seen || card_seen, |row| { + row.child( + Button::new("onboarding-show-again", "Show again") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| change(&reset, cx, |seen| *seen = Seen::default())), + ) + }), + ) +} + +/// Which setup tasks are done, and whether the list was hidden. +struct Setup { + done: [bool; 4], + hidden: bool, +} + +pub fn checklist(window: &mut Window, cx: &mut App) -> impl IntoElement + use<> { + let state = keep( + "onboarding-setup", + || Setup { + done: [true, false, false, false], + hidden: false, + }, + window, + cx, + ); + let (done, hidden) = (state.read(cx).done, state.read(cx).hidden); + let [start, hide, reset] = [(); 3].map(|_| state.clone()); + const TASKS: [(&str, &str, &str); 4] = [ + ( + "profile", + "Add your photo", + "So your team knows who is who.", + ), + ("invite", "Invite a teammate", "Work is better shared."), + ( + "connect", + "Connect your calendar", + "See meetings beside your tasks.", + ), + ( + "shortcuts", + "Learn three shortcuts", + "Command K opens everything.", + ), + ]; + let tasks = TASKS + .iter() + .zip(done) + .map(|((key, title, body), done)| SetupTask { + key: (*key).into(), + title: (*title).into(), + body: (*body).into(), + done, + }); + let body = match hidden { + true => Button::new("onboarding-setup-again", "Show the checklist") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, _, cx| { + change(&reset, cx, |setup| { + *setup = Setup { + done: [true, false, false, false], + hidden: false, + } + }) + }) + .into_any_element(), + false => SetupChecklist::new("onboarding-setup", "Get started", tasks) + .on_start(move |key, _, cx| { + let at = TASKS + .iter() + .position(|(each, _, _)| *each == key.as_ref()) + .expect("a setup task"); + change(&start, cx, |setup| setup.done[at] = true) + }) + .on_dismiss(move |_, cx| change(&hide, cx, |setup| setup.hidden = true)) + .into_any_element(), + }; + section( + "Checklist (first-run tasks)", + "First things to do, counted in words and a bar. Start opens each; here it marks it done, and the check eases in. Once all are done, Hide puts it away.", + cx, + ) + .child(probe("onboarding-setup", div().w(px(400.)).child(body))) +} diff --git a/examples/gallery/pages/onboarding/mod.rs b/examples/gallery/pages/onboarding/mod.rs new file mode 100644 index 0000000..c58b07c --- /dev/null +++ b/examples/gallery/pages/onboarding/mod.rs @@ -0,0 +1,38 @@ +use gpui::{AnyElement, App, Window, div, prelude::*}; + +mod flows; + +use super::Page; +use crate::script::Step; + +pub const PAGE: Page = Page { + number: 37, + slug: "onboarding", + title: "Onboarding & Help", + summary: "A first run in steps, what is new called out, and first things to do.", + render, + script: SCRIPT, +}; + +const SCRIPT: &[Step] = &[ + Step::DownAt("onboarding-wizard", 200.0, 167.0), + Step::UpAt("onboarding-wizard", 200.0, 167.0), + Step::Type("Acme"), + Step::Key("tab"), + Step::Key("enter"), + Step::Wait(200), + Step::Shot("wizard-step-2"), + Step::DownAt("onboarding-hotspot", 10.0, 10.0), + Step::UpAt("onboarding-hotspot", 10.0, 10.0), + Step::Wait(200), + Step::Shot("hotspot-tip"), + Step::Key("escape"), +]; + +fn render(window: &mut Window, cx: &mut App) -> AnyElement { + div() + .child(flows::wizard(window, cx)) + .child(flows::highlights(window, cx)) + .child(flows::checklist(window, cx)) + .into_any_element() +} diff --git a/src/lib.rs b/src/lib.rs index ac45c99..e380b03 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,6 +28,7 @@ pub mod menus; pub mod messaging; pub mod motion; pub mod navigation; +pub mod onboarding; pub mod overlays; pub mod primitives; pub mod project; diff --git a/src/navigation/steps.rs b/src/navigation/steps.rs index 6ed1064..1474fe1 100644 --- a/src/navigation/steps.rs +++ b/src/navigation/steps.rs @@ -62,7 +62,8 @@ impl Steps { impl RenderOnce for Steps { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { - let turn = motion::changes((self.id.clone(), "current"), self.current, window, cx); + let id = self.id.clone(); + let turn = motion::changes((id.clone(), "current"), self.current, window, cx); let (current, vertical, count) = (self.current, self.vertical, self.steps.len()); let duration = motion::duration(motion::SLOW, cx); let theme = cx.theme(); @@ -79,7 +80,7 @@ impl RenderOnce for Steps { }); let fill = if filled && ix + 1 == current && turn > 0 { fill.with_animation( - ("step-fill", turn), + (id.clone(), format!("fill-{turn}")), Animation::new(duration).with_easing(motion::ease_out_cubic), move |fill, t| { if vertical { @@ -155,7 +156,7 @@ impl RenderOnce for Steps { }); let select = self.on_select.clone().filter(|_| done); let head = div() - .id(("step", ix)) + .id((id.clone(), format!("step-{ix}"))) .flex() .items_center() .gap_2() @@ -215,6 +216,7 @@ pub struct Wizard { current: usize, content: Option, ready: bool, + headless: bool, on_step: Option, on_finish: Option, } @@ -225,17 +227,30 @@ impl Wizard { steps: impl IntoIterator, current: usize, ) -> Self { + let steps: Vec = steps.into_iter().collect(); + assert!( + current < steps.len(), + "step {current} is past the last of {}", + steps.len() + ); Self { id: id.into(), - steps: steps.into_iter().collect(), + steps, current, content: None, ready: true, + headless: false, on_step: None, on_finish: None, } } + /// Leaves out the numbered steps; the owner shows where the flow stands. + pub(crate) fn headless(mut self) -> Self { + self.headless = true; + self + } + /// The current step's content. pub fn content(mut self, content: impl IntoElement) -> Self { self.content = Some(content.into_any_element()); @@ -264,28 +279,31 @@ impl RenderOnce for Wizard { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let (current, last) = (self.current, self.steps.len().saturating_sub(1)); let turn = motion::changes((self.id.clone(), "content"), current, window, cx); + let id = self.id.clone(); + let on_step = self + .on_step + .unwrap_or_else(|| panic!("wizard {id:?} has no on_step")); + let on_finish = self + .on_finish + .unwrap_or_else(|| panic!("wizard {id:?} has no on_finish")); let step: OnStep = { - let (id, on_step) = (self.id.clone(), self.on_step); + let id = id.clone(); Rc::new(move |to, window, cx| { log::info!("wizard {id:?}: step {to}"); - if let Some(on_step) = &on_step { - on_step(to, window, cx); - } + on_step(to, window, cx); }) }; let (back, next, jump) = (step.clone(), step.clone(), step); let finish = { - let (id, on_finish) = (self.id.clone(), self.on_finish); + let id = id.clone(); move |window: &mut Window, cx: &mut App| { log::info!("wizard {id:?}: finished"); - if let Some(on_finish) = &on_finish { - on_finish(window, cx); - } + on_finish(window, cx); } }; let content = self.content.map(|content| { div().child(content).with_animation( - ("wizard-step", turn), + (id.clone(), format!("step-{turn}")), Animation::new(motion::duration(motion::BASE, cx)) .with_easing(motion::ease_out_cubic), move |content, t| { @@ -298,12 +316,12 @@ impl RenderOnce for Wizard { ) }); let advance = if current == last { - Button::new("wizard-finish", "Finish") + Button::new((id.clone(), "finish"), "Finish") .primary() .disabled(!self.ready) .on_click(move |_, window, cx| finish(window, cx)) } else { - Button::new("wizard-next", "Next") + Button::new((id.clone(), "next"), "Next") .primary() .disabled(!self.ready) .on_click(move |_, window, cx| next(current + 1, window, cx)) @@ -314,10 +332,12 @@ impl RenderOnce for Wizard { .flex_col() .gap_6() .w_full() - .child( - Steps::new((self.id, "steps"), self.steps, current) - .on_select(move |to, window, cx| jump(to, window, cx)), - ) + .when(!self.headless, |wizard| { + wizard.child( + Steps::new((id.clone(), "steps"), self.steps, current) + .on_select(move |to, window, cx| jump(to, window, cx)), + ) + }) .children(content) .child( div() @@ -325,7 +345,7 @@ impl RenderOnce for Wizard { .items_center() .justify_between() .child( - Button::new("wizard-back", "Back") + Button::new((id.clone(), "back"), "Back") .variant(ButtonVariant::Ghost) .disabled(current == 0) .on_click(move |_, window, cx| back(current - 1, window, cx)), diff --git a/src/onboarding/checklist.rs b/src/onboarding/checklist.rs new file mode 100644 index 0000000..275d0d0 --- /dev/null +++ b/src/onboarding/checklist.rs @@ -0,0 +1,219 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, App, ElementId, FontWeight, InteractiveElement, IntoElement, + ParentElement, RenderOnce, SharedString, Styled, Window, div, prelude::*, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::Run, + motion::{self, ProgressBar}, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, TextSize}, + typography::tabular, +}; + +type OnKey = Rc; + +/// A first thing to do: its key, its title, a line on it, and whether it is done. +#[derive(Clone, Debug, PartialEq)] +pub struct SetupTask { + pub key: SharedString, + pub title: SharedString, + pub body: SharedString, + pub done: bool, +} + +/// First things to do in a new app: how many are done, in words and a bar in as many parts; each task with its line and Start while not done, done ones struck through. The check eases in as a task gets done. Once all are, it says so and, with `on_dismiss`, offers Hide. +#[derive(IntoElement)] +pub struct SetupChecklist { + id: ElementId, + title: SharedString, + tasks: Vec, + on_start: Option, + on_dismiss: Option, +} + +impl SetupChecklist { + pub fn new( + id: impl Into, + title: impl Into, + tasks: impl IntoIterator, + ) -> Self { + let tasks: Vec = tasks.into_iter().collect(); + assert!( + tasks.len() >= 2, + "a setup checklist needs two tasks, got {}", + tasks.len() + ); + Self { + id: id.into(), + title: title.into(), + tasks, + on_start: None, + on_dismiss: None, + } + } + + /// Runs with the key of the task whose Start was pressed. + pub fn on_start( + mut self, + handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static, + ) -> Self { + self.on_start = Some(Rc::new(handler)); + self + } + + /// Shows Hide once every task is done. + pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_dismiss = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for SetupChecklist { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_start = self + .on_start + .unwrap_or_else(|| panic!("setup checklist {id:?} has no on_start")); + let (done, all) = ( + self.tasks.iter().filter(|task| task.done).count(), + self.tasks.len(), + ); + let turns: Vec = self + .tasks + .iter() + .map(|task| { + motion::changes( + (id.clone(), format!("done-{}", task.key)), + task.done, + window, + cx, + ) + }) + .collect(); + let theme = cx.theme(); + let duration = motion::duration(motion::BASE, cx); + let rows = self + .tasks + .into_iter() + .zip(turns) + .enumerate() + .map(|(ix, (task, turn))| { + let (icon, ink) = match task.done { + true => (IconName::CircleCheck, theme.colors.success), + false => (IconName::Circle, theme.colors.fg_subtle), + }; + let mark = div() + .flex_none() + .pt_0p5() + .child(Icon::new(icon).size(IconSize::Sm).color(ink)) + .with_animation( + (id.clone(), format!("mark-{}-{turn}", task.key)), + Animation::new(duration).with_easing(motion::ease_out_cubic), + move |mark, t| match turn { + 0 => mark, + _ => mark.opacity(t), + }, + ); + let start = (!task.done).then(|| { + let (key, start) = (task.key.clone(), on_start.clone()); + Button::new((id.clone(), format!("start-{}", task.key)), "Start") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("setup checklist: start {key}"); + start(&key, window, cx) + }) + }); + let key = task.key.clone(); + div() + .debug_selector(move || format!("setup-task-{key}")) + .flex() + .items_start() + .gap_3() + .py_2p5() + .when(ix > 0, |row| { + row.border_t_1().border_color(theme.colors.border) + }) + .child(mark) + .child( + div() + .flex_1() + .min_w_0() + .child( + div() + .font_weight(FontWeight::MEDIUM) + .when(task.done, |title| { + title.line_through().text_color(theme.colors.fg_muted) + }) + .child(task.title), + ) + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(task.body), + ), + ) + .children(start.map(|start| div().flex_none().child(start))) + }); + let finished = (done == all).then(|| { + div() + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .pt_2() + .child( + div() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.success) + .child("All set."), + ) + .children(self.on_dismiss.map(|run| { + Button::new((id.clone(), "hide"), "Hide") + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("setup checklist: hidden"); + run(window, cx) + }) + })) + }); + div() + .flex() + .flex_col() + .gap_2() + .child( + div() + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .flex_1() + .min_w(theme.label_width()) + .font_weight(FontWeight::SEMIBOLD) + .child(self.title), + ) + .child( + tabular(div()) + .debug_selector(move || format!("setup-{done}-of-{all}")) + .flex_none() + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(format!("{done} of {all} done")), + ), + ) + .child( + ProgressBar::new((id.clone(), "progress"), done as f32 / all as f32).segments(all), + ) + .child(div().flex().flex_col().children(rows)) + .children(finished) + } +} diff --git a/src/onboarding/highlight.rs b/src/onboarding/highlight.rs new file mode 100644 index 0000000..40651f6 --- /dev/null +++ b/src/onboarding/highlight.rs @@ -0,0 +1,134 @@ +use std::rc::Rc; + +use gpui::{ + Animation, AnimationExt, App, ElementId, FontWeight, IntoElement, ParentElement, RenderOnce, + SharedString, Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + data_display::{Badge, Tone}, + forms::Run, + layout::Card, + motion, + primitives::{Icon, IconName}, + theme::{ActiveTheme, ControlSize, IconSize, Radius, TextSize}, +}; + +/// Word of something new: its icon, a New badge over its name, a line on what it does, then Try it and Not now. It rises in when it appears. +#[derive(IntoElement)] +pub struct FeatureHighlight { + id: ElementId, + icon: IconName, + title: SharedString, + body: SharedString, + on_try: Option, + on_dismiss: Option, +} + +impl FeatureHighlight { + pub fn new( + id: impl Into, + icon: IconName, + title: impl Into, + body: impl Into, + ) -> Self { + Self { + id: id.into(), + icon, + title: title.into(), + body: body.into(), + on_try: None, + on_dismiss: None, + } + } + + pub fn on_try(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_try = Some(Rc::new(handler)); + self + } + + /// Runs on Not now; the owner stops showing it. + pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_dismiss = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for FeatureHighlight { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let try_it = self + .on_try + .unwrap_or_else(|| panic!("feature highlight {id:?} has no on_try")); + let dismiss = self + .on_dismiss + .unwrap_or_else(|| panic!("feature highlight {id:?} has no on_dismiss")); + let theme = cx.theme(); + let title = self.title.clone(); + let actions = div() + .flex() + .flex_wrap() + .gap_2() + .mt_3() + .child( + Button::new((id.clone(), "try"), "Try it") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("feature highlight: try {title}"); + try_it(window, cx) + }), + ) + .child( + Button::new((id.clone(), "dismiss"), "Not now") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| dismiss(window, cx)), + ); + let words = div() + .flex_1() + .min_w_0() + .child(div().flex().child(Badge::new("New").tone(Tone::Accent))) + .child( + div() + .mt_2() + .text_size(theme.text_size(TextSize::Base)) + .font_weight(FontWeight::SEMIBOLD) + .child(self.title), + ) + .child( + div() + .mt_1() + .text_color(theme.colors.fg_muted) + .child(self.body), + ) + .child(actions); + let mark = div() + .flex_none() + .p_2() + .rounded(theme.radius(Radius::Md)) + .bg(theme.colors.sunken) + .child( + Icon::new(self.icon) + .size(IconSize::Md) + .color(theme.colors.fg_muted), + ); + Card::new() + .child( + div() + .flex() + .items_start() + .gap_3() + .text_size(theme.text_size(TextSize::Sm)) + .child(mark) + .child(words), + ) + .with_animation( + (id, "in"), + Animation::new(motion::duration(motion::SLOW, cx)) + .with_easing(motion::ease_out_cubic), + |card, t| card.opacity(t).mt(motion::NUDGE * 2.0 * (1.0 - t)), + ) + } +} diff --git a/src/onboarding/hotspot.rs b/src/onboarding/hotspot.rs new file mode 100644 index 0000000..d19c814 --- /dev/null +++ b/src/onboarding/hotspot.rs @@ -0,0 +1,124 @@ +use std::rc::Rc; + +use gpui::{ + App, ElementId, FontWeight, InteractiveElement, IntoElement, MouseButton, ParentElement, + RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::Run, + motion::Pulse, + overlays::Popover, + primitives::FocusRing, + theme::{ActiveTheme, ControlSize, TextSize}, +}; + +/// The dot: a Tab stop that opens the tip on a press or on Enter. +#[derive(IntoElement)] +struct Dot { + id: ElementId, + toggle: Run, +} + +impl RenderOnce for Dot { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let theme = cx.theme(); + let side = theme.onboarding().hotspot; + let toggle = self.toggle; + div() + .id(self.id.clone()) + .flex() + .flex_none() + .items_center() + .justify_center() + .size(side * 2.0) + .rounded_full() + .border_1() + .border_color(gpui::transparent_black()) + .tab_index(0) + .focus_ring(cx) + .cursor_pointer() + .on_mouse_down(MouseButton::Left, |_, window, _| window.prevent_default()) + .on_click(move |_, window, cx| toggle(window, cx)) + .child( + Pulse::new((self.id, "pulse")) + .size(side) + .rounded_full() + .bg(theme.colors.accent), + ) + } +} + +/// A dot that calls out something new, with rings that widen from it; a press or Enter opens a tip beside it with a title, a line and Got it. Under reduced motion no ring shows. +#[derive(IntoElement)] +pub struct Hotspot { + id: ElementId, + title: SharedString, + body: SharedString, + on_dismiss: Option, +} + +impl Hotspot { + pub fn new( + id: impl Into, + title: impl Into, + body: impl Into, + ) -> Self { + Self { + id: id.into(), + title: title.into(), + body: body.into(), + on_dismiss: None, + } + } + + /// Runs on Got it; the owner stops showing the hotspot. + pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_dismiss = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for Hotspot { + fn render(self, _: &mut Window, _: &mut App) -> impl IntoElement { + let id = self.id; + let dismiss = self + .on_dismiss + .unwrap_or_else(|| panic!("hotspot {id:?} has no on_dismiss")); + let (dot, got_it) = ((id.clone(), "dot"), (id.clone(), "got-it")); + let (title, body) = (self.title, self.body); + Popover::with_opener( + id.clone(), + move |toggle| Dot { + id: dot.into(), + toggle, + }, + move |_, cx| { + let theme = cx.theme(); + let prose = + |text: SharedString| div().flex().child(div().flex_1().min_w_0().child(text)); + div() + .debug_selector(|| "hotspot-tip".into()) + .w(theme.tooltip_max_width()) + .flex() + .flex_col() + .gap_2() + .text_size(theme.text_size(TextSize::Sm)) + .child(prose(title).font_weight(FontWeight::SEMIBOLD)) + .child(prose(body).text_color(theme.colors.fg_muted)) + .child( + div().flex().justify_end().pt_1().child( + Button::new(got_it, "Got it") + .variant(ButtonVariant::Primary) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("hotspot: dismissed"); + dismiss(window, cx) + }), + ), + ) + }, + ) + } +} diff --git a/src/onboarding/mod.rs b/src/onboarding/mod.rs new file mode 100644 index 0000000..f282d9f --- /dev/null +++ b/src/onboarding/mod.rs @@ -0,0 +1,12 @@ +mod checklist; +mod highlight; +mod hotspot; +mod wizard; + +#[cfg(all(test, feature = "test-support"))] +mod tests; + +pub use checklist::{SetupChecklist, SetupTask}; +pub use highlight::FeatureHighlight; +pub use hotspot::Hotspot; +pub use wizard::{OnboardingStep, OnboardingWizard}; diff --git a/src/onboarding/tests.rs b/src/onboarding/tests.rs new file mode 100644 index 0000000..2372baf --- /dev/null +++ b/src/onboarding/tests.rs @@ -0,0 +1,235 @@ +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, KeyBinding, ParentElement, Render, Styled, + TestAppContext, VisualTestContext, Window, div, px, +}; + +use super::{ + FeatureHighlight, Hotspot, OnboardingStep, OnboardingWizard, SetupChecklist, SetupTask, +}; +use crate::{ + forms, + primitives::{FocusNext, IconName}, + theme::Theme, +}; + +type Part = fn(&Bench, Entity) -> AnyElement; + +/// A view that shows one onboarding part, keeps what it heard, and holds the owner's step and readiness. +struct Bench { + part: Part, + said: Vec, + step: usize, + ready: bool, + all_done: bool, +} + +impl Render for Bench { + fn render(&mut self, _: &mut Window, cx: &mut Context) -> impl IntoElement { + div() + .w(px(360.0)) + .p_4() + .child((self.part)(self, cx.entity())) + } +} + +fn bench(part: Part, cx: &mut TestAppContext) -> (Entity, &mut VisualTestContext) { + cx.update(|cx| { + Theme::init(cx); + Theme::update(cx, |theme| theme.reduced_motion = true); + forms::bind_keys(cx); + cx.bind_keys([KeyBinding::new("tab", FocusNext, None)]); + }); + let (host, cx) = cx.add_window_view(|_, _| Bench { + part, + said: Vec::new(), + step: 0, + ready: true, + all_done: false, + }); + settle(cx); + (host, cx) +} + +fn settle(cx: &mut VisualTestContext) { + for _ in 0..3 { + cx.run_until_parked(); + cx.update(|window, _| window.refresh()); + } + cx.run_until_parked(); +} + +fn say(owner: &Entity, words: String, cx: &mut App) { + owner.update(cx, |bench, cx| { + bench.said.push(words); + cx.notify(); + }); +} + +fn said(host: &Entity, cx: &mut VisualTestContext) -> Vec { + host.read_with(cx, |bench, _| bench.said.clone()) +} + +fn edit(host: &Entity, cx: &mut VisualTestContext, change: impl FnOnce(&mut Bench)) { + host.update(cx, |bench, cx| { + change(bench); + cx.notify(); + }); + settle(cx); +} + +/// Focus on the `stops`th Tab stop from the top. +fn tab(stops: usize, cx: &mut VisualTestContext) { + cx.update(|window, _| { + window.blur(); + (0..stops).for_each(|_| window.focus_next()); + }); + settle(cx); +} + +fn tap(key: &str, cx: &mut VisualTestContext) { + cx.simulate_keystrokes(key); + cx.simulate_event(gpui::KeyUpEvent { + keystroke: gpui::Keystroke::parse(key).expect("a key"), + }); + settle(cx); +} + +fn wizard(bench: &Bench, owner: Entity) -> AnyElement { + let steps = ["name", "invite", "theme"].map(|key| OnboardingStep { + key: key.into(), + title: format!("About {key}").into(), + body: "A line on it".into(), + }); + let [stepper, finisher, skipper] = [(); 3].map(|_| owner.clone()); + OnboardingWizard::new("welcome", steps, bench.step) + .ready(bench.ready) + .content(div().child("Fields")) + .on_step(move |to, _, cx| { + stepper.update(cx, |bench, cx| { + bench.step = to; + bench.said.push(format!("step {to}")); + cx.notify(); + }) + }) + .on_finish(move |_, cx| say(&finisher, "finish".into(), cx)) + .on_skip(move |_, cx| say(&skipper, "skip".into(), cx)) + .into_any_element() +} + +/// Stops: Skip, then Next on the first step; Skip, Back, then Next or Finish after it. +#[gpui::test] +fn the_wizard_walks_on_and_back_and_finishes(cx: &mut TestAppContext) { + let (host, cx) = bench(wizard, cx); + assert!(cx.debug_bounds("onboarding-step-1-of-3").is_some()); + tab(2, cx); + tap("space", cx); + tab(2, cx); + tap("space", cx); + edit(&host, cx, |bench| bench.step = 2); + assert!(cx.debug_bounds("onboarding-step-3-of-3").is_some()); + tab(3, cx); + tap("space", cx); + tab(1, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["step 1", "step 0", "finish", "skip"]); +} + +/// While the step's fields are not ready, Next rests, so the second stop wraps to Skip. +#[gpui::test] +fn next_rests_until_the_step_is_ready(cx: &mut TestAppContext) { + let (host, cx) = bench(wizard, cx); + edit(&host, cx, |bench| bench.ready = false); + tab(2, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["skip"]); +} + +fn hotspot(_: &Bench, owner: Entity) -> AnyElement { + Hotspot::new("pins", "Pin a view", "Keep it one press away.") + .on_dismiss(move |_, cx| say(&owner, "seen".into(), cx)) + .into_any_element() +} + +/// Stops: the dot; Enter opens the tip, where Got it is the one stop. +#[gpui::test] +fn a_hotspot_opens_its_tip_from_the_keyboard_and_is_dismissed(cx: &mut TestAppContext) { + let (host, cx) = bench(hotspot, cx); + assert!( + cx.debug_bounds("hotspot-tip").is_none(), + "the tip rests shut" + ); + tab(1, cx); + tap("enter", cx); + assert!(cx.debug_bounds("hotspot-tip").is_some()); + tap("escape", cx); + tap("enter", cx); + tap("tab", cx); + tap("space", cx); + assert_eq!( + said(&host, cx), + ["seen"], + "Escape handed focus back to the dot" + ); +} + +fn highlight(_: &Bench, owner: Entity) -> AnyElement { + let later = owner.clone(); + FeatureHighlight::new( + "split", + IconName::Columns2, + "Split view", + "Work on two files side by side.", + ) + .on_try(move |_, cx| say(&owner, "try".into(), cx)) + .on_dismiss(move |_, cx| say(&later, "later".into(), cx)) + .into_any_element() +} + +/// Stops: Try it, then Not now. +#[gpui::test] +fn a_feature_highlight_is_tried_or_put_off(cx: &mut TestAppContext) { + let (host, cx) = bench(highlight, cx); + tab(1, cx); + tap("space", cx); + tab(2, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["try", "later"]); +} + +fn checklist(bench: &Bench, owner: Entity) -> AnyElement { + let task = |key: &str, done: bool| SetupTask { + key: key.to_string().into(), + title: format!("Do {key}").into(), + body: "Why it helps".into(), + done: done || bench.all_done, + }; + let hidden = owner.clone(); + SetupChecklist::new( + "setup", + "Get started", + [ + task("profile", true), + task("invite", false), + task("connect", false), + ], + ) + .on_start(move |key, _, cx| say(&owner, format!("start {key}"), cx)) + .on_dismiss(move |_, cx| say(&hidden, "hide".into(), cx)) + .into_any_element() +} + +/// Stops: Start on each task not done, so the third wraps to the first; once all are done, Hide. +#[gpui::test] +fn a_setup_checklist_starts_what_is_left_and_hides_when_done(cx: &mut TestAppContext) { + let (host, cx) = bench(checklist, cx); + assert!(cx.debug_bounds("setup-1-of-3").is_some()); + tab(2, cx); + tap("space", cx); + tab(3, cx); + tap("space", cx); + edit(&host, cx, |bench| bench.all_done = true); + assert!(cx.debug_bounds("setup-3-of-3").is_some()); + tab(1, cx); + tap("space", cx); + assert_eq!(said(&host, cx), ["start connect", "start invite", "hide"]); +} diff --git a/src/onboarding/wizard.rs b/src/onboarding/wizard.rs new file mode 100644 index 0000000..814fa20 --- /dev/null +++ b/src/onboarding/wizard.rs @@ -0,0 +1,184 @@ +use std::rc::Rc; + +use gpui::{ + AnyElement, App, ElementId, FontWeight, InteractiveElement, IntoElement, ParentElement, + RenderOnce, SharedString, Styled, Window, div, +}; + +use crate::{ + buttons::{Button, ButtonVariant}, + forms::{Choice, Run}, + motion::ProgressBar, + navigation::Wizard, + theme::{ActiveTheme, ControlSize, TextSize}, +}; + +type OnStep = Rc; + +/// A step on the way in: its key, its title and a line on what it is for. +#[derive(Clone, Debug, PartialEq)] +pub struct OnboardingStep { + pub key: SharedString, + pub title: SharedString, + pub body: SharedString, +} + +/// A first-run flow on `navigation::Wizard`: "Step 2 of 4" over a bar in as many parts, the step's title, line and content, then Back and Next; Next is Finish on the last step. With `on_skip`, Skip for now leaves early. +#[derive(IntoElement)] +pub struct OnboardingWizard { + id: ElementId, + steps: Vec, + current: usize, + content: Option, + ready: bool, + on_step: Option, + on_finish: Option, + on_skip: Option, +} + +impl OnboardingWizard { + pub fn new( + id: impl Into, + steps: impl IntoIterator, + current: usize, + ) -> Self { + let steps: Vec = steps.into_iter().collect(); + assert!( + steps.len() >= 2, + "an onboarding wizard needs two steps, got {}", + steps.len() + ); + assert!( + current < steps.len(), + "step {current} is past the last of {}", + steps.len() + ); + Self { + id: id.into(), + steps, + current, + content: None, + ready: true, + on_step: None, + on_finish: None, + on_skip: None, + } + } + + /// The current step's content, under its title and line. + pub fn content(mut self, content: impl IntoElement) -> Self { + self.content = Some(content.into_any_element()); + self + } + + /// Whether Next may run; the step's fields decide. + pub fn ready(mut self, ready: bool) -> Self { + self.ready = ready; + self + } + + /// Runs with the step Back or Next leads to. + pub fn on_step(mut self, handler: impl Fn(usize, &mut Window, &mut App) + 'static) -> Self { + self.on_step = Some(Rc::new(handler)); + self + } + + pub fn on_finish(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_finish = Some(Rc::new(handler)); + self + } + + /// Shows Skip for now. + pub fn on_skip(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self { + self.on_skip = Some(Rc::new(handler)); + self + } +} + +impl RenderOnce for OnboardingWizard { + fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement { + let id = self.id; + let on_step = self + .on_step + .unwrap_or_else(|| panic!("onboarding wizard {id:?} has no on_step")); + let on_finish = self + .on_finish + .unwrap_or_else(|| panic!("onboarding wizard {id:?} has no on_finish")); + let theme = cx.theme(); + let (current, count) = (self.current, self.steps.len()); + let skip = self.on_skip.map(|run| { + Button::new((id.clone(), "skip"), "Skip for now") + .variant(ButtonVariant::Ghost) + .size(ControlSize::Sm) + .on_click(move |_, window, cx| { + log::info!("onboarding wizard: skipped at step {current}"); + run(window, cx) + }) + }); + let head = div() + .flex() + .flex_wrap() + .items_center() + .justify_between() + .gap_2() + .child( + div() + .debug_selector(move || format!("onboarding-step-{}-of-{count}", current + 1)) + .text_size(theme.text_size(TextSize::Sm)) + .text_color(theme.colors.fg_muted) + .child(format!("Step {} of {count}", current + 1)), + ) + .children(skip.map(|skip| div().flex_none().child(skip))); + let step = &self.steps[current]; + let words = div().flex().child( + div() + .flex_1() + .min_w_0() + .child( + div() + .text_size(theme.text_size(TextSize::Lg)) + .font_weight(FontWeight::SEMIBOLD) + .child(step.title.clone()), + ) + .child( + div() + .mt_1() + .text_color(theme.colors.fg_muted) + .child(step.body.clone()), + ), + ); + let wizard = Wizard::new( + (id.clone(), "wizard"), + self.steps + .iter() + .map(|step| Choice::new(step.key.clone(), step.title.clone())), + current, + ) + .headless() + .ready(self.ready) + .content( + div() + .flex() + .flex_col() + .gap_5() + .child(words) + .children(self.content), + ) + .on_step(move |to, window, cx| on_step(to, window, cx)) + .on_finish(move |window, cx| on_finish(window, cx)); + div() + .flex() + .flex_col() + .gap_6() + .child( + div().flex().flex_col().gap_2().child(head).child( + ProgressBar::new( + (id.clone(), "progress"), + (current + 1) as f32 / count as f32, + ) + .segments(count), + ), + ) + .child(wizard) + } +} diff --git a/src/theme/mod.rs b/src/theme/mod.rs index 8f6b9d0..35c6724 100644 --- a/src/theme/mod.rs +++ b/src/theme/mod.rs @@ -6,6 +6,7 @@ mod files; mod generative; mod media; mod messaging; +mod onboarding; mod palette; mod project; mod tokens; @@ -23,6 +24,7 @@ pub use files::FileSizes; pub use generative::GenerativeSizes; pub use media::MediaSizes; pub use messaging::MessagingSizes; +pub use onboarding::OnboardingSizes; pub use palette::{HUE_NAMES, Mix, Palette, Syntax}; pub use project::ProjectSizes; pub use tokens::{ diff --git a/src/theme/onboarding.rs b/src/theme/onboarding.rs new file mode 100644 index 0000000..8dba1a7 --- /dev/null +++ b/src/theme/onboarding.rs @@ -0,0 +1,18 @@ +use gpui::Rems; + +use super::{Theme, tokens::px_to_rems}; + +/// Onboarding's measures. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct OnboardingSizes { + /// A hotspot's dot, wide; its rings reach twice that. + pub hotspot: Rems, +} + +impl Theme { + pub fn onboarding(&self) -> OnboardingSizes { + OnboardingSizes { + hotspot: px_to_rems(10.0), + } + } +} diff --git a/tasks/ch31-43.md b/tasks/ch31-43.md index 572a666..5b73da0 100644 --- a/tasks/ch31-43.md +++ b/tasks/ch31-43.md @@ -198,13 +198,16 @@ - [x] RoleSelector - [x] InvitationList -## T37 Onboarding & Help — `src/onboarding` +## T37a Onboarding · Flows and highlights — `src/onboarding` + +- [x] OnboardingWizard — on `navigation::Wizard` without its numbered steps +- [x] WelcomeTour → `overlays::Tour` (T09b) +- [x] Coachmark / Hotspot — Coachmark → `overlays::Spotlight`; Hotspot here +- [x] FeatureHighlight / NewBadge — NewBadge → `data_display::Badge` with `Tone::Accent`, which FeatureHighlight wears +- [x] Checklist (first-run tasks) → `onboarding::SetupChecklist`; a to-do list ticked by hand is `documents::Checklist` + +## T37b Onboarding · Help -- [ ] OnboardingWizard -- [ ] WelcomeTour → `overlays::Tour` (T09b) -- [ ] Coachmark / Hotspot — Coachmark → `overlays::Spotlight`; Hotspot here -- [ ] FeatureHighlight / NewBadge -- [ ] Checklist (first-run tasks) - [ ] HelpTooltip - [ ] HelpPanel / DocsSidebar - [ ] KeyboardShortcutCheatsheet