# Tutorial / Onboarding Mode ## Context New parents signing up for the chore app land on an empty screen with no guidance. Many will not be especially technical — a busy parent setting things up on a phone needs to be shown where to tap, what each control does, and what states like `TOO LATE` / `PENDING` mean, without feeling badgered. Today there is no onboarding, no first-time tips, no contextual help. The goal is a modular tutorial layer that: - Walks users through the essentials (PIN → child → chore) so the app is never empty after first use. - Surfaces contextual hints **just-in-time** when users reach a feature for the first time (image picker, kebab menus, scheduler, status badges, notifications, assignment, etc.). - Persists progress per-user on the backend so it syncs across devices and is dismissed only once. - Can be reset or disabled from the profile page. - Is friction-light: visible Skip, one card at a time outside the intro, a permanent `?` button so users can re-trigger help themselves. - Is extensible — adding a tutorial for a future feature is roughly a one-line call + a registry entry. ## Approach (locked decisions) - **Cadence**: brief forced 3-step intro after first sign-in (PIN setup → first child → first chore), then everything else is just-in-time. - **Mobile**: bottom-sheet card on screens ≤600px, floating tooltip on desktop. Spotlight cut-out is shared. - **Skip behaviour**: a single Skip suppresses all hints for the session; user re-engages via the per-screen `?` button or by resetting from the profile. - **State storage**: `tutorial_progress: dict[str, bool]` and `tutorial_enabled: bool` on the `User` model, broadcast via existing `PROFILE_UPDATED` SSE event. - **Step authoring**: imperative `tutorial.maybeShow('step-id', anchorRef)` from `onMounted` / `watch` in each host component, with step copy in a single registry file. ## Architecture ### Backend - **`backend/models/user.py`** — add `tutorial_enabled: bool = True` and `tutorial_progress: dict = field(default_factory=dict)` alongside `email_digest_enabled`. Update `from_dict` / `to_dict`. - **`backend/api/user_api.py`** — add `PATCH /user/tutorial-progress` that accepts: - `{ "step_id": "...", "seen": true }` — mark one step - `{ "reset": true }` — clear the dict - `{ "enabled": false }` — toggle Each branch emits `EventType.PROFILE_UPDATED` (already wired) so other tabs / devices stay in sync. ### Frontend — new directory `frontend/src/tutorial/` - **`controller.ts`** — module-level `ref()`s (matches the no-Pinia pattern from `stores/auth.ts`): ```ts export const tutorialEnabled = ref(true) export const tutorialProgress = ref>({}) export const activeStep = ref(null) export function shouldShowStep(id: string): boolean export function maybeShow(id: string, anchor: HTMLElement | (() => HTMLElement | null)): void export async function markStepSeen(id: string): Promise // PATCH + optimistic export async function resetAllProgress(): Promise export async function setTutorialEnabled(on: boolean): Promise ``` Hydrated from `/user/profile` (extend the existing fetch in `LoginButton.vue`); re-hydrated on `profile_updated` SSE. - **`steps.ts`** — single registry of step copy/config: ```ts export interface StepDef { id: string title: string body: string next?: string // chains a tour placement?: 'auto' | 'below' | 'above' ctaLabel?: string // defaults "Got it" } export const stepRegistry: Record = { … } ``` Step IDs follow flat kebab-case: `setup-parent-pin`, `create-child`, `create-chore`, `create-chore-image-photo`, `create-chore-image-camera`, `create-chore-schedule`, `chore-kebab`, `chore-kebab-extend-time`, `status-too-late`, `status-pending`, `notification-click`, `assign-chore`, `child-mode-tour-offer`, etc. - **`TutorialOverlay.vue`** — mounted once in `App.vue`. Watches `activeStep`. Renders: - Four-rectangle dim (top/left/right/bottom of the anchor's bbox) with `rgba(0,0,0,0.55)`. - A coach-mark card with `title`, `body`, **Skip tour** + primary action button. - Desktop: tooltip positioned by `getBoundingClientRect()` + viewport clamping (no `floating-ui` — same hand-rolled pattern as the existing `TimePickerPopover.vue`). - Mobile (`window.matchMedia('(max-width: 600px)')`): card docks as a bottom sheet with a chevron pointing toward the spotlight. - Re-positions on `resize`, capture-phase `scroll`, and `ResizeObserver` on the card. - `role="dialog"`, focus trap, Esc to dismiss, `aria-live="polite"` for announcements, `prefers-reduced-motion` kills animations. - **`HelpButton.vue`** — a small `?` icon placed in the `ParentLayout` topbar (and `ChildLayout`). On tap, re-fires the most relevant step for the current route from a `routeToStep` map. This is the user's escape hatch — critical for the non-technical audience. ### Triggering — three sources, all routed through `maybeShow` 1. **Route entered** — `router.afterEach` in `controller.ts` consults a small `routeTriggers: Record` map (used for `tab-tasks`, `tab-rewards`, `tab-notifications`, …). 2. **Element first rendered** — host component calls `maybeShow` in `onMounted` or `watchEffect` once its anchor `ref` is bound. 3. **State-derived** — host component `watch`es a reactive condition (`isChoreLate`, `menuOpen`, `firstPendingChore`) and calls `maybeShow` when it flips true. This is how dynamic kebab options (`Extend Time` only on late chores) and badge-first-appearance (`TOO LATE`, `PENDING`) get triggered. No global DOM scanning, no MutationObserver — every anchor is already owned by a Vue component that knows when it appears. ## Step coverage (the JIT hints) Phase 2 wires these via `maybeShow` calls in the listed components. All copy is warm/parent-friendly, never jargon. | Step ID | Trigger | Anchor | |---|---|---| | `setup-parent-pin` (intro #1) | First load after signup, no PIN set | `LoginButton.vue` avatar | | `create-child` (intro #2) | After PIN set, 0 children | FAB in `ChildrenListView.vue` | | `create-chore` (intro #3) | After 1+ child, 0 chores, on Tasks tab | FAB in tasks view | | `create-chore-image-photo` | First time `ImagePicker` opens | `+` button (`addPhotoBtn` ref) in `ImagePicker.vue` | | `create-chore-image-camera` | First time `ImagePicker` opens | Camera button (`cameraBtn` ref) in `ImagePicker.vue` | | `create-chore-schedule` | First time `ScheduleModal` opens | day chips in `ScheduleModal.vue` | | `create-kindness`, `create-penalty`, `create-routine` | First time landing on each create view | FAB / form | | `create-routine-add-task` | First time inside `RoutineEditView` | `.add-task-trigger` | | `create-reward` | First time on Rewards tab with 0 rewards | FAB | | `notification-click` | First time on Notifications tab with 1+ notification | first notification row in `NotificationView.vue` | | `select-child` then `assign-chore` / `assign-reward` / `assign-routine` | First time entering ParentView for a specific child | the assignment sections | | `child-kebab` | First time `.kebab-btn` opens in `ChildrenListView.vue` | the open menu | | `chore-kebab` | First time `.kebab-menu` opens in `ParentView.vue` | the open menu | | `chore-kebab-extend-time` | First time the `Extend Time` menu item renders (chore is late) | that menu item | | `chore-kebab-reset` | First time the `Reset` menu item renders | that menu item | | `status-too-late` | First time `.chore-stamp` (TOO LATE) renders for any chore | the badge | | `status-pending` | First time `.chore-stamp.pending-stamp` renders | the badge | Adding a future step = one entry in `steps.ts` + one `maybeShow` call at the anchor site. ## Profile controls (`UserProfile.vue`) Add a new "Help" section after the existing email-digest / push-notifications toggles: - **Show tutorial tips** toggle — bound to `tutorialEnabled`, copy: *"Show helpful tips as I use the app."* - **Restart tutorial** button — opens a `ModalDialog` confirm (*"Start the tour again from the beginning?"*), then calls `resetAllProgress()` and routes to `/parent` (a toast confirms reset). Dev bypass: support `?tutorial=off` / `?tutorial=reset` in `controller.ts` init (gated by `import.meta.env.DEV`) for debugging without touching the backend. ## Child-mode tour (parent learns to teach) A derived `watchEffect` in `controller.ts`: ```ts watchEffect(() => { const p = tutorialProgress.value if (p['create-child'] && p['create-chore'] && p['create-reward'] && !p['child-mode-tour-offer']) { showOfferModal() // ModalDialog with Yes / Maybe later / No thanks } }) ``` - **Yes** → mark `child-mode-tour-offer` seen, navigate to `/child`, fire `child-mode-*` steps (separate IDs, same registry/controller). - **Maybe later** → don't mark seen; reappears once next session, then auto-converts to "No thanks" (max two asks). - **No thanks** → mark seen, never asked again (reset clears it). ## UX rules baked in (for non-technical users) - 3-step forced intro, never longer. Single-card JIT hints elsewhere — no chained `Next → Next → Next` outside the intro. - Skip is always visible, labeled "Skip tour", and one Skip suppresses everything for the session. - Persistent `?` button on every screen — taps re-fire the most relevant step. This is the single most important affordance for users who don't remember the first run. - Copy is short and warm: *"Let's add your first kiddo"*, not *"Create a child entity."* - If an anchor element doesn't exist (e.g. 0 chores so no kebab to point at), the step silently waits — never an arrow into empty space. - Bottom-sheet on mobile keeps text at thumb height and survives the on-screen keyboard via the `visualViewport` API. - `prefers-reduced-motion`, focus trap, Esc-to-dismiss, ≥5:1 contrast on the highlight ring. ## Phasing **Phase 1 — Infrastructure + one end-to-end step.** - Backend `tutorial_enabled` / `tutorial_progress` fields, PATCH endpoint, SSE wiring. - Frontend `controller.ts`, `steps.ts` (one entry), `TutorialOverlay.vue` mounted in `App.vue`, hydrate from profile fetch, listen for `profile_updated`. - Ship `create-child` end-to-end (triggered from `ChildrenListView.vue` when child list is empty). - Manual verification: sign up a fresh user, see the coach mark, dismiss, confirm it doesn't return; reload to confirm persistence; open in second tab to confirm SSE sync. **Phase 2 — Roll out remaining steps.** - Add the 3-step forced intro chaining (`setup-parent-pin` → `create-child` → `create-chore`). - Add the JIT step entries from the coverage table; wire `maybeShow` calls at each anchor. - Add `HelpButton.vue` in both layouts with a `routeToStep` map. **Phase 3 — Profile controls + child-mode tour.** - "Help" section in `UserProfile.vue` (toggle + restart button). - `child-mode-tour-offer` watcher + child-mode step set. - Dev bypass URL params. ## Critical files **Backend** - `backend/models/user.py` — model fields - `backend/api/user_api.py` — PATCH endpoint - (no event type changes — `PROFILE_UPDATED` is reused) **Frontend (new)** - `frontend/src/tutorial/controller.ts` - `frontend/src/tutorial/steps.ts` - `frontend/src/tutorial/TutorialOverlay.vue` - `frontend/src/tutorial/HelpButton.vue` **Frontend (modified — `maybeShow` call sites, mostly one-liners)** - `frontend/src/App.vue` — mount `TutorialOverlay` - `frontend/src/common/models.ts` — `User` interface additions - `frontend/src/components/shared/LoginButton.vue` — hydrate controller from profile fetch - `frontend/src/layout/ParentLayout.vue`, `frontend/src/layout/ChildLayout.vue` — `HelpButton` - `frontend/src/components/shared/ChildrenListView.vue` — `create-child`, `child-kebab` - `frontend/src/components/child/ParentView.vue` — `chore-kebab`, `chore-kebab-extend-time`, `status-too-late`, `status-pending`, `assign-*`, `select-child` - `frontend/src/components/task/ChoreEditView.vue` + sibling create views — `create-chore`, `create-kindness`, `create-penalty` - `frontend/src/components/utils/ImagePicker.vue` — `create-chore-image-photo`, `create-chore-image-camera` - `frontend/src/components/routine/RoutineEditView.vue` — `create-routine`, `create-routine-add-task` - `frontend/src/components/reward/RewardEditView.vue` — `create-reward` - `frontend/src/components/notification/NotificationView.vue` — `notification-click` - `frontend/src/components/shared/ScheduleModal.vue` — `create-chore-schedule` - `frontend/src/components/profile/UserProfile.vue` — "Help" section ## Verification - **Backend**: `pytest tests/test_user_api.py` covering the new PATCH branches (mark / reset / disable), plus a check that `PROFILE_UPDATED` is emitted. - **Frontend unit**: `npm run test:unit` for `controller.ts` (`shouldShowStep`, `markStepSeen` optimism, reset/disable). - **E2E** (`npx playwright test`): a new bucket `chromium-tutorial` with an isolated user that signs up fresh, walks the 3-step intro, navigates to Rewards/Notifications to hit JIT hints, then resets from the profile and confirms the intro replays. - **Manual mobile check**: Chrome devtools at 375×667, confirm the bottom-sheet variant renders, survives the on-screen keyboard, and re-positions on rotation. - **Manual cross-device SSE**: log in to two tabs, dismiss a step in one, confirm the second tab updates without manual refresh. - **Accessibility**: keyboard-only run-through (Tab/Esc/Enter); VoiceOver pass on macOS to confirm `aria-live` announcements; `prefers-reduced-motion` set in devtools to confirm pulse animation is suppressed.