From c3b2d69c2186820c72f04cb1f9f0f9da25b12a55 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:53:27 +0400 Subject: [PATCH] feat: coach-mark tips --- AGENTS.md | 91 ++- e2e/tests/motion.test.mjs | 30 +- e2e/tests/ui.test.mjs | 369 ++++++++- package.json | 3 +- pnpm-lock.yaml | 20 - src/app/page.tsx | 98 +-- src/components/client-providers.tsx | 5 +- src/components/home-header.tsx | 4 +- src/components/onboarding-card.tsx | 117 --- src/components/onboarding-provider.tsx | 67 -- src/components/onboarding-tour.tsx | 126 +++ src/components/onboarding/scenes.tsx | 927 ++++++++++++++++++++++ src/components/profile-data-table.tsx | 18 +- src/components/profile-isolation-demo.tsx | 4 +- src/components/profile-search-input.tsx | 2 + src/components/rail-nav.tsx | 59 +- src/components/tips-dialog.tsx | 74 +- src/components/tips/inline-tip.tsx | 208 ----- src/components/tips/tip-coach-mark.tsx | 200 +++++ src/components/ui/coach-mark.tsx | 793 ++++++++++++++++++ src/components/ui/step-dots.tsx | 66 ++ src/components/welcome-dialog.tsx | 900 +++++++++++---------- src/generated/licenses.json | 4 - src/hooks/use-tips.ts | 74 +- src/i18n/locales/de.json | 20 +- src/i18n/locales/en.json | 20 +- src/i18n/locales/es.json | 20 +- src/i18n/locales/fr.json | 20 +- src/i18n/locales/ja.json | 20 +- src/i18n/locales/ko.json | 20 +- src/i18n/locales/pt.json | 20 +- src/i18n/locales/ru.json | 20 +- src/i18n/locales/tr.json | 20 +- src/i18n/locales/vi.json | 20 +- src/i18n/locales/zh.json | 20 +- src/lib/coach-mark.test.mjs | 89 +++ src/lib/coach-mark.ts | 221 ++++++ src/lib/onboarding-signal.ts | 10 - src/lib/tips.test.mjs | 26 + src/lib/tips.ts | 46 +- 40 files changed, 3738 insertions(+), 1133 deletions(-) delete mode 100644 src/components/onboarding-card.tsx delete mode 100644 src/components/onboarding-provider.tsx create mode 100644 src/components/onboarding-tour.tsx create mode 100644 src/components/onboarding/scenes.tsx delete mode 100644 src/components/tips/inline-tip.tsx create mode 100644 src/components/tips/tip-coach-mark.tsx create mode 100644 src/components/ui/coach-mark.tsx create mode 100644 src/components/ui/step-dots.tsx create mode 100644 src/lib/coach-mark.test.mjs create mode 100644 src/lib/coach-mark.ts diff --git a/AGENTS.md b/AGENTS.md index 22edd105..0154bec2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -40,11 +40,12 @@ donutbrowser/ │ │ ├── entity-empty-state.tsx # Empty states of those pages, with their scenes │ │ ├── profile-search-input.tsx # Profile search with ghost-text completion and suggestions │ │ ├── settings/ # Settings page parts: rows, choice cards, theme previews, sounds, save bar -│ │ └── tips/ # Feature tips: scenes, icons and the inline tip card +│ │ ├── onboarding/ # Welcome step scenes +│ │ └── tips/ # Feature tips: scenes, icons and the day's coach mark │ ├── hooks/ # Event-driven React hooks, table preferences, filtering, and keyboard controls │ ├── i18n/locales/ # Translations (de, en, es, fr, ja, ko, pt, ru, tr, vi, zh) │ ├── generated/ # Build-generated third-party license inventory -│ ├── lib/ # Utilities (themes, toast, browser-utils, ui-sounds, schedules, team-activity) +│ ├── lib/ # Utilities (themes, toast, browser-utils, ui-sounds, schedules, team-activity, coach-mark placement) │ └── types.ts # Shared TypeScript interfaces ├── src-tauri/ # Rust backend (Tauri) │ ├── src/ @@ -311,11 +312,11 @@ Reference implementations: `proxy-management-dialog.tsx`, `extension-management- Tips are short feature walkthroughs: a looping SVG scene, a title, two or three lines of copy, and a button into the feature. The catalog is -`src/lib/tips.ts` (ids, sections, deep-link actions, the plan capability a -tip needs, the `inUse` relevance check); scenes live in -`src/components/tips/scenes-*.tsx` and are mapped in `scene-for.tsx`, icons -in `tip-icon.tsx`; the catalog dialog is `src/components/tips-dialog.tsx`; -the card on the Profiles page is `src/components/tips/inline-tip.tsx`; the +`src/lib/tips.ts` (ids, sections, deep-link actions, the anchors a tip points +at, the plan capability a tip needs, the `inUse` relevance check); scenes live +in `src/components/tips/scenes-*.tsx` and are mapped in `scene-for.tsx`, icons +in `tip-icon.tsx`; the catalog dialog is `src/components/tips-dialog.tsx`; the +day's tip on the Profiles page is `src/components/tips/tip-coach-mark.tsx`; the flow (what to show when) is `src/hooks/use-tips.ts`. State (`tips_auto_show`, `tips_seen`, `tips_used`, `tips_inline`, `tips_last_auto_shown_at`, `tips_next_auto_show_at`, `paid_welcome_seen_for`, `cloud_plan_memory`) is in @@ -324,22 +325,41 @@ flow (what to show when) is `src/hooks/use-tips.ts`. State (`tips_auto_show`, `set_tips_auto_show` and `observe_cloud_plan` commands. - Tips never open a dialog by themselves. The catalog opens from the rail, - the shortcut, the command palette, the inline card's menu and the paid + the shortcut, the command palette, the coach mark's menu and the paid welcome. -- The inline tip is one slim card under the profile table: at most one new - tip a day, only on the Profiles page with at least one profile, after a - settled launch (onboarding done, terms accepted, never in the first-run - session), once the page has been quiet a few seconds with nothing modal - open. It takes its own row and never takes focus. The backend pins it - (`tips_inline`), so a restart shows the same tip until it is dismissed, - acted on or read in the catalog (`mark_tip_seen`). Closing it sets - `tips_next_auto_show_at` to the next local day, at least 12 hours later - (`next_inline_tip_at`). `tips_auto_show` is the "Show tips on the Profiles - page" switch; off removes the card unread and counts as the day's tip. - `save_app_settings` keeps the stored tips fields (`keep_tips_state`): only - the tips commands write them. The E2E harness seeds - `tips_auto_show: false`; a test that wants the inline tip passes +- The day's tip is a coach mark (`src/components/ui/coach-mark.tsx`) on the + feature itself: a ring drawn around it, a beacon on its corner, and a card + beside it with an arrow. Each tip lists `anchors`, best first: the feature + when it is on the Profiles page (a column header, the search box, the New + button, a row's info button), then the rail item that leads to it (the + Settings button for DNS settings, More for the trash). The first anchor on + screen wins; the last one is always on screen (`pnpm test:tips` checks it). + When the feature lives on another page, the card's button deep-links to it; + when it is on the page, the card has only "Got it". +- An anchor is any element with `data-tip-anchor=""`, spread with + `tipAnchor(id, side)` from `src/lib/coach-mark.ts`; the ids are the typed + `COACH_ANCHORS` list. `data-tip-side` names the side the card prefers (the + rail uses `right`). The coach mark follows its anchor through scrolling, + resizing and re-renders, flips to stay on screen, hides while a modal + surface is open, never takes the focus, describes the anchor with its text + (`aria-describedby`), and closes with Escape. Placement is the pure + `placeCoachMark` (tests in `src/lib/coach-mark.test.mjs`). +- One new tip a day, only on the Profiles page with at least one profile, + after a settled launch (onboarding done, terms accepted, never in the + first-run session), once the page has been quiet a few seconds with nothing + modal open. The backend pins it (`tips_inline`), so a restart shows the same + tip until it is dismissed ("Got it", Escape), acted on (its button, or a + click on the feature it points at) or read in the catalog (`mark_tip_seen`). + Closing it sets `tips_next_auto_show_at` to the next local day, at least 12 + hours later (`next_inline_tip_at`). `tips_auto_show` is the "Show tips on + the Profiles page" switch; off removes the mark unread and counts as the + day's tip. `save_app_settings` keeps the stored tips fields + (`keep_tips_state`): only the tips commands write them. The E2E harness seeds + `tips_auto_show: false`; a test that wants the day's tip passes `settings: { tips_auto_show: true }` or calls `set_tips_auto_show`. +- "Show me" in the catalog closes it and points the same coach mark at the + feature (`showOnPage`). The tip is read already, so closing it records + nothing and the day's tip, if one is pinned, waits behind it. - The pick is random among the unseen tips whose feature is not in use (`pickInlineTip`). Each tip's `inUse` reads a `FeatureUsage` snapshot: profile fields, groups, VPNs, extension groups, team, Cookie Bot @@ -350,7 +370,7 @@ flow (what to show when) is `src/hooks/use-tips.ts`. State (`tips_auto_show`, the running. Features that leave no trace (command palette, field search, shortcuts page, import page, 2FA tool, synchronizer) are noted as they are used (`note_tip_feature_used`, `tips_used`). An unknown fact never hides a - tip. A card whose feature comes into use leaves by itself; backend facts + tip. A mark whose feature comes into use leaves by itself; backend facts are read again when the window or the Profiles page comes back, at most once a minute. A launcher seat is not offered tips marked `editsTeamData`. A pinned tip the seat is no longer offered (its plan is gone) is released @@ -360,14 +380,29 @@ flow (what to show when) is `src/hooks/use-tips.ts`. State (`tips_auto_show`, grants the capability. The paid welcome opens once per account when the backend sees it turn paid (free -> paid, or a paid account first seen right after signing in); `paid_welcome_due` in `settings_manager.rs` is the rule. -- Adding a tip: append to `TIPS` with a `section` and an `inUse` check, write - the scene, map an icon, add `tips.items..{label,title,body,action}` to - every locale, and run `pnpm test:tips`, which checks every locale carries - every tip and section. +- Adding a tip: append to `TIPS` with a `section`, `anchors` and an `inUse` + check, write the scene, map an icon, add + `tips.items..{label,title,body,action}` to every locale, and run + `pnpm test:tips`, which checks every locale carries every tip and section + and every tip points somewhere. - Scenes are decorative and loop on their own clock (`useScene`); they show their resting frame under reduced motion and never hide the copy. The - inline card plays its scene for a few loops and then rests - (`ScenePlayback`), so the main page does not animate all day. + coach mark plays its scene for a few loops and then rests + (`ScenePlayback`), so the main page does not animate all day; a return to + the page shows the mark at rest (`settled`). + +## Onboarding + +A new install sees `src/components/welcome-dialog.tsx`: intro, the +profile-isolation example, licensing, macOS permissions, and the browser +setup, with `StepDots` (`ui/step-dots.tsx`) for progress and one drawing per +step (`src/components/onboarding/scenes.tsx`; the setup drawing follows the +real download). Steps move, they never fade in: text and controls are opaque +from the first frame. A user with no profile then gets the tour, +`src/components/onboarding-tour.tsx`: the same coach mark with `spotlight` +(the page dims around the anchor, and the anchor still takes clicks), first on +the New button until a profile exists, then on that profile's DNS control. +Skip or Escape completes onboarding; Finish also opens the thank-you dialog. ## Timelines (`OperationFlow`) diff --git a/e2e/tests/motion.test.mjs b/e2e/tests/motion.test.mjs index 6154d75c..f673dce2 100644 --- a/e2e/tests/motion.test.mjs +++ b/e2e/tests/motion.test.mjs @@ -223,26 +223,24 @@ test("first-run profile cutaway is interactive, isolated, and readable at both w async (app) => { await app.waitForText(en.welcome.title); await resize(app, 1080, 900); - await exerciseIsolation(app); - await app.capture("onboarding-isolation-wide"); - await resize(app, 640, 480); - await assertContained(app, '[role="dialog"]'); - await app.clickSelector(`${slot("welcome-features")} summary`); - await app.waitFor( - () => - app.execute( - `return document.querySelector('[data-slot="welcome-features"]')?.open === true;`, - ), - { - description: - "welcome feature disclosure to open from a real pointer click", - }, - ); + // The intro lists what the app does under its drawing. assert.equal( await app.visibleTextIncludes(en.welcome.features.items.cookies), true, ); - await app.clickSelector(`${slot("welcome-features")} summary`); + assert.equal( + await app.execute( + `return document.querySelector('[data-slot="welcome-scene-panel"] [data-slot="welcome-scene"]')?.dataset.scene;`, + ), + "intro", + ); + await app.capture("onboarding-intro-wide"); + await app.clickText(en.welcome.next, { roles: ["button"] }); + await app.waitForText(en.isolationDemo.title); + await exerciseIsolation(app); + await app.capture("onboarding-isolation-wide"); + await resize(app, 640, 480); + await assertContained(app, '[role="dialog"]'); await app.capture("onboarding-isolation-narrow"); await app.clickText(en.welcome.next, { roles: ["button"] }); await app.waitForText(en.welcome.license.title); diff --git a/e2e/tests/ui.test.mjs b/e2e/tests/ui.test.mjs index 577323e5..2b7f5ec2 100644 --- a/e2e/tests/ui.test.mjs +++ b/e2e/tests/ui.test.mjs @@ -2655,6 +2655,19 @@ test("first-run onboarding stays recoverable, responsive, and platform-aware", a assert.ok(layout.top >= 0 && layout.bottom <= layout.viewportHeight); assert.equal(layout.progressNow, "1"); + await app.clickText("Next", { roles: ["button"] }); + await app.waitForText(en.isolationDemo.title); + await app.clickText("Next", { roles: ["button"] }); + await app.waitForText("Licensing"); + // Back returns to the step before, and the dots follow. + await app.clickSelector('[data-slot="welcome-back"]'); + await app.waitForText(en.isolationDemo.title); + assert.equal( + await app.execute( + `return document.querySelector("[role='dialog'] [data-slot='step-dots']")?.getAttribute("aria-valuenow");`, + ), + "2", + ); await app.clickText("Next", { roles: ["button"] }); await app.waitForText("Licensing"); await app.clickText("I understand", { roles: ["button"] }); @@ -2680,6 +2693,131 @@ test("first-run onboarding stays recoverable, responsive, and platform-aware", a ); }); +/** The tour's coach mark: its step and anchor, once it is placed and shown. */ +async function tourState(app) { + return app.execute(` + const card = document.querySelector('[data-slot="onboarding-tour"]'); + if (!card || card.dataset.state !== "shown") return null; + if (getComputedStyle(card.parentElement).visibility !== "visible") return null; + return { + step: card.dataset.step, + anchor: card.dataset.anchor, + spotlight: Boolean(document.querySelector('[data-slot="coach-mark-spotlight"]')), + }; + `); +} + +test("a new install walks from the welcome into a tour on the real controls", async () => { + await withApp( + "ui-onboarding-tour", + async (app) => { + await app.waitForText(en.welcome.title); + await app.clickText(en.welcome.next, { roles: ["button"] }); + await app.waitForText(en.isolationDemo.title); + await app.clickText(en.welcome.next, { roles: ["button"] }); + await app.waitForText(en.welcome.license.title); + await app.clickText(en.welcome.license.agree, { roles: ["button"] }); + // macOS asks for the microphone and camera unless they are granted. + const next = await app.waitFor( + async () => { + const body = await app.bodyText(); + if (body.includes(en.welcome.permissions.title)) return "permissions"; + if (body.includes(en.welcome.ready.title)) return "setup"; + return null; + }, + { description: "the step after licensing" }, + ); + if (next === "permissions") { + await app.clickText(en.welcome.permissions.skip, { roles: ["button"] }); + } + // The browser is registered already, so setup is ready at once. + await app.waitForText(en.welcome.ready.cta); + await app.clickText(en.welcome.ready.cta, { roles: ["button"] }); + + // Step one dims the page around the New button and waits for a profile. + const first = await app.waitFor( + async () => { + const state = await tourState(app); + return state?.step === "0" ? state : null; + }, + { description: "the tour on the New button" }, + ); + assert.equal(first.anchor, "new-profile"); + assert.ok(first.spotlight, "the page is dimmed around the button"); + assert.equal( + await app.execute( + `return document.elementFromPoint(...(() => { const r = document.querySelector('[data-tip-anchor="new-profile"]').getBoundingClientRect(); return [r.left + r.width / 2, r.top + r.height / 2]; })())?.closest('[data-tip-anchor="new-profile"]') !== null;`, + ), + true, + "the spotlit button still takes the click", + ); + assert.equal(await app.invoke("get_onboarding_completed"), false); + await app.capture("onboarding-tour-create"); + + // A profile moves the tour on to that profile's DNS blocking. + await createUiProfile(app, "Tour Profile"); + const second = await app.waitFor( + async () => { + const state = await tourState(app); + return state?.step === "1" && state.anchor !== "none" ? state : null; + }, + { description: "the tour on the DNS control" }, + ); + assert.ok( + ["row-dns", "column-dns"].includes(second.anchor), + `the DNS step points at DNS blocking, not ${second.anchor}`, + ); + await app.capture("onboarding-tour-dns"); + + await app.clickSelector('[data-slot="onboarding-tour-finish"]'); + await app.waitForText(en.onboarding.thankYou.title); + await app.waitFor(() => app.invoke("get_onboarding_completed"), { + description: "onboarding completed by the finished tour", + }); + await app.waitFor( + () => + app.execute( + `return !document.querySelector('[data-slot="onboarding-tour"]');`, + ), + { description: "the tour gone after its exit" }, + ); + }, + { onboardingCompleted: false, seedDownloadedBrowser: true }, + ); +}); + +test("Escape skips the tour and still completes onboarding", async () => { + await withApp( + "ui-onboarding-tour-skip", + async (app) => { + await app.waitForText(en.welcome.title); + await app.clickText(en.welcome.skip, { roles: ["button"] }); + await app.waitForText(en.welcome.ready.cta); + await app.clickText(en.welcome.ready.cta, { roles: ["button"] }); + await app.waitFor(async () => (await tourState(app))?.step === "0", { + description: "the tour", + }); + await app.pressShortcut({ key: "Escape" }); + await app.waitFor( + () => + app.execute( + `return !document.querySelector('[data-slot="onboarding-tour"]');`, + ), + { description: "the tour gone" }, + ); + await app.waitFor(() => app.invoke("get_onboarding_completed"), { + description: "a skipped tour completes onboarding", + }); + assert.equal( + await app.visibleTextIncludes(en.onboarding.thankYou.title), + false, + "only a finished tour is celebrated", + ); + }, + { onboardingCompleted: false, seedDownloadedBrowser: true }, + ); +}); + test("interface sounds, their pack, volume and kinds stay as saved", async () => { await withApp("ui-sounds-setting", async (app) => { const sounds = async () => (await app.invoke("get_app_settings")).ui_sounds; @@ -3538,7 +3676,7 @@ test("a launcher seat opens the team's profiles but cannot change them, and an o ); assert.equal( await app.execute( - `return Boolean(document.querySelector('[data-onborda="create-profile"]'));`, + `return Boolean(document.querySelector('[data-tip-anchor="new-profile"]'));`, ), false, "a launcher is not offered a new profile", @@ -5100,29 +5238,58 @@ async function openTipsFromRail(app) { ); } -const INLINE_TIP = '[data-slot="inline-tip"]'; +const COACH_MARK = '[data-slot="tip-coach-mark"]'; -async function inlineTipId(app) { +/** The tip the coach mark shows, once it is placed and visible. */ +async function coachMarkTipId(app) { return app.execute( - `const card = document.querySelector(arguments[0]); return card?.dataset.state === "shown" ? card.dataset.tipId : null;`, - [INLINE_TIP], + `const card = document.querySelector(arguments[0]); + if (!card || card.dataset.state !== "shown") return null; + const shell = card.parentElement; + return getComputedStyle(shell).visibility === "visible" ? card.dataset.tipId : null;`, + [COACH_MARK], ); } -async function waitForInlineTip(app, id) { - await app.waitFor(async () => (await inlineTipId(app)) === id, { - description: `the inline ${id} tip`, +async function waitForCoachMark(app, id) { + await app.waitFor(async () => (await coachMarkTipId(app)) === id, { + description: `the ${id} coach mark`, timeoutMs: 30_000, }); } -async function waitForNoInlineTip(app) { +async function waitForNoCoachMark(app) { await app.waitFor( () => app.execute(`return !document.querySelector(arguments[0]);`, [ - INLINE_TIP, + COACH_MARK, ]), - { description: "the inline tip gone" }, + { description: "the coach mark gone" }, + ); +} + +/** + * Where the coach mark points: its anchor id, the anchor's box, the card's + * box, the side the card took, and whether the anchor is described by it. + */ +async function coachMarkGeometry(app) { + return app.execute( + `const card = document.querySelector(arguments[0]); + if (!card) return null; + const id = card.dataset.anchor; + const anchor = [...document.querySelectorAll('[data-tip-anchor="' + id + '"]')] + .find((node) => (node.getAttribute("aria-describedby") ?? "").split(" ").includes(card.getAttribute("aria-describedby"))); + const box = (node) => { const r = node.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom }; }; + return { + anchor: id, + side: card.dataset.side ?? null, + described: Boolean(anchor), + anchorBox: anchor ? box(anchor) : null, + cardBox: box(card), + beacon: Boolean(document.querySelector('[data-slot="coach-mark-beacon"]')), + viewport: { width: innerWidth, height: innerHeight }, + };`, + [COACH_MARK], ); } @@ -5290,7 +5457,7 @@ test("tips open from the rail, walk the catalog, and deep-link into the feature" }); }); -test("one tip a day sits on the Profiles page for an unused feature, and a dismissed one waits for tomorrow", async () => { +test("one tip a day points at an unused feature on the Profiles page, and a dismissed one waits for tomorrow", async () => { // Every tip but two is read already, and the only profile blocks ads, so // the DNS tip is in use and the trash tip is the one the page may show. // Tips are switched on only after that profile exists. @@ -5307,30 +5474,38 @@ test("one tip a day sits on the Profiles page for an unused feature, and a dismi await app.invoke("set_tips_auto_show", { enabled: true }); await app.restart(); - await waitForInlineTip(app, "trash"); + await waitForCoachMark(app, "trash"); assert.ok(await app.visibleTextIncludes(en.tips.items.trash.title)); assert.equal( await app.execute( `return Boolean(document.querySelector('[role="dialog"], [role="alertdialog"]'));`, ), false, - "the tip is part of the page, not a dialog", + "the tip is a coach mark on the page, not a dialog", ); assert.equal( await app.execute( `return document.querySelector(arguments[0]).contains(document.activeElement);`, - [INLINE_TIP], + [COACH_MARK], ), false, "the tip never takes the focus", ); - assert.equal( - await app.execute( - `const card = document.querySelector(arguments[0]).getBoundingClientRect(); const table = document.querySelector('[data-slot="profile-workspace"]').getBoundingClientRect(); return card.top >= table.bottom - 1;`, - [INLINE_TIP], - ), - true, - "the card sits under the table and covers no profile", + // The trash lives in the rail's More menu, so the mark points at the + // More button, with its card to the right of the rail. + const geometry = await coachMarkGeometry(app); + assert.equal(geometry.anchor, "rail-more"); + assert.equal(geometry.side, "right"); + assert.ok(geometry.described, "the anchor is described by the tip"); + assert.ok(geometry.beacon, "a beacon marks the feature"); + assert.ok( + geometry.cardBox.left >= geometry.anchorBox.right, + `the card sits beside its anchor: ${JSON.stringify(geometry)}`, + ); + assert.ok( + geometry.cardBox.top >= 0 && + geometry.cardBox.bottom <= geometry.viewport.height, + "the card stays on screen", ); const pinned = await app.invoke("get_tips_state"); assert.equal(pinned.inline_tip, "trash"); @@ -5339,14 +5514,14 @@ test("one tip a day sits on the Profiles page for an unused feature, and a dismi !pinned.seen.includes("trash") && !pinned.seen.includes("dnsBlocklist"), "showing a tip is not reading it, and the skipped tip stays unread", ); - await app.capture("tips-inline"); + await app.capture("tips-coach-mark"); // The day's tip comes back after a restart until it is closed. await app.restart(); - await waitForInlineTip(app, "trash"); + await waitForCoachMark(app, "trash"); - await app.clickSelector('[data-slot="inline-tip-dismiss"]'); - await waitForNoInlineTip(app); + await app.clickSelector('[data-slot="tip-coach-dismiss"]'); + await waitForNoCoachMark(app); const state = await app.waitFor( async () => { const current = await app.invoke("get_tips_state"); @@ -5369,7 +5544,7 @@ test("one tip a day sits on the Profiles page for an unused feature, and a dismi assert.equal( await app.execute( `return Boolean(document.querySelector(arguments[0]));`, - [INLINE_TIP], + [COACH_MARK], ), false, ); @@ -5391,16 +5566,16 @@ function pinnedTip(id) { }; } -test("the inline tip's action opens its feature and closes the tip as read", async () => { +test("the coach mark's action opens its feature and closes the tip as read", async () => { await withApp( "ui-tips-inline-action", async (app) => { await createUiProfile(app, "Tips Action"); await app.waitForText("Tips Action"); - await waitForInlineTip(app, "trash"); + await waitForCoachMark(app, "trash"); assert.ok(await app.visibleTextIncludes(en.tips.items.trash.title)); - await app.clickSelector('[data-slot="inline-tip-action"]'); + await app.clickSelector('[data-slot="tip-coach-action"]'); await app.waitFor( () => app.execute( @@ -5422,18 +5597,19 @@ test("the inline tip's action opens its feature and closes the tip as read", asy ); }); -test("turning tips off from the card takes it away, unread, for the day", async () => { +test("turning tips off from the coach mark takes it away, unread, for the day", async () => { await withApp( "ui-tips-inline-off", async (app) => { await createUiProfile(app, "Tips Off"); await app.waitForText("Tips Off"); - await waitForInlineTip(app, "vpn"); + await waitForCoachMark(app, "vpn"); + assert.equal((await coachMarkGeometry(app)).anchor, "rail-proxies"); - await app.clickSelector('[data-slot="inline-tip-menu"]'); - await app.clickSelector('[data-slot="inline-tip-off"]'); - await waitForNoInlineTip(app); - assert.ok(await app.visibleTextIncludes(en.tips.inline.offTitle)); + await app.clickSelector('[data-slot="tip-coach-menu"]'); + await app.clickSelector('[data-slot="tip-coach-off"]'); + await waitForNoCoachMark(app); + assert.ok(await app.visibleTextIncludes(en.tips.coach.offTitle)); const off = await app.invoke("get_tips_state"); assert.equal(off.auto_show, false); assert.equal(off.inline_tip, null); @@ -5467,7 +5643,7 @@ test("turning tips off from the card takes it away, unread, for the day", async assert.equal( await app.execute( `return Boolean(document.querySelector(arguments[0]));`, - [INLINE_TIP], + [COACH_MARK], ), false, ); @@ -5477,6 +5653,125 @@ test("turning tips off from the card takes it away, unread, for the day", async ); }); +test("a tip for a feature on the page points at it, and using the feature or Escape closes it as read", async () => { + await withApp( + "ui-tips-coach-anchor", + async (app) => { + await createUiProfile(app, "Tips Anchor"); + await app.waitForText("Tips Anchor"); + await waitForCoachMark(app, "dnsBlocklist"); + // DNS blocking is a column on this page: the mark points at its header, + // under it, and the action still goes to the DNS settings. + const geometry = await coachMarkGeometry(app); + assert.equal(geometry.anchor, "column-dns"); + assert.equal(geometry.side, "bottom"); + assert.ok(geometry.cardBox.top >= geometry.anchorBox.bottom); + assert.ok( + await app.execute( + `return Boolean(document.querySelector('[data-slot="tip-coach-action"]'));`, + ), + ); + await app.capture("tips-coach-mark-column"); + + // It follows its anchor: a narrower window moves the card with it. + const before = geometry.cardBox.left; + const rect = await app.session.command("GET", "/window/rect"); + await app.session.command("POST", "/window/rect", { + width: rect.width + 120, + height: rect.height, + }); + await app.waitFor( + async () => { + const moved = await coachMarkGeometry(app); + return ( + moved?.anchor === "column-dns" && + Math.abs(moved.cardBox.left - before) > 4 && + moved.cardBox.top >= moved.anchorBox.bottom + ); + }, + { description: "the card following its anchor" }, + ); + await app.session.command("POST", "/window/rect", rect); + + // Escape closes it as read, like Got it. + await app.pressShortcut({ key: "Escape" }); + await waitForNoCoachMark(app); + const closed = await app.waitFor( + async () => { + const current = await app.invoke("get_tips_state"); + return current.seen.includes("dnsBlocklist") ? current : null; + }, + { description: "the tip closed by Escape recorded" }, + ); + assert.equal(closed.inline_tip, null); + }, + { settings: pinnedTip("dnsBlocklist") }, + ); +}); + +test("clicking the feature a coach mark points at closes the tip as read", async () => { + await withApp( + "ui-tips-coach-activate", + async (app) => { + await createUiProfile(app, "Tips Activate"); + await app.waitForText("Tips Activate"); + await waitForCoachMark(app, "searchSyntax"); + assert.equal((await coachMarkGeometry(app)).anchor, "profile-search"); + await app.clickSelector('[data-tip-anchor="profile-search"] input'); + await waitForNoCoachMark(app); + await app.waitFor( + async () => + (await app.invoke("get_tips_state")).seen.includes("searchSyntax"), + { description: "the used feature closes its tip as read" }, + ); + }, + { settings: pinnedTip("searchSyntax") }, + ); +}); + +test("Show me in the catalog points the tip at its feature without spending the day's tip", async () => { + await withApp("ui-tips-show-me", async (app) => { + await createUiProfile(app, "Tips Show Me"); + await app.waitForText("Tips Show Me"); + await openTipsFromRail(app); + await app.clickSelector( + '[data-slot="tips-list-item"][data-tip-id="proxyCheck"]', + ); + await app.clickSelector('[data-slot="tip-show-me"]'); + await app.waitFor( + () => + app.execute(`return !document.querySelector(arguments[0]);`, [ + TIPS_DIALOG, + ]), + { description: "the catalog closed" }, + ); + await waitForCoachMark(app, "proxyCheck"); + const geometry = await coachMarkGeometry(app); + assert.equal(geometry.anchor, "column-proxy"); + assert.equal( + await app.execute( + `return document.querySelector(arguments[0]).dataset.preview;`, + [COACH_MARK], + ), + "true", + ); + assert.equal( + await app.execute( + `return Boolean(document.querySelector('[data-slot="tip-coach-off"]'));`, + ), + false, + "a tip asked for is not the place to turn tips off", + ); + const before = await app.invoke("get_tips_state"); + await app.capture("tips-show-me"); + await app.clickSelector('[data-slot="tip-coach-dismiss"]'); + await waitForNoCoachMark(app); + const after = await app.invoke("get_tips_state"); + assert.equal(after.inline_tip, before.inline_tip); + assert.equal(after.next_auto_show_at, before.next_auto_show_at); + }); +}); + test("a freshly paid account is welcomed once and walked to its plan tips", async () => { await withApp("ui-paid-welcome", async (app) => { await app.waitForText("No profiles yet"); diff --git a/package.json b/package.json index a6a8480f..555da93c 100644 --- a/package.json +++ b/package.json @@ -12,7 +12,7 @@ "start": "next start", "test": "pnpm test:themes && pnpm test:tips && pnpm test:window-decorations && pnpm test:cookie-bot-limits && pnpm test:cookie-bot-outcomes && pnpm test:recipes && pnpm test:agent-console && pnpm test:api-examples && pnpm test:backend-errors && pnpm test:i18n-parity && pnpm test:proxy-string && pnpm test:proxy-type && pnpm test:proxy-first-hop-claims && pnpm test:profile-search && pnpm test:palette-search && pnpm test:ui-sounds && pnpm test:schedules && pnpm test:team-activity && pnpm test:table-tools && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e", "test:themes": "node --test src/lib/themes.test.mjs", - "test:tips": "node --test src/lib/tips.test.mjs", + "test:tips": "node --test src/lib/tips.test.mjs src/lib/coach-mark.test.mjs", "test:window-decorations": "node --test src/lib/window-decorations.test.mjs", "test:cookie-bot-limits": "node --test src/lib/cookie-bot-limits.test.mjs src/lib/schedule-layout.test.mjs", "test:cookie-bot-outcomes": "node --test src/lib/cookie-bot-outcomes.test.mjs", @@ -102,7 +102,6 @@ "motion": "^14.0.0", "next": "^16.4.0", "next-themes": "^0.4.6", - "onborda": "^1.2.5", "radix-ui": "^1.7.0", "react": "^19.3.0", "react-dom": "^19.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5b3ed338..5f55221c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -285,9 +285,6 @@ importers: next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - onborda: - specifier: ^1.2.5 - version: 1.2.5(@radix-ui/react-portal@1.1.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(framer-motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(next@16.4.0(@babel/core@8.0.1)(@types/node@26.6.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0) radix-ui: specifier: ^1.7.0 version: 1.7.0(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) @@ -4417,15 +4414,6 @@ packages: resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} engines: {node: '>= 0.8'} - onborda@1.2.5: - resolution: {integrity: sha512-S9EtQpKr8oYz7j0Bmr0w7BdG4Q4ud6QuNxBsSShzcf9khhuLEEjkbhYYMmdMlVa56QK/rXW/9pc8JJvBXUhOeA==} - peerDependencies: - '@radix-ui/react-portal': '>=1.1.1' - framer-motion: '>=11' - next: '>=13' - react: '>=18' - react-dom: '>=18' - once@1.4.0: resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} @@ -9374,14 +9362,6 @@ snapshots: dependencies: ee-first: 1.1.1 - onborda@1.2.5(@radix-ui/react-portal@1.1.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(framer-motion@14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(next@16.4.0(@babel/core@8.0.1)(@types/node@26.6.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0))(react-dom@19.3.0(react@19.3.0))(react@19.3.0): - dependencies: - '@radix-ui/react-portal': 1.1.18(@types/react-dom@19.3.0(@types/react@19.3.0))(@types/react@19.3.0)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - framer-motion: 14.0.0(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - next: 16.4.0(@babel/core@8.0.1)(@types/node@26.6.4)(react-dom@19.3.0(react@19.3.0))(react@19.3.0) - react: 19.3.0 - react-dom: 19.3.0(react@19.3.0) - once@1.4.0: dependencies: wrappy: 1.0.2 diff --git a/src/app/page.tsx b/src/app/page.tsx index 041bcf33..e91fdb95 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core"; import { listen } from "@tauri-apps/api/event"; import { getCurrent } from "@tauri-apps/plugin-deep-link"; import { useReducedMotion } from "motion/react"; -import { useOnborda } from "onborda"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { AboutDialog } from "@/components/about-dialog"; @@ -28,7 +27,7 @@ import { GroupManagementDialog } from "@/components/group-management-dialog"; import HomeHeader from "@/components/home-header"; import { ImportProfileDialog } from "@/components/import-profile-dialog"; import { IntegrationsDialog } from "@/components/integrations-dialog"; -import { ONBOARDING_TOUR } from "@/components/onboarding-provider"; +import { OnboardingTour, type TourStep } from "@/components/onboarding-tour"; import { PaidWelcomeDialog } from "@/components/paid-welcome-dialog"; import { PermissionDialog } from "@/components/permission-dialog"; import { @@ -56,7 +55,7 @@ import { SyncFollowerDialog } from "@/components/sync-follower-dialog"; import { SynchronizerPanel } from "@/components/synchronizer-panel"; import { TableFilterControls } from "@/components/table-controls"; import { ThankYouDialog } from "@/components/thank-you-dialog"; -import { InlineTip } from "@/components/tips/inline-tip"; +import { TipCoachMark } from "@/components/tips/tip-coach-mark"; import { TipsDialog } from "@/components/tips-dialog"; import { TotpToolDialog } from "@/components/totp-code"; import { TrashPage } from "@/components/trash-page"; @@ -91,11 +90,7 @@ import { getEntitlements, isTeamLauncher, } from "@/lib/entitlements"; -import { - ONBOARDING_TOUR_CLOSED_EVENT, - ONBOARDING_TOUR_FINISHED_EVENT, - setOnboardingActive, -} from "@/lib/onboarding-signal"; +import { setOnboardingActive } from "@/lib/onboarding-signal"; import { type ProfileSearchContext } from "@/lib/profile-search"; import { matchesGroupDigit, @@ -175,9 +170,11 @@ export default function Home() { loadProfiles, } = useProfileEvents(); - // First-run onboarding tour (Onborda). - const { startOnborda, setCurrentStep, isOnbordaVisible, currentStep } = - useOnborda(); + // First-run product tour: coach marks on the real controls. + const [tour, setTour] = useState<{ open: boolean; step: TourStep }>({ + open: false, + step: 0, + }); const onboardingHandledRef = useRef(false); const [welcomeOpen, setWelcomeOpen] = useState(false); const [thankYouOpen, setThankYouOpen] = useState(false); @@ -200,50 +197,29 @@ export default function Home() { const handleWelcomeComplete = useCallback(() => { setWelcomeOpen(false); if (profiles.length === 0) { - startOnborda(ONBOARDING_TOUR); + setTour({ open: true, step: 0 }); } else { persistOnboardingComplete(); } - }, [persistOnboardingComplete, profiles.length, startOnborda]); + }, [persistOnboardingComplete, profiles.length]); - // Finishing or explicitly skipping the product tour completes the one-shot - // onboarding. Only reaching the end triggers the celebration. - useEffect(() => { - const handleClosed = () => persistOnboardingComplete(); - const handleFinished = () => setThankYouOpen(true); - window.addEventListener(ONBOARDING_TOUR_CLOSED_EVENT, handleClosed); - window.addEventListener(ONBOARDING_TOUR_FINISHED_EVENT, handleFinished); - return () => { - window.removeEventListener(ONBOARDING_TOUR_CLOSED_EVENT, handleClosed); - window.removeEventListener( - ONBOARDING_TOUR_FINISHED_EVENT, - handleFinished, - ); - }; + // Finishing or skipping the product tour completes the one-shot + // onboarding. Only reaching the end earns the celebration. + const skipTour = useCallback(() => { + setTour((current) => ({ ...current, open: false })); + persistOnboardingComplete(); + }, [persistOnboardingComplete]); + const finishTour = useCallback(() => { + setTour((current) => ({ ...current, open: false })); + persistOnboardingComplete(); + setThankYouOpen(true); }, [persistOnboardingComplete]); // Suppress the global browser-download toasts while onboarding (welcome or // tour) is active — the welcome dialog shows setup progress itself. useEffect(() => { - setOnboardingActive(welcomeOpen || isOnbordaVisible); - }, [welcomeOpen, isOnbordaVisible]); - - // While the tour is visible, keep the body pinned to the left. Onborda calls - // scrollIntoView({ inline: "center" }) on the highlighted element; because the - // body is overflow-hidden it can still be scrolled programmatically, which - // would shove the whole app (rail and all) sideways with no way to scroll - // back. The profile table keeps its own scroll container, untouched here. - useEffect(() => { - if (!isOnbordaVisible) return; - const pin = () => { - if (document.body.scrollLeft !== 0) document.body.scrollLeft = 0; - if (document.documentElement.scrollLeft !== 0) - document.documentElement.scrollLeft = 0; - }; - pin(); - window.addEventListener("scroll", pin, true); - return () => window.removeEventListener("scroll", pin, true); - }, [isOnbordaVisible]); + setOnboardingActive(welcomeOpen || tour.open); + }, [welcomeOpen, tour.open]); // On the very first launch, always show the welcome + commercial-use steps. // The completion flag is only persisted after the user finishes or skips the @@ -269,18 +245,14 @@ export default function Home() { })(); }, [profilesLoading]); - // Advance from the "create a profile" step to the "DNS blocking" step as soon - // as the user's first profile exists (its DNS dropdown is now in the DOM). + // The "create a profile" step moves on to "DNS blocking" as soon as the + // first profile exists. The coach mark finds the new row's DNS control by + // itself once the row renders. useEffect(() => { - if (isOnbordaVisible && currentStep === 0 && profiles.length > 0) { - // Small delay so the new profile row (and its DNS dropdown target) has - // mounted before Onborda re-points at it. Owning the timeout here lets - // cleanup cancel it if the tour closes, instead of reopening a dismissed - // tour when Onborda's delayed setter eventually fires. - const timeout = window.setTimeout(() => setCurrentStep(1), 300); - return () => window.clearTimeout(timeout); + if (tour.open && tour.step === 0 && profiles.length > 0) { + setTour({ open: true, step: 1 }); } - }, [isOnbordaVisible, currentStep, profiles.length, setCurrentStep]); + }, [tour.open, tour.step, profiles.length]); const { groups: groupsData, @@ -436,7 +408,7 @@ export default function Home() { !profilesLoading && !welcomeOpen && !thankYouOpen && - !isOnbordaVisible && + !tour.open && !termsLoading && termsAccepted === true && !commercialTrialModalOpen; @@ -2514,7 +2486,7 @@ export default function Home() { handleRailNavigate("import"); }} /> - + setThankYouOpen(false)} @@ -2793,6 +2771,10 @@ export default function Home() { onTipShown={tipsFlow.markSeen} onAutoShowChange={(enabled) => void tipsFlow.setAutoShow(enabled)} onAction={runTipAction} + onShowMe={(id) => { + handleRailNavigate("profiles"); + tipsFlow.showOnPage(id); + }} /> - - {children} - + {children} diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index 9e41046b..f5a3a34f 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -6,6 +6,7 @@ import { useTranslation } from "react-i18next"; import { GoPlus } from "react-icons/go"; import { useWindowDecorations } from "@/hooks/use-window-decorations"; import { getCurrentOS } from "@/lib/browser-utils"; +import { tipAnchor } from "@/lib/coach-mark"; import type { ProfileSearchContext } from "@/lib/profile-search"; import { cn } from "@/lib/utils"; import type { BrowserProfile, GroupWithCount } from "@/types"; @@ -233,6 +234,7 @@ const HomeHeader = ({ - )} - -
- {!isFirst && !isLast && ( - - )} - {isLast ? ( - - ) : requiresAction ? null : ( - - )} -
- - - {arrow} - - ); -} diff --git a/src/components/onboarding-provider.tsx b/src/components/onboarding-provider.tsx deleted file mode 100644 index fc47463e..00000000 --- a/src/components/onboarding-provider.tsx +++ /dev/null @@ -1,67 +0,0 @@ -"use client"; - -import { Onborda, type OnbordaProps, OnbordaProvider } from "onborda"; -import { useTranslation } from "react-i18next"; -import { OnboardingCard } from "@/components/onboarding-card"; - -// Name of the first-run product tour. Referenced by the trigger logic in -// `src/app/page.tsx` via `startOnborda(ONBOARDING_TOUR)`. -export const ONBOARDING_TOUR = "donut-onboarding"; - -export function OnboardingProvider({ - children, -}: { - children: React.ReactNode; -}) { - const { t } = useTranslation(); - - const tours: OnbordaProps["steps"] = [ - { - tour: ONBOARDING_TOUR, - steps: [ - { - icon: null, - title: t("onboarding.steps.createProfile.title"), - content: t("onboarding.steps.createProfile.content"), - selector: '[data-onborda="create-profile"]', - // The "New" button sits in the top-right corner; "bottom-right" - // anchors the card's right edge to it so the card extends left/down - // and stays on-screen instead of overflowing the right viewport edge. - side: "bottom-right", - showControls: true, - pointerPadding: 8, - pointerRadius: 10, - }, - { - icon: null, - title: t("onboarding.steps.dnsBlocking.title"), - content: t("onboarding.steps.dnsBlocking.content"), - selector: '[data-onborda="dns-blocklist"]', - // The DNS dropdown sits in the right-hand columns. A centered "bottom" - // card runs off the right edge; "bottom-right" anchors the card's right - // edge to the dropdown and extends it left/down, keeping it fully - // on-screen with its arrow pointing up at the option. - side: "bottom-right", - showControls: true, - pointerPadding: 6, - pointerRadius: 8, - }, - ], - }, - ]; - - return ( - - - {children} - - - ); -} diff --git a/src/components/onboarding-tour.tsx b/src/components/onboarding-tour.tsx new file mode 100644 index 00000000..3750eab8 --- /dev/null +++ b/src/components/onboarding-tour.tsx @@ -0,0 +1,126 @@ +"use client"; + +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { NewProfileScene } from "@/components/onboarding/scenes"; +import { DnsScene } from "@/components/tips/scenes-essentials"; +import { Button } from "@/components/ui/button"; +import { + CoachMark, + CoachMarkBody, + CoachMarkFooter, + CoachMarkHeader, + CoachMarkScene, +} from "@/components/ui/coach-mark"; +import { StepDots } from "@/components/ui/step-dots"; +import { StepTransition } from "@/components/ui/step-transition"; +import { COACH_MARK_EXIT_MS, type CoachAnchorId } from "@/lib/coach-mark"; + +export type TourStep = 0 | 1; + +const STEPS: readonly { + key: "createProfile" | "dnsBlocking"; + anchors: readonly CoachAnchorId[]; +}[] = [ + { key: "createProfile", anchors: ["new-profile"] }, + { key: "dnsBlocking", anchors: ["row-dns", "column-dns"] }, +]; + +/** + * The first-run tour: the page dims around one feature at a time and a coach + * mark explains it. The first step waits for the person to create a profile + * with the button it points at; the second shows that profile's DNS + * blocking and finishes. Skip and Escape end it at any step. + */ +export function OnboardingTour({ + open, + step, + onSkip, + onFinish, +}: { + open: boolean; + step: TourStep; + onSkip: () => void; + onFinish: () => void; +}) { + const { t } = useTranslation(); + const [mounted, setMounted] = useState(open); + + useEffect(() => { + if (open) { + setMounted(true); + return; + } + const timer = window.setTimeout( + () => setMounted(false), + COACH_MARK_EXIT_MS, + ); + return () => window.clearTimeout(timer); + }, [open]); + + if (!mounted) return null; + const current = STEPS[step]; + const last = step === STEPS.length - 1; + + return ( + + +
+ {step === 0 ? : } +
+
+ + + + {t(`onboarding.steps.${current.key}.content`)} + + + + + {last ? ( + + ) : ( + + )} + +
+ ); +} diff --git a/src/components/onboarding/scenes.tsx b/src/components/onboarding/scenes.tsx new file mode 100644 index 00000000..1f748c41 --- /dev/null +++ b/src/components/onboarding/scenes.tsx @@ -0,0 +1,927 @@ +"use client"; + +import { motion, type Transition, useReducedMotion } from "motion/react"; +import type { ReactNode } from "react"; +import { Logo } from "@/components/icons/logo"; +import { + bezier, + Check, + Cursor, + Globe, + type Point, + travel, +} from "@/components/tips/scene-primitives"; +import { MOTION_EASE_OUT } from "@/lib/motion"; +import { cn } from "@/lib/utils"; + +/** + * The drawings of the first-run flow, one per step. They are decorative: the + * text beside them says everything. Each one builds itself once (lines draw, + * parts settle in order), loops a quiet detail a few times, and then rests on + * its finished frame. Reduced motion shows the finished frame at once. + */ + +const WIDE_W = 480; +const WIDE_H = 160; + +/** How many times an ambient detail plays before the drawing rests. */ +const AMBIENT_REPEATS = 4; + +function useStage() { + const reduce = useReducedMotion() ?? false; + /** A one-shot entrance that starts `delay` seconds in. */ + const enter = (delay: number, duration = 0.7): Transition => + reduce ? { duration: 0 } : { delay, duration, ease: MOTION_EASE_OUT }; + const pop = (delay: number): Transition => + reduce + ? { duration: 0 } + : { delay, type: "spring", stiffness: 420, damping: 22, mass: 0.6 }; + /** A detail that repeats a few times, then rests on its last keyframe. */ + const ambient = ( + duration: number, + delay: number, + times: number[], + extra?: Transition, + ): Transition => + reduce + ? { duration: 0 } + : { + duration, + delay, + times, + repeat: AMBIENT_REPEATS, + ease: "easeInOut", + ...extra, + }; + /** The resting value under reduced motion, every keyframe otherwise. */ + const kf = (values: T[]): T | T[] => + reduce ? (values[values.length - 1] as T) : values; + return { reduce, enter, pop, ambient, kf }; +} + +function WideScene({ + children, + className, + slot, +}: { + children: ReactNode; + className?: string; + slot: string; +}) { + return ( + + ); +} + +/** Three concentric arcs, a fingerprint at a glance; `turn` makes each one its own. */ +function Fingerprint({ + cx, + cy, + turn, + transition, + drawn = true, +}: { + cx: number; + cy: number; + turn: number; + transition: Transition; + drawn?: boolean; +}) { + return ( + + {[3, 6, 9].map((r, index) => ( + + ))} + + ); +} + +const DONUT = { x: 104, y: 80 }; +const WINDOWS = [ + { y: 14, turn: 0, pin: { x: -3, y: -2 } }, + { y: 62, turn: 70, pin: { x: 3, y: 1 } }, + { y: 110, turn: 150, pin: { x: -1, y: 4 } }, +]; +const WINDOW_X = 262; +const WINDOW_W = 186; +const WINDOW_H = 36; + +function wire(y: number): [Point, Point, Point, Point] { + return [ + { x: DONUT.x + 44, y: DONUT.y }, + { x: 200, y: DONUT.y }, + { x: 214, y: y + WINDOW_H / 2 }, + { x: WINDOW_X, y: y + WINDOW_H / 2 }, + ]; +} + +/** + * One app, many browsers: wires run from the donut to three profile windows, + * each window gets its own fingerprint and its own place on the map, and a + * signal travels each wire on its own. + */ +export function IntroScene() { + const { reduce, enter, pop, ambient, kf } = useStage(); + return ( + + + + + + {WINDOWS.map((profile, index) => { + const points = wire(profile.y); + const path = `M${points[0].x} ${points[0].y} C${points[1].x} ${points[1].y} ${points[2].x} ${points[2].y} ${points[3].x} ${points[3].y}`; + const signal = travel(bezier(...points, 14), 0.08, 0.46); + const start = 0.55 + index * 0.14; + return ( + + + + + {[0, 1, 2].map((dot) => ( + + ))} + + + + + + + + + + + {!reduce && ( + (t > 0.06 && t < 0.48 ? 1 : 0)), + ), + }} + transition={ambient(3.4, 2 + index * 0.5, signal.times, { + ease: "linear", + repeatDelay: 0.6, + })} + /> + )} + + ); + })} + + ); +} + +const LEMNISCATE = [ + ...bezier( + { x: 130, y: 96 }, + { x: 150, y: 116 }, + { x: 180, y: 116 }, + { x: 180, y: 96 }, + 8, + ), + ...bezier( + { x: 180, y: 96 }, + { x: 180, y: 76 }, + { x: 150, y: 76 }, + { x: 130, y: 96 }, + 8, + ).slice(1), + ...bezier( + { x: 130, y: 96 }, + { x: 110, y: 116 }, + { x: 80, y: 116 }, + { x: 80, y: 96 }, + 8, + ).slice(1), + ...bezier( + { x: 80, y: 96 }, + { x: 80, y: 76 }, + { x: 110, y: 76 }, + { x: 130, y: 96 }, + 8, + ).slice(1), +]; +const LOOP = travel(LEMNISCATE, 0, 1); +const CALENDAR = Array.from({ length: 14 }, (_, day) => ({ + day, + x: 290 + (day % 7) * 20, + y: 78 + Math.floor(day / 7) * 20, +})); + +/** + * Personal use runs on forever, a signal circling an infinity loop; the + * commercial trial fills its two weeks day by day and ends on a check. + */ +export function LicenseScene() { + const { reduce, enter, ambient, kf } = useStage(); + return ( + + + + + + + + {!reduce && ( + + )} + + + + + + + + + + + {CALENDAR.map(({ day, x, y }) => { + const at = 0.04 + day * 0.048; + return ( + + + + + ); + })} + + + + + + + ); +} + +/** + * A site asks for the microphone and the camera; a click allows it and both + * come alive. The loop starts and ends on the allowed frame, so it can rest + * there and never jumps between rounds. + */ +export function PermissionsScene() { + const { reduce, enter, ambient, kf } = useStage(); + const cycle = (times: number[]) => + ambient(5.6, 1, times, { repeatDelay: 0.8 }); + const CLICK = [0, 0.1, 0.12, 0.22, 0.34, 0.4, 0.46, 1]; + return ( + + + + + {[0, 1, 2].map((dot) => ( + + ))} + + + + + + + + + + + + + + + + + + + {[0, 1, 2].map((wave) => ( + + ))} + + + + + ); +} + +const PACKET_PATH = bezier( + { x: 132, y: 72 }, + { x: 168, y: 52 }, + { x: 186, y: 62 }, + { x: 206, y: 74 }, + 10, +); +const PACKET = travel(PACKET_PATH, 0, 1); + +/** + * The browser download, drawn from the real progress: packets travel from + * the cloud into a ring that fills with the download; unpacking draws the + * globe in; when it is ready the ring closes, a check lands and the first + * profile's window slides out. + */ +export function SetupScene({ + phase, + progress, +}: { + phase: "downloading" | "extracting" | "ready" | "error"; + /** 0 to 100, for the current phase. */ + progress: number; +}) { + const { reduce, enter, pop } = useStage(); + const ready = phase === "ready"; + const failed = phase === "error"; + const fill = + phase === "ready" + ? 1 + : phase === "downloading" + ? Math.max(0.03, progress / 100) + : phase === "extracting" + ? 1 + : 0.25; + const globe = ready + ? 1 + : phase === "extracting" + ? Math.max(0.1, progress / 100) + : 0.15; + const settle: Transition = reduce + ? { duration: 0 } + : { type: "spring", stiffness: 120, damping: 24 }; + return ( + + + + + + {phase === "downloading" && + !reduce && + [0, 1, 2].map((packet) => ( + (t < 0.08 || t > 0.94 ? 0 : 1)), + }} + transition={{ + duration: 1.5, + times: PACKET.times, + ease: "linear", + repeat: Number.POSITIVE_INFINITY, + delay: packet * 0.5, + }} + /> + ))} + + + + + {phase === "extracting" && !reduce && ( + + )} + + + + + + + {ready && ( + <> + {Array.from({ length: 8 }, (_, ray) => { + const angle = (ray * Math.PI) / 4; + const x1 = 240 + Math.cos(angle) * 50; + const y1 = 80 + Math.sin(angle) * 50; + const x2 = 240 + Math.cos(angle) * 60; + const y2 = 80 + Math.sin(angle) * 60; + return ( + + ); + })} + + + + + + + + + + + + + + + + + + )} + + ); +} + +/** + * A click on New and a fresh profile slides out, with its own fingerprint. + * Each round starts and ends on the finished frame. + */ +export function NewProfileScene() { + const { reduce, enter, ambient, kf } = useStage(); + const cycle = (times: number[]) => + ambient(4.8, 0.4, times, { repeatDelay: 0.8 }); + return ( + + ); +} diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index b2e9d16b..aa436833 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -139,6 +139,7 @@ import { getProfileIcon, isCrossOsProfile, } from "@/lib/browser-utils"; +import { type CoachAnchorId, tipAnchor } from "@/lib/coach-mark"; import { type CookieBotSchedule, cancelCookieBotRun, @@ -612,6 +613,19 @@ function ExtCell({ ); } +/** The column headers a feature tip can point at. */ +const COLUMN_ANCHORS: Partial> = { + state: "column-state", + proxy: "column-proxy", + ext: "column-ext", + dns: "column-dns", +}; + +function columnAnchor(columnId: string) { + const anchor = COLUMN_ANCHORS[columnId]; + return anchor ? tipAnchor(anchor) : {}; +} + // Inline DNS blocklist dropdown — same Popover/Command pattern as Ext. function DnsCell({ profile, @@ -649,7 +663,7 @@ function DnsCell({ ); @@ -286,11 +310,11 @@ export function TipsDialog({ data-tip-id={tip.id} className="flex min-h-0 min-w-0 flex-col overflow-y-auto" > -
+
@@ -298,14 +322,20 @@ export function TipsDialog({ @@ -322,7 +352,7 @@ export function TipsDialog({

-

+
+ - )} - - - - - - flow.openTips(tip.id)} - > - {t("tips.inline.allTips")} - - { - void flow.setAutoShow(false).then((saved) => { - if (!saved) return; - showSuccessToast(t("tips.inline.offTitle"), { - description: t("tips.inline.offBody", { - shortcut: tipsShortcut(), - }), - sound: null, - }); - }); - }} - > - {t("tips.inline.turnOff")} - - - - -
-
- - ); -} diff --git a/src/components/tips/tip-coach-mark.tsx b/src/components/tips/tip-coach-mark.tsx new file mode 100644 index 00000000..f456173f --- /dev/null +++ b/src/components/tips/tip-coach-mark.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { LuEllipsis } from "react-icons/lu"; +import type { AppPage } from "@/components/rail-nav"; +import { TipScene } from "@/components/tips/scene-for"; +import { ScenePlayback } from "@/components/tips/scene-primitives"; +import { TipIcon } from "@/components/tips/tip-icon"; +import { Button } from "@/components/ui/button"; +import { + CoachMark, + CoachMarkBody, + CoachMarkFooter, + CoachMarkHeader, + CoachMarkScene, +} from "@/components/ui/coach-mark"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import type { TipsFlow } from "@/hooks/use-tips"; +import { isMacOS } from "@/lib/platform"; +import { formatShortcut, SHORTCUTS } from "@/lib/shortcuts"; +import { actionStaysOn, type TipAction, tipTextKeys } from "@/lib/tips"; +import { showSuccessToast } from "@/lib/toast-utils"; + +/** Tips introduced this session; coming back to the page does not replay the entrance. */ +const introduced = new Set(); +/** The drawing plays this long, then rests until the pointer comes over it. */ +const SCENE_PLAY_MS = 12_000; + +function tipsShortcut(): string { + const shortcut = SHORTCUTS.find((item) => item.id === "openTips"); + return shortcut ? formatShortcut(shortcut).join(isMacOS() ? "" : "+") : ""; +} + +/** + * The day's tip, pointed out on the feature itself: a coach mark on the + * column, button or rail item where the feature lives. When the feature is on + * another page, the mark sits on the rail item that leads there and its + * button goes straight to it. Using the feature, "Got it" and Escape all + * close the tip as read; the next one waits for tomorrow. + * + * The same mark shows a tip picked in the catalog with "Show me". + */ +export function TipCoachMark({ + flow, + page, + onAction, +}: { + flow: TipsFlow; + page: AppPage; + onAction: (action: TipAction) => void; +}) { + const { t } = useTranslation(); + const current = flow.pageTip; + const id = current?.tip.id; + // Read before the effect below records the tip, so only a return visit + // comes in settled. + const settled = useMemo(() => (id ? introduced.has(id) : false), [id]); + const [intro, setIntro] = useState(true); + const [hovered, setHovered] = useState(false); + + useEffect(() => { + if (!id) return; + const first = !introduced.has(id); + introduced.add(id); + setIntro(first); + if (!first) return; + const timer = window.setTimeout(() => setIntro(false), SCENE_PLAY_MS); + return () => window.clearTimeout(timer); + }, [id]); + + if (!current || page !== "profiles") return null; + const { tip, leaving, preview } = current; + const keys = tipTextKeys(tip.id); + const mod = isMacOS() ? "⌘" : "Ctrl"; + const title = t(keys.title, { mod }); + const showAction = !actionStaysOn(tip.action, page); + + return ( + +
setHovered(true)} + onPointerLeave={() => setHovered(false)} + > + + +
+ +
+
+
+ + + + + + {t("tips.coach.eyebrow")} + + + + {t(`tips.sections.${tip.section}`)} + + + } + title={title} + aside={ + + + + + + flow.openTips(tip.id)} + > + {t("tips.coach.allTips")} + + {!preview && ( + { + // Off takes the tip away unread; the flow sees the + // switch and lets the mark go. + void flow.setAutoShow(false).then((saved) => { + if (!saved) return; + showSuccessToast(t("tips.coach.offTitle"), { + description: t("tips.coach.offBody", { + shortcut: tipsShortcut(), + }), + sound: null, + }); + }); + }} + > + {t("tips.coach.turnOff")} + + )} + + + } + /> + {t(keys.body, { mod })} + + + {showAction && ( + + )} + +
+
+ ); +} diff --git a/src/components/ui/coach-mark.tsx b/src/components/ui/coach-mark.tsx new file mode 100644 index 00000000..10c285c0 --- /dev/null +++ b/src/components/ui/coach-mark.tsx @@ -0,0 +1,793 @@ +"use client"; + +import { + animate, + type MotionValue, + motion, + useMotionValue, + useReducedMotion, + useTransform, +} from "motion/react"; +import { + createContext, + type ReactNode, + useCallback, + useContext, + useEffect, + useId, + useLayoutEffect, + useRef, + useState, +} from "react"; +import { createPortal } from "react-dom"; +import { + anchorSelector, + type Box, + COACH_MARK_EXIT_MS, + type CoachAnchorId, + mostlyVisible, + placeCoachMark, + type Side, +} from "@/lib/coach-mark"; +import { MOTION_EASE_OUT } from "@/lib/motion"; +import { cn } from "@/lib/utils"; + +/** A modal surface is open; a coach mark waits behind it. */ +const MODAL_SELECTOR = '[role="dialog"], [role="alertdialog"]'; +/** Layout shifts that raise no event (a sticky header settling) are caught by this poll. */ +const POLL_MS = 400; +/** DOM changes come in bursts; one measure after the burst is enough. */ +const MUTATION_SETTLE_MS = 40; +/** A move to a new anchor travels; plain scrolling follows at once. */ +const TRAVEL_MS = 560; +const TRAVEL_SPRING = { + type: "spring" as const, + stiffness: 340, + damping: 34, + mass: 0.9, +}; +const ENTER_SPRING = { + type: "spring" as const, + stiffness: 480, + damping: 30, + mass: 0.7, +}; +const ARROW_W = 18; +const ARROW_H = 9; + +const SIDE_VALUES: readonly Side[] = ["top", "right", "bottom", "left"]; +/** The arrow points up at rest; it turns to face the anchor. */ +const ARROW_TURN: Record = { + bottom: 0, + top: 180, + right: -90, + left: 90, +}; + +interface CoachMarkIds { + titleId: string; + bodyId: string; +} + +const CoachMarkContext = createContext(null); + +function useCoachMarkIds(): CoachMarkIds { + const ids = useContext(CoachMarkContext); + if (!ids) throw new Error("Coach mark parts belong inside a CoachMark"); + return ids; +} + +function toBox(rect: DOMRect): Box { + return { x: rect.left, y: rect.top, width: rect.width, height: rect.height }; +} + +/** Every box that clips `element`: its scrolling ancestors and the window. */ +function clipsOf(element: Element): Box[] { + const clips: Box[] = [ + { x: 0, y: 0, width: window.innerWidth, height: window.innerHeight }, + ]; + for ( + let node = element.parentElement; + node && node !== document.body; + node = node.parentElement + ) { + const style = getComputedStyle(node); + if (style.overflowX !== "visible" || style.overflowY !== "visible") { + clips.push(toBox(node.getBoundingClientRect())); + } + } + return clips; +} + +function onScreen(element: Element): boolean { + if (element.closest("[inert], [aria-hidden='true']")) return false; + const style = getComputedStyle(element); + if (style.visibility === "hidden" || style.display === "none") return false; + const rect = element.getBoundingClientRect(); + if (rect.width < 1 || rect.height < 1) return false; + return mostlyVisible(toBox(rect), clipsOf(element)); +} + +/** The first anchor, by preference, with an element that shows. */ +function resolveAnchor( + anchors: readonly CoachAnchorId[], +): { id: CoachAnchorId; element: HTMLElement } | null { + for (const id of anchors) { + for (const element of document.querySelectorAll( + anchorSelector(id), + )) { + if (onScreen(element)) return { id, element }; + } + } + return null; +} + +/** + * Adds or removes one id in an element's `aria-describedby`, leaving the + * others alone: a tooltip on the same element manages its own id there. + */ +function describe(element: Element, id: string, on: boolean) { + const ids = (element.getAttribute("aria-describedby") ?? "") + .split(/\s+/) + .filter(Boolean); + const has = ids.includes(id); + if (on === has) return; + const next = on ? [...ids, id] : ids.filter((other) => other !== id); + if (next.length > 0) element.setAttribute("aria-describedby", next.join(" ")); + else element.removeAttribute("aria-describedby"); +} + +function isEditable(target: EventTarget | null): boolean { + if (!(target instanceof HTMLElement)) return false; + return ( + target.isContentEditable || + ["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName) + ); +} + +/** + * Where the arrow sits and which way it points, for the side the card took. + * The arrow is an 18x9 triangle that turns about its middle; its base sinks + * one pixel into the card so the card's hairline does not cross it. + */ +function arrowTransform( + side: Side, + arrow: number, + card: { width: number; height: number }, +) { + switch (side) { + case "bottom": + return { x: arrow - ARROW_W / 2, y: -ARROW_H + 1 }; + case "top": + return { x: arrow - ARROW_W / 2, y: card.height - 1 }; + case "right": + return { x: -ARROW_W / 2 - ARROW_H / 2 + 1, y: arrow - ARROW_H / 2 }; + case "left": + return { + x: card.width - ARROW_W / 2 + ARROW_H / 2 - 1, + y: arrow - ARROW_H / 2, + }; + } +} + +function originFor(side: Side, arrow: number): string { + switch (side) { + case "bottom": + return `${arrow}px 0px`; + case "top": + return `${arrow}px 100%`; + case "right": + return `0px ${arrow}px`; + case "left": + return `100% ${arrow}px`; + } +} + +/** The card starts a few pixels toward its anchor and settles away from it. */ +function entranceOffset(side: Side | null): { x: number; y: number } { + switch (side) { + case "bottom": + return { x: 0, y: -6 }; + case "top": + return { x: 0, y: 6 }; + case "right": + return { x: -6, y: 0 }; + case "left": + return { x: 6, y: 0 }; + default: + return { x: 0, y: 8 }; + } +} + +/** A rounded rectangle cut out of a sheet that covers any window. */ +function spotlightPath(x: number, y: number, w: number, h: number, r: number) { + const radius = Math.max(0, Math.min(r, w / 2, h / 2)); + return `M-1 -1H100000V100000H-1Z M${x + radius} ${y}H${x + w - radius}A${radius} ${radius} 0 0 1 ${x + w} ${y + radius}V${y + h - radius}A${radius} ${radius} 0 0 1 ${x + w - radius} ${y + h}H${x + radius}A${radius} ${radius} 0 0 1 ${x} ${y + h - radius}V${y + radius}A${radius} ${radius} 0 0 1 ${x + radius} ${y}Z`; +} + +interface Layout { + anchor: CoachAnchorId | null; + side: Side | null; + hidden: boolean; +} + +type DataAttributes = { + [key: `data-${string}`]: string | number | boolean | undefined; +}; + +export interface CoachMarkProps extends DataAttributes { + /** False plays the exit. The owner unmounts the mark `COACH_MARK_EXIT_MS` later. */ + open: boolean; + /** Where to point, best first. The first anchor that shows wins. */ + anchors: readonly CoachAnchorId[]; + /** With no anchor on screen, wait at the bottom of the window instead of hiding. */ + floatWithoutAnchor?: boolean; + /** Dims the page around the anchor, for a guided tour. */ + spotlight?: boolean; + /** The side to prefer when the anchor does not name one (`data-tip-side`). */ + side?: Side; + /** Escape. The owner's own close controls call their handlers directly. */ + onDismiss: () => void; + /** The person used the feature the mark points at. */ + onAnchorActivate?: () => void; + /** Read out politely when the mark appears. */ + announcement?: string; + /** Changes with the content (a tour step), so the card travels to the new anchor. */ + stepKey?: string | number; + /** Already introduced this session: appear at rest, without the entrance or the beacon's pulse. */ + settled?: boolean; + className?: string; + children: ReactNode; +} + +/** + * Points at a real element: a ring drawn around it, a beacon on its corner, + * and a card beside it with an arrow. The anchor is found by its + * `data-tip-anchor` id, and the mark follows it through scrolling, resizing + * and re-renders, flipping sides to stay on screen. + * + * It never takes the focus. The card is a labelled region whose buttons are in + * the tab order, the anchor is described by the card's text while the mark + * points at it, and Escape closes it. It hides while a modal surface is open. + * + * Motion is decoration: the card is fully opaque from its first frame and + * only travels and scales into place, so a stalled frame clock leaves it + * readable. Reduced motion shows every part at rest. + */ +export function CoachMark({ + open, + anchors, + floatWithoutAnchor = false, + spotlight = false, + side: preferredSide = "bottom", + onDismiss, + onAnchorActivate, + announcement, + stepKey, + settled = false, + className, + children, + ...dataAttributes +}: CoachMarkProps) { + const reduceMotion = useReducedMotion() ?? false; + const quiet = reduceMotion || settled; + const titleId = useId(); + const bodyId = useId(); + const [mounted, setMounted] = useState(false); + const [layout, setLayout] = useState({ + anchor: null, + side: null, + hidden: true, + }); + const [spoken, setSpoken] = useState(""); + + const cardRef = useRef(null); + const anchorRef = useRef(null); + const travelUntilRef = useRef(0); + const shownRef = useRef(false); + + const cardX = useMotionValue(0); + const cardY = useMotionValue(0); + const enterX = useMotionValue(0); + const enterY = useMotionValue(0); + const enterScale = useMotionValue(1); + const cardOpacity = useMotionValue(1); + const ringX = useMotionValue(0); + const ringY = useMotionValue(0); + const ringW = useMotionValue(0); + const ringH = useMotionValue(0); + const ringR = useMotionValue(8); + const arrowX = useMotionValue(0); + const arrowY = useMotionValue(0); + const beaconX = useMotionValue(0); + const beaconY = useMotionValue(0); + const hole = useTransform( + [ringX, ringY, ringW, ringH, ringR], + ([x, y, w, h, r]: number[]) => spotlightPath(x, y, w, h, r), + ); + + const latest = useRef({ + anchors, + preferredSide, + spotlight, + reduceMotion, + quiet, + floatWithoutAnchor, + open, + }); + latest.current = { + anchors, + preferredSide, + spotlight, + reduceMotion, + quiet, + floatWithoutAnchor, + open, + }; + + const measure = useCallback(() => { + const card = cardRef.current; + if (!card) return; + const { + anchors, + preferredSide, + spotlight, + reduceMotion, + quiet, + floatWithoutAnchor, + open, + } = latest.current; + const blocked = Boolean(document.querySelector(MODAL_SELECTOR)); + const found = blocked ? null : resolveAnchor(anchors); + const hidden = blocked || (!found && !floatWithoutAnchor); + const viewport = { width: window.innerWidth, height: window.innerHeight }; + const size = { width: card.offsetWidth, height: card.offsetHeight }; + + const now = performance.now(); + if ( + shownRef.current && + !hidden && + !reduceMotion && + found?.element !== anchorRef.current + ) { + travelUntilRef.current = now + TRAVEL_MS; + } + const travel = now < travelUntilRef.current; + const move = (value: MotionValue, target: number) => { + if (travel) animate(value, target, TRAVEL_SPRING); + else value.jump(target); + }; + // The anchor is described by the card while the mark points at it. + const previous = anchorRef.current; + if (previous && previous !== found?.element) + describe(previous, bodyId, false); + if (found) describe(found.element, bodyId, open && !hidden); + anchorRef.current = found?.element ?? null; + + let side: Side | null = null; + if (found) { + const rect = found.element.getBoundingClientRect(); + const pad = spotlight ? 6 : 4; + const radius = + Number.parseFloat( + getComputedStyle(found.element).borderTopLeftRadius, + ) || 6; + const ring = { + x: rect.left - pad, + y: rect.top - pad, + width: rect.width + pad * 2, + height: rect.height + pad * 2, + }; + const named = found.element.getAttribute("data-tip-side"); + const placed = placeCoachMark(ring, size, viewport, { + side: SIDE_VALUES.includes(named as Side) + ? (named as Side) + : preferredSide, + gap: ARROW_H + 4, + }); + side = placed.side; + const arrow = arrowTransform(placed.side, placed.arrow, size); + move(cardX, placed.x); + move(cardY, placed.y); + move(ringX, ring.x); + move(ringY, ring.y); + move(ringW, ring.width); + move(ringH, ring.height); + move(ringR, Math.min(radius + pad, 14)); + move(beaconX, ring.x + ring.width - 3); + move(beaconY, ring.y + 3); + move(arrowX, arrow.x); + move(arrowY, arrow.y); + card.style.transformOrigin = originFor(placed.side, placed.arrow); + } else { + move(cardX, (viewport.width - size.width) / 2); + move(cardY, viewport.height - size.height - 24); + card.style.transformOrigin = "50% 100%"; + } + + // The first time the card shows, it settles out from its anchor. + if (!hidden && !shownRef.current && !quiet) { + const offset = entranceOffset(side); + enterX.jump(offset.x); + enterY.jump(offset.y); + enterScale.jump(0.94); + animate(enterX, 0, ENTER_SPRING); + animate(enterY, 0, ENTER_SPRING); + animate(enterScale, 1, ENTER_SPRING); + } + if (!hidden) shownRef.current = true; + + setLayout((previous) => + previous.anchor === (found?.id ?? null) && + previous.side === side && + previous.hidden === hidden + ? previous + : { anchor: found?.id ?? null, side, hidden }, + ); + }, [ + bodyId, + cardX, + cardY, + enterX, + enterY, + enterScale, + ringX, + ringY, + ringW, + ringH, + ringR, + beaconX, + beaconY, + arrowX, + arrowY, + ]); + + useEffect(() => setMounted(true), []); + + // Placed before the first paint, then kept in place by every event that can + // move the anchor. No frame callback is involved, so a throttled webview + // still places the card. + useLayoutEffect(() => { + if (!mounted) return; + measure(); + const onChange = () => measure(); + let settle = 0; + const onMutation = () => { + window.clearTimeout(settle); + settle = window.setTimeout(measure, MUTATION_SETTLE_MS); + }; + window.addEventListener("resize", onChange); + document.addEventListener("scroll", onChange, { + capture: true, + passive: true, + }); + const resize = new ResizeObserver(onChange); + if (cardRef.current) resize.observe(cardRef.current); + const mutations = new MutationObserver(onMutation); + mutations.observe(document.body, { childList: true, subtree: true }); + const poll = window.setInterval(onChange, POLL_MS); + return () => { + window.removeEventListener("resize", onChange); + document.removeEventListener("scroll", onChange, { capture: true }); + resize.disconnect(); + mutations.disconnect(); + window.clearInterval(poll); + window.clearTimeout(settle); + }; + }, [mounted, measure]); + + // A new step or a new list of anchors is placed at once. + const anchorKey = anchors.join(" "); + useLayoutEffect(() => { + if (mounted && (stepKey !== undefined || anchorKey)) measure(); + }, [mounted, measure, stepKey, anchorKey]); + + // The exit: a short fade and a slight shrink toward the anchor. + useEffect(() => { + if (open) { + cardOpacity.jump(1); + return; + } + const exit = { + duration: (COACH_MARK_EXIT_MS - 30) / 1000, + ease: MOTION_EASE_OUT, + }; + animate(cardOpacity, 0, exit); + if (!reduceMotion) animate(enterScale, 0.97, exit); + }, [open, reduceMotion, cardOpacity, enterScale]); + + // Closing or leaving takes the description off the anchor. + useEffect(() => { + if (open) return; + if (anchorRef.current) describe(anchorRef.current, bodyId, false); + }, [open, bodyId]); + useEffect( + () => () => { + if (anchorRef.current) describe(anchorRef.current, bodyId, false); + }, + [bodyId], + ); + + // Escape closes the mark when nothing in front of it wants the key: a field, + // a menu or a dialog keeps Escape for itself. Using the feature the mark + // points at tells its owner. + const dismissRef = useRef(onDismiss); + dismissRef.current = onDismiss; + const activateRef = useRef(onAnchorActivate); + activateRef.current = onAnchorActivate; + useEffect(() => { + if (!open) return; + const onKeyDown = (event: KeyboardEvent) => { + if (event.key !== "Escape" || event.defaultPrevented) return; + const inside = cardRef.current?.contains(event.target as Node) ?? false; + if (!inside) { + if (isEditable(event.target)) return; + if (document.querySelector(`${MODAL_SELECTOR}, [role="menu"]`)) return; + } + event.preventDefault(); + dismissRef.current(); + }; + const onClick = (event: MouseEvent) => { + const anchor = anchorRef.current; + if (!anchor || !activateRef.current) return; + if (event.target instanceof Node && anchor.contains(event.target)) { + activateRef.current(); + } + }; + document.addEventListener("keydown", onKeyDown, true); + document.addEventListener("click", onClick, true); + return () => { + document.removeEventListener("keydown", onKeyDown, true); + document.removeEventListener("click", onClick, true); + }; + }, [open]); + + // Spoken a moment after it appears, so the live region already exists. + useEffect(() => { + if (!open || layout.hidden || !announcement) return; + const timer = window.setTimeout(() => setSpoken(announcement), 120); + return () => window.clearTimeout(timer); + }, [open, layout.hidden, announcement]); + + if (!mounted) return null; + + const hidden = layout.hidden; + const anchored = layout.anchor !== null && !hidden; + const visibility = hidden ? "hidden" : "visible"; + const leaving = !open; + + return createPortal( + + {spotlight && ( + + )} + + {anchored && ( + + )} + + {anchored && ( + + )} + + + + {anchored && ( + + )} + {children} + + + +
+ {open && !hidden ? spoken : ""} +
+
, + document.body, + ); +} + +/** The drawing at the top of a card. Decorative. */ +export function CoachMarkScene({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( + + ); +} + +export function CoachMarkHeader({ + eyebrow, + title, + aside, +}: { + eyebrow?: ReactNode; + title: ReactNode; + aside?: ReactNode; +}) { + const { titleId } = useCoachMarkIds(); + return ( +
+
+ {eyebrow && ( +

+ {eyebrow} +

+ )} +

+ {title} +

+
+ {aside} +
+ ); +} + +export function CoachMarkBody({ children }: { children: ReactNode }) { + const { bodyId } = useCoachMarkIds(); + return ( +

+ {children} +

+ ); +} + +export function CoachMarkFooter({ + className, + children, +}: { + className?: string; + children: ReactNode; +}) { + return ( +
+ {children} +
+ ); +} diff --git a/src/components/ui/step-dots.tsx b/src/components/ui/step-dots.tsx new file mode 100644 index 00000000..696df7ce --- /dev/null +++ b/src/components/ui/step-dots.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { motion, useReducedMotion } from "motion/react"; +import { useId } from "react"; +import { useInputModality } from "@/hooks/use-input-modality"; +import { MOTION_SPRING_POSITION } from "@/lib/motion"; +import { cn } from "@/lib/utils"; + +/** + * Where a person is in a short flow: one dot per step, the current one drawn + * long, the finished ones darker. The current mark glides to the next dot + * when the step changes by pointer, and jumps for keyboard and reduced motion. + */ +export function StepDots({ + count, + current, + label, + className, +}: { + count: number; + /** Zero-based. */ + current: number; + label: string; + className?: string; +}) { + const id = useId(); + const reduceMotion = useReducedMotion(); + const modality = useInputModality(); + const glide = !reduceMotion && modality === "pointer"; + return ( +
+ {Array.from({ length: count }, (_, index) => { + const active = index === current; + return ( + + {active && ( + + ); + })} +
+ ); +} diff --git a/src/components/welcome-dialog.tsx b/src/components/welcome-dialog.tsx index 183c3546..7c5754ee 100644 --- a/src/components/welcome-dialog.tsx +++ b/src/components/welcome-dialog.tsx @@ -1,74 +1,56 @@ "use client"; import { AnimatePresence, motion, useReducedMotion } from "motion/react"; -import { useCallback, useState } from "react"; +import { type ReactNode, useCallback, useLayoutEffect, useState } from "react"; import { useTranslation } from "react-i18next"; import { + LuArrowLeft, LuArrowRight, LuBriefcase, - LuCamera, - LuChevronDown, LuCookie, LuFolders, LuGithub, LuGlobe, LuHeart, LuLoaderCircle, - LuMic, LuNetwork, LuShieldCheck, LuTerminal, LuTriangleAlert, LuUsers, } from "react-icons/lu"; -import { Logo } from "@/components/icons/logo"; +import { + IntroScene, + LicenseScene, + PermissionsScene, + SetupScene, +} from "@/components/onboarding/scenes"; import { ProfileIsolationDemo } from "@/components/profile-isolation-demo"; +import { SCENE_GRID_CLASS } from "@/components/tips/scene-primitives"; import { Button } from "@/components/ui/button"; import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog"; +import { StepDots } from "@/components/ui/step-dots"; +import { StepTransition } from "@/components/ui/step-transition"; import { useBrowserSetup } from "@/hooks/use-browser-setup"; import { useInputModality } from "@/hooks/use-input-modality"; import { usePermissions } from "@/hooks/use-permissions"; import { translateBackendError } from "@/lib/backend-errors"; import { getBrowserDisplayName } from "@/lib/browser-utils"; import { getCurrentOS } from "@/lib/platform"; +import { cn } from "@/lib/utils"; -type WelcomeStep = "intro" | "license" | "permissions" | "setup"; +type WelcomeStep = "intro" | "isolation" | "license" | "permissions" | "setup"; -const panelSpring = { - type: "spring", - stiffness: 260, - damping: 28, -} as const; - -/** - * Steps travel, they do not fade in. - * - * Onboarding is the first thing a new install shows and the only way past it is - * the button on the current step, so a step that fails to appear is a dead app. - * These panels used to start at `opacity: 0` inside an `AnimatePresence - * mode="wait"`, which put both the visibility AND the mount of every step - * behind an animation. `requestAnimationFrame` stops whenever the webview is - * occluded, unfocused or throttled, and when it stopped mid-transition the - * dialog sat empty with the next step never mounted. - * - * Full opacity at rest means a stalled animation costs 12px of offset instead - * of the whole screen. - */ -const panelVariants = { - enter: { y: 12 }, - center: { y: 0 }, -}; - -// Concrete feature list shown on the intro step, rendered as an icon grid. +// Concrete features shown on the intro step. const FEATURES = [ - { key: "welcome.features.items.setDefault", Icon: LuGlobe }, + { key: "welcome.features.items.profiles", Icon: LuUsers }, { key: "welcome.features.items.proxy", Icon: LuNetwork }, { key: "welcome.features.items.vpn", Icon: LuShieldCheck }, - { key: "welcome.features.items.profiles", Icon: LuUsers }, - { key: "welcome.features.items.api", Icon: LuTerminal }, - { key: "welcome.features.items.openSource", Icon: LuGithub }, - { key: "welcome.features.items.groups", Icon: LuFolders }, { key: "welcome.features.items.cookies", Icon: LuCookie }, + { key: "welcome.features.items.groups", Icon: LuFolders }, + { key: "welcome.features.items.api", Icon: LuTerminal }, + { key: "welcome.features.items.setDefault", Icon: LuGlobe }, + { key: "welcome.features.items.openSource", Icon: LuGithub }, ] as const; const BYTE_UNITS = ["byte", "kilobyte", "megabyte", "gigabyte"] as const; @@ -122,12 +104,12 @@ function SetupProgress({ value, label }: { value: number; label: string }) { aria-valuemin={0} aria-valuemax={100} aria-valuenow={determined ? normalizedValue : undefined} - className="h-1.5 w-full overflow-hidden rounded-full bg-muted" + className="h-1.5 w-full overflow-hidden rounded-full bg-foreground/8" > {determined ? (
) : (
@@ -136,6 +118,44 @@ function SetupProgress({ value, label }: { value: number; label: string }) { ); } +function StepHeading({ + title, + body, + tone, +}: { + title: ReactNode; + body: ReactNode; + tone?: "error"; +}) { + return ( +
+

+ {title} +

+

+ {body} +

+
+ ); +} + +/** + * The first-run welcome: a few short steps, each with its own drawing. The + * drawing slides in the direction of travel and the step's text moves a few + * pixels with it; the dots in the footer show where the person is. + * + * Steps travel, they do not fade in. Onboarding is the first thing a new + * install shows and the only way past it is the button on the current step, + * so a step that fails to appear is a dead app. Text and controls are at full + * opacity from the first frame and only move into place, so a stalled frame + * clock costs a few pixels of offset, never the screen. Only the decorative + * drawing fades. + */ export function WelcomeDialog({ isOpen, needsSetup, @@ -154,7 +174,7 @@ export function WelcomeDialog({ const { t, i18n } = useTranslation(); const reduceMotion = useReducedMotion(); const inputModality = useInputModality(); - const animatePanels = !reduceMotion && inputModality === "pointer"; + const animateSteps = !reduceMotion && inputModality === "pointer"; const { requestPermission, isMicrophoneAccessGranted, @@ -163,28 +183,43 @@ export function WelcomeDialog({ requiresSystemPermissions, } = usePermissions(isOpen); const [step, setStep] = useState("intro"); + const [direction, setDirection] = useState<1 | -1>(1); const permissionsGranted = isMicrophoneAccessGranted && isCameraAccessGranted; const showPermissionsStep = requiresSystemPermissions && (step === "permissions" || !isInitialized || !permissionsGranted); const visibleSteps: WelcomeStep[] = [ "intro", + "isolation", "license", ...(showPermissionsStep ? (["permissions"] as const) : []), ...(needsSetup ? (["setup"] as const) : []), ]; const currentStepIndex = Math.max(0, visibleSteps.indexOf(step)); - const panelTransition = animatePanels - ? panelSpring - : ({ duration: 0 } as const); + const go = (next: WelcomeStep) => { + setDirection(visibleSteps.indexOf(next) >= currentStepIndex ? 1 : -1); + setStep(next); + }; + const back = () => { + const previous = visibleSteps[currentStepIndex - 1]; + if (previous) go(previous); + }; // Where the "skip" / "continue" affordances go: into the setup flow when a // browser/profile is still needed, otherwise straight to completion. const advanceToSetup = () => { - if (needsSetup) setStep("setup"); + if (needsSetup) go("setup"); else onComplete(); }; const [requesting, setRequesting] = useState(false); + // Each step starts at its top, also when the one before was scrolled. + // biome-ignore lint/correctness/useExhaustiveDependencies: the step is the trigger + useLayoutEffect(() => { + document + .querySelector('[data-slot="welcome-dialog"]') + ?.scrollTo({ top: 0 }); + }, [step]); + // Track the required browser's download + extraction the whole time the // dialog is open, so progress is live by the time the user reaches setup. const setup = useBrowserSetup("wayfern", isOpen); @@ -203,388 +238,423 @@ export function WelcomeDialog({ console.error("Permission request failed:", err); } finally { setRequesting(false); + setDirection(1); setStep("setup"); } }, [isCameraAccessGranted, isMicrophoneAccessGranted, requestPermission]); + const acceptLicense = () => { + if (!needsSetup) { + onComplete(); + } else if ( + getCurrentOS() === "macos" && + !(isInitialized && permissionsGranted) + ) { + go("permissions"); + } else { + go("setup"); + } + }; + + const scene = + step === "intro" ? ( + + ) : step === "license" ? ( + + ) : step === "permissions" ? ( + + ) : step === "setup" ? ( + + ) : null; + + const skipButton = ( + + ); + const backButton = ( + + ); + + let body: ReactNode = null; + let leading: ReactNode = null; + let trailing: ReactNode = null; + + switch (step) { + case "intro": + body = ( + <> + +
    + {FEATURES.map(({ key, Icon }, index) => ( + + + ))} +
+ + ); + leading = skipButton; + trailing = ( + + ); + break; + + case "isolation": + body = ( + <> + + + + ); + leading = skipButton; + trailing = ( + <> + {backButton} + + + ); + break; + + case "license": + body = ( + <> + +
+
+ +
+
+ {t("welcome.license.personalTitle")} +
+
+ {t("welcome.license.personalDesc")} +
+
+
+
+ +
+
+ {t("welcome.license.commercialTitle")} + + {t("welcome.license.trialBadge")} + +
+
+ {t("welcome.license.commercialDesc")} +
+
+
+
+ + ); + leading = skipButton; + trailing = ( + <> + {backButton} + + + ); + break; + + case "permissions": + body = ( + + ); + leading = ( + + ); + trailing = ( + + ); + break; + + case "setup": + if (setup.phase === "error") { + body = ( + <> + + + {t("welcome.ready.errorTitle")} + + } + body={ + setup.error?.stage === "downloading" + ? t("welcome.ready.errorDownload", { browser: browserName }) + : setup.error?.stage === "extracting" || + setup.error?.stage === "verifying" + ? t("welcome.ready.errorExtraction", { + browser: browserName, + }) + : t("welcome.ready.errorGeneric", { browser: browserName }) + } + /> + {setup.error?.detail && ( +

+ {translateBackendError(t, setup.error.detail)} +

+ )} + + ); + // No escape hatch here: a browser must finish downloading before + // onboarding can complete, so the only action on failure is to retry. + trailing = ( + + ); + break; + } + body = ( + <> + + {setup.phase === "downloading" && ( +
+ +
+ + + {t("welcome.ready.downloading")} + + {setup.downloadPercent}% +
+
+ + {setup.totalBytes != null + ? t("welcome.ready.stats", { + downloaded: formatBytes( + setup.downloadedBytes, + i18n.language, + ), + total: formatBytes(setup.totalBytes, i18n.language), + }) + : formatBytes(setup.downloadedBytes, i18n.language)} + + {setup.speedBytesPerSec > 0 && ( + + {t("welcome.ready.speed", { + speed: formatBytes(setup.speedBytesPerSec, i18n.language), + })} + + )} + {setup.etaSeconds != null && + Number.isFinite(setup.etaSeconds) && + setup.etaSeconds > 0 && ( + + {t("welcome.ready.timeLeft", { + time: formatDuration(setup.etaSeconds, i18n.language), + })} + + )} +
+
+ )} + {setup.phase === "extracting" && ( +
+ {setup.extractionOvertime ? ( +
+ + {t("welcome.ready.almostFinished")} +
+ ) : ( + <> + +
+ + + {t("welcome.ready.extracting")} + + {setup.extractionPercent}% +
+ + )} +
+ )} + + ); + if (setup.phase === "ready") { + trailing = ( + + ); + } + break; + } + return ( {}}> {t("welcome.title")} -
-
-
- - - {step === "intro" && ( - -
- -
-

- {t("welcome.title")} -

-

- {t("welcome.tagline")} -

-
-
- - - -
- - -
- {FEATURES.map(({ key, Icon }) => ( -
- -
- {t(key)} -
-
- ))} -
-
- -
- - -
-
- )} - - {step === "license" && ( - -
-

- {t("welcome.license.title")} -

-

- {t("welcome.license.body")} -

-
- -
-
- -
-
- {t("welcome.license.personalTitle")} -
-
- {t("welcome.license.personalDesc")} -
-
-
-
- -
-
- {t("welcome.license.commercialTitle")} - - {t("welcome.license.trialBadge")} - -
-
- {t("welcome.license.commercialDesc")} -
-
-
-
- -
- - -
-
- )} - - {step === "permissions" && ( - -
- -

- {t("welcome.permissions.title")} -

-

- {t("welcome.permissions.desc")} -

-
- -
- - -
-
- )} - - {step === "setup" && ( - - {setup.phase === "error" ? ( - <> -
-

- - {t("welcome.ready.errorTitle")} -

-

- {setup.error?.stage === "downloading" - ? t("welcome.ready.errorDownload", { - browser: browserName, - }) - : setup.error?.stage === "extracting" || - setup.error?.stage === "verifying" - ? t("welcome.ready.errorExtraction", { - browser: browserName, - }) - : t("welcome.ready.errorGeneric", { - browser: browserName, - })} -

- {setup.error?.detail && ( -

- {translateBackendError(t, setup.error.detail)} -

- )} -
- - {/* No escape hatch here: a browser must finish downloading - before onboarding can complete, so the only action on - failure is to retry. */} - - - ) : ( - <> -
-

- {t("welcome.ready.title")} -

-

- {setup.phase === "ready" - ? t("welcome.ready.descReady") - : setup.phase === "extracting" - ? t("welcome.ready.descExtracting") - : t("welcome.ready.descDownloading")} -

-
- - {setup.phase === "downloading" && ( -
- -
- - - {t("welcome.ready.downloading")} - - {setup.downloadPercent}% -
-
- - {setup.totalBytes != null - ? t("welcome.ready.stats", { - downloaded: formatBytes( - setup.downloadedBytes, - i18n.language, - ), - total: formatBytes( - setup.totalBytes, - i18n.language, - ), - }) - : formatBytes(setup.downloadedBytes, i18n.language)} - - {setup.speedBytesPerSec > 0 && ( - - {t("welcome.ready.speed", { - speed: formatBytes( - setup.speedBytesPerSec, - i18n.language, - ), - })} - - )} - {setup.etaSeconds != null && - Number.isFinite(setup.etaSeconds) && - setup.etaSeconds > 0 && ( - - {t("welcome.ready.timeLeft", { - time: formatDuration( - setup.etaSeconds, - i18n.language, - ), - })} - - )} -
-
- )} - - {setup.phase === "extracting" && ( -
- {setup.extractionOvertime ? ( -
- - {t("welcome.ready.almostFinished")} -
- ) : ( - <> - -
- - - {t("welcome.ready.extracting")} - - {setup.extractionPercent}% -
- - )} -
- )} - - {setup.phase === "ready" && ( - - )} - + {scene && ( +
+
- )} - + > + + + {scene} + + +
+
+ )} + + + {body} + + +
+
+ {leading} +
+ +
+ {trailing} +
+
); diff --git a/src/generated/licenses.json b/src/generated/licenses.json index 85e383d1..b602a6c5 100644 --- a/src/generated/licenses.json +++ b/src/generated/licenses.json @@ -1975,10 +1975,6 @@ "name": "objc2-web-kit", "license": "Zlib OR Apache-2.0 OR MIT" }, - { - "name": "onborda", - "license": "MIT" - }, { "name": "once_cell", "license": "Apache-2.0 OR MIT" diff --git a/src/hooks/use-tips.ts b/src/hooks/use-tips.ts index 835453bc..b59429a2 100644 --- a/src/hooks/use-tips.ts +++ b/src/hooks/use-tips.ts @@ -2,6 +2,7 @@ import { invoke } from "@tauri-apps/api/core"; import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import type { AppPage } from "@/components/rail-nav"; import { getAgentConsole } from "@/lib/agent-console"; +import { COACH_MARK_EXIT_MS } from "@/lib/coach-mark"; import { effectivePlanOf, getEntitlements, @@ -77,8 +78,6 @@ const PROXY_HISTORY_BATCH = 16; const INLINE_TIP_RECHECK_MS = 15 * 60 * 1000; /** Backend facts behind the tip on the page are read again at most this often. */ const FACTS_REFRESH_MS = 60 * 1000; -/** The inline tip's exit; it is removed after this even if no frame ran. */ -export const INLINE_TIP_EXIT_MS = 220; /** * The answer of one usage read, or undefined when it fails or is too slow. @@ -234,6 +233,17 @@ interface InlineState { leaving: boolean; } +/** The tip a coach mark points out on the Profiles page right now. */ +export interface PageTip { + tip: TipDefinition; + leaving: boolean; + /** + * Asked for from the catalog ("Show me"). It is read already, so closing it + * records nothing and does not count as the day's tip. + */ + preview: boolean; +} + interface UseTipsOptions { cloudUser: CloudUser | null; /** The account is still loading, so the plan's tips are not known yet. */ @@ -251,9 +261,9 @@ interface UseTipsOptions { /** * The tips flow: which tips this install may see, what has been seen, the - * one tip a day that sits on the Profiles page, and the welcome for an - * account that just turned paid. State lives in the app settings; this hook - * only decides. + * one tip a day that a coach mark points out on the Profiles page, and the + * welcome for an account that just turned paid. State lives in the app + * settings; this hook only decides. */ export function useTips({ cloudUser, @@ -271,6 +281,7 @@ export function useTips({ }); const [paidWelcome, setPaidWelcome] = useState(null); const [inline, setInline] = useState(null); + const [preview, setPreview] = useState(null); const [clock, setClock] = useState(() => Date.now()); const entitlements = useMemo(() => getEntitlements(cloudUser), [cloudUser]); @@ -422,7 +433,8 @@ export function useTips({ useEffect(() => { if (!inline?.leaving) return; - const timer = window.setTimeout(() => setInline(null), INLINE_TIP_EXIT_MS); + // Removed after the coach mark's exit, even if no frame ran. + const timer = window.setTimeout(() => setInline(null), COACH_MARK_EXIT_MS); return () => window.clearTimeout(timer); }, [inline?.leaving]); @@ -442,7 +454,7 @@ export function useTips({ useEffect(() => { if (!ready || accountLoading || !usage.loaded) return; if (!state?.auto_show || inline) return; - if (!onProfiles || dialog.open || welcomeBusy) return; + if (!onProfiles || dialog.open || welcomeBusy || preview) return; if (!pinned && !due) return; if (!pinned && attemptedAtRef.current === clock) return; let cancelled = false; @@ -533,6 +545,7 @@ export function useTips({ onProfiles, dialog.open, welcomeBusy, + preview, pinned, due, clock, @@ -545,7 +558,7 @@ export function useTips({ ? (tips.find((tip) => tip.id === inline.id) ?? null) : null; - // The card leaves on its own once its tip is done with: read in the + // The coach mark leaves on its own once its tip is done with: read in the // catalog, tips switched off, or the feature put to use. useEffect(() => { if (!inline || inline.leaving || !state) return; @@ -574,7 +587,7 @@ export function useTips({ ]); // Facts read from the backend (a proxy check, the API switch, the trash) - // can change while the card is up. They are read again when the person + // can change while the coach mark is up. They are read again when the person // comes back to the Profiles page or to the window, at most once a minute. const [returnedAt, setReturnedAt] = useState(0); useEffect(() => { @@ -611,6 +624,42 @@ export function useTips({ void resolveInline(current.id); }, [leaveInline, resolveInline]); + // "Show me" from the catalog: the catalog closes and a coach mark points at + // the feature. The day's tip, if one is up, waits behind it. + const showOnPage = useCallback((id: TipId) => { + setDialog((previous) => + previous.open ? { ...previous, open: false } : previous, + ); + setPreview({ id, leaving: false }); + }, []); + + useEffect(() => { + if (!preview?.leaving) return; + const timer = window.setTimeout(() => setPreview(null), COACH_MARK_EXIT_MS); + return () => window.clearTimeout(timer); + }, [preview?.leaving]); + + const previewTip = preview + ? (tips.find((tip) => tip.id === preview.id) ?? null) + : null; + const pageTip: PageTip | null = previewTip + ? { tip: previewTip, leaving: preview?.leaving ?? false, preview: true } + : inlineTip + ? { tip: inlineTip, leaving: inline?.leaving ?? false, preview: false } + : null; + + const dismissPageTip = useCallback(() => { + if (preview) { + setPreview((previous) => + previous && !previous.leaving + ? { ...previous, leaving: true } + : previous, + ); + return; + } + dismissInlineTip(); + }, [preview, dismissInlineTip]); + const markSeen = useCallback(async (id: TipId) => { try { setState(await invoke("mark_tip_seen", { tipId: id })); @@ -645,10 +694,9 @@ export function useTips({ closeTips, markSeen, setAutoShow, - inlineTip: inlineTip - ? { tip: inlineTip, leaving: inline?.leaving ?? false } - : null, - dismissInlineTip, + pageTip, + dismissPageTip, + showOnPage, paidWelcome: { open: paidWelcome?.status === "open", plan: paidWelcome?.plan ?? "", diff --git a/src/i18n/locales/de.json b/src/i18n/locales/de.json index b4690095..0f2da514 100644 --- a/src/i18n/locales/de.json +++ b/src/i18n/locales/de.json @@ -2997,15 +2997,15 @@ }, "buttons": { "skip": "Überspringen", - "back": "Zurück", - "next": "Weiter", "finish": "Fertig" }, "thankYou": { "title": "Danke, dass Sie sich für Donut Browser entschieden haben", "body": "Wir hoffen, dass Ihr Surfen damit privater wird: Jede Identität bleibt für sich, und nichts verlässt Ihren Rechner. Viel Spaß.", "cta": "Lossurfen" - } + }, + "stepOf": "Schritt {{current}} von {{total}}", + "progress": "Fortschritt der Tour" }, "welcome": { "title": "Willkommen bei Donut Browser", @@ -3059,7 +3059,8 @@ "commercialTitle": "Kommerzielle Nutzung", "trialBadge": "2 Wochen kostenlos", "commercialDesc": "Zwei Wochen kostenlos zum Testen. Danach sorgt ein kostenpflichtiger Tarif dafür, dass das Projekt gepflegt wird und weiter wächst." - } + }, + "progress": "Fortschritt der Einrichtung" }, "wayfernBlocked": { "title": "Browser-Automatisierung pausiert", @@ -4125,16 +4126,17 @@ "progress": "{{read}} von {{total}} gelesen", "unread": "Noch nicht gelesen", "nextShort": "Weiter", - "inline": { - "label": "Funktionstipp", + "coach": { "eyebrow": "Tipp", "more": "Weitere Optionen zum Tipp", "allTips": "Alle Tipps", "turnOff": "Keine Tipps anzeigen", - "dismiss": "Tipp ausblenden", "offTitle": "Tipps sind aus", - "offBody": "Unter Tipps ({{shortcut}}) schalten Sie sie wieder ein." - } + "offBody": "Unter Tipps ({{shortcut}}) schalten Sie sie wieder ein.", + "gotIt": "Verstanden", + "announce": "Tipp: {{title}}" + }, + "showMe": "Zeig es mir" }, "paidWelcome": { "title": "Willkommen bei {{plan}}", diff --git a/src/i18n/locales/en.json b/src/i18n/locales/en.json index 8f9eefa3..6822e5a5 100644 --- a/src/i18n/locales/en.json +++ b/src/i18n/locales/en.json @@ -2997,15 +2997,15 @@ }, "buttons": { "skip": "Skip", - "back": "Back", - "next": "Next", "finish": "Finish" }, "thankYou": { "title": "Thank you for choosing Donut Browser", "body": "Hopefully it helps make your browsing more private — every identity kept its own, and nothing leaving your machine. Enjoy.", "cta": "Start browsing" - } + }, + "stepOf": "Step {{current}} of {{total}}", + "progress": "Tour progress" }, "welcome": { "title": "Welcome to Donut Browser", @@ -3059,7 +3059,8 @@ "commercialTitle": "Commercial use", "trialBadge": "2 weeks free", "commercialDesc": "Free for a 2-week evaluation. After that, a paid plan keeps the project maintained and thriving." - } + }, + "progress": "Setup progress" }, "wayfernBlocked": { "title": "Browser automation paused", @@ -4125,16 +4126,17 @@ "progress": "{{read}} of {{total}} read", "unread": "Not read yet", "nextShort": "Next", - "inline": { - "label": "Feature tip", + "coach": { "eyebrow": "Tip", "more": "More tip options", "allTips": "All tips", "turnOff": "Don't show tips", - "dismiss": "Dismiss tip", "offTitle": "Tips are off", - "offBody": "Turn them on again in Tips ({{shortcut}})." - } + "offBody": "Turn them on again in Tips ({{shortcut}}).", + "gotIt": "Got it", + "announce": "Tip: {{title}}" + }, + "showMe": "Show me" }, "paidWelcome": { "title": "Welcome to {{plan}}", diff --git a/src/i18n/locales/es.json b/src/i18n/locales/es.json index 192faedc..c554cb8f 100644 --- a/src/i18n/locales/es.json +++ b/src/i18n/locales/es.json @@ -3016,15 +3016,15 @@ }, "buttons": { "skip": "Omitir", - "back": "Atrás", - "next": "Siguiente", "finish": "Finalizar" }, "thankYou": { "title": "Gracias por elegir Donut Browser", "body": "Ojalá ayude a hacer tu navegación más privada: cada identidad por separado y sin que nada salga de tu equipo. ¡Que lo disfrutes!", "cta": "Empezar a navegar" - } + }, + "stepOf": "Paso {{current}} de {{total}}", + "progress": "Progreso del recorrido" }, "welcome": { "title": "Te damos la bienvenida a Donut Browser", @@ -3078,7 +3078,8 @@ "commercialTitle": "Uso comercial", "trialBadge": "2 semanas gratis", "commercialDesc": "Gratis durante una evaluación de 2 semanas. Después, un plan de pago mantiene el proyecto en buen estado y próspero." - } + }, + "progress": "Progreso de la configuración" }, "wayfernBlocked": { "title": "Automatización del navegador en pausa", @@ -4168,16 +4169,17 @@ "progress": "{{read}} de {{total}} leídos", "unread": "Sin leer", "nextShort": "Siguiente", - "inline": { - "label": "Consejo de función", + "coach": { "eyebrow": "Consejo", "more": "Más opciones del consejo", "allTips": "Todos los consejos", "turnOff": "No mostrar consejos", - "dismiss": "Descartar consejo", "offTitle": "Consejos desactivados", - "offBody": "Vuelve a activarlos en Consejos ({{shortcut}})." - } + "offBody": "Vuelve a activarlos en Consejos ({{shortcut}}).", + "gotIt": "Entendido", + "announce": "Consejo: {{title}}" + }, + "showMe": "Muéstramelo" }, "paidWelcome": { "title": "Te damos la bienvenida a {{plan}}", diff --git a/src/i18n/locales/fr.json b/src/i18n/locales/fr.json index c558be96..3df7d2eb 100644 --- a/src/i18n/locales/fr.json +++ b/src/i18n/locales/fr.json @@ -3016,15 +3016,15 @@ }, "buttons": { "skip": "Passer", - "back": "Retour", - "next": "Suivant", "finish": "Terminer" }, "thankYou": { "title": "Merci d'avoir choisi Donut Browser", "body": "Avec un peu de chance, il rendra votre navigation plus privée : chaque identité séparée et rien ne quittant votre machine. Bonne navigation !", "cta": "Commencer à naviguer" - } + }, + "stepOf": "Étape {{current}} sur {{total}}", + "progress": "Progression de la visite" }, "welcome": { "title": "Bienvenue dans Donut Browser", @@ -3078,7 +3078,8 @@ "commercialTitle": "Usage commercial", "trialBadge": "2 semaines gratuites", "commercialDesc": "Gratuit pendant une évaluation de 2 semaines. Ensuite, un forfait payant permet de maintenir et de faire prospérer le projet." - } + }, + "progress": "Progression de la configuration" }, "wayfernBlocked": { "title": "Automatisation du navigateur en pause", @@ -4168,16 +4169,17 @@ "progress": "{{read}} sur {{total}} lues", "unread": "Pas encore lue", "nextShort": "Suivante", - "inline": { - "label": "Astuce", + "coach": { "eyebrow": "Astuce", "more": "Plus d'options pour l'astuce", "allTips": "Toutes les astuces", "turnOff": "Ne plus afficher d'astuces", - "dismiss": "Masquer l'astuce", "offTitle": "Astuces désactivées", - "offBody": "Réactivez-les dans Astuces ({{shortcut}})." - } + "offBody": "Réactivez-les dans Astuces ({{shortcut}}).", + "gotIt": "Compris", + "announce": "Astuce : {{title}}" + }, + "showMe": "Montrez-moi" }, "paidWelcome": { "title": "Bienvenue dans {{plan}}", diff --git a/src/i18n/locales/ja.json b/src/i18n/locales/ja.json index d09480e7..2c401cef 100644 --- a/src/i18n/locales/ja.json +++ b/src/i18n/locales/ja.json @@ -2997,15 +2997,15 @@ }, "buttons": { "skip": "スキップ", - "back": "戻る", - "next": "次へ", "finish": "完了" }, "thankYou": { "title": "Donut Browser を選んでいただきありがとうございます", "body": "それぞれのIDを分けて、データを端末の外に出さずに、よりプライベートなブラウジングのお役に立てれば幸いです。どうぞお楽しみください。", "cta": "ブラウジングを始める" - } + }, + "stepOf": "ステップ {{current}}/{{total}}", + "progress": "ツアーの進行状況" }, "welcome": { "title": "Donut Browser へようこそ", @@ -3059,7 +3059,8 @@ "commercialTitle": "商用利用", "trialBadge": "2週間無料", "commercialDesc": "2週間の評価期間は無料です。その後は有料プランが必要で、これによりプロジェクトの維持と発展が支えられます。" - } + }, + "progress": "セットアップの進行状況" }, "wayfernBlocked": { "title": "ブラウザの自動化が一時停止しました", @@ -4125,16 +4126,17 @@ "progress": "{{total}} 件中 {{read}} 件既読", "unread": "未読", "nextShort": "次へ", - "inline": { - "label": "機能のヒント", + "coach": { "eyebrow": "ヒント", "more": "ヒントのその他のオプション", "allTips": "すべてのヒント", "turnOff": "ヒントを表示しない", - "dismiss": "ヒントを閉じる", "offTitle": "ヒントはオフです", - "offBody": "ヒント ({{shortcut}}) から再びオンにできます。" - } + "offBody": "ヒント ({{shortcut}}) から再びオンにできます。", + "gotIt": "了解", + "announce": "ヒント: {{title}}" + }, + "showMe": "場所を見る" }, "paidWelcome": { "title": "{{plan}} へようこそ", diff --git a/src/i18n/locales/ko.json b/src/i18n/locales/ko.json index 3143e6e3..2607f461 100644 --- a/src/i18n/locales/ko.json +++ b/src/i18n/locales/ko.json @@ -2997,15 +2997,15 @@ }, "buttons": { "skip": "건너뛰기", - "back": "뒤로", - "next": "다음", "finish": "완료" }, "thankYou": { "title": "Donut Browser를 선택해 주셔서 감사합니다", "body": "각 ID를 분리하고 어떤 데이터도 기기 밖으로 내보내지 않으면서 더 프라이빗한 브라우징에 도움이 되길 바랍니다. 즐겁게 사용하세요.", "cta": "브라우징 시작" - } + }, + "stepOf": "{{total}}단계 중 {{current}}단계", + "progress": "둘러보기 진행 상황" }, "welcome": { "title": "Donut Browser에 오신 것을 환영합니다", @@ -3059,7 +3059,8 @@ "commercialTitle": "상업적 사용", "trialBadge": "2주 무료", "commercialDesc": "2주간의 평가 기간 동안 무료입니다. 이후에는 유료 요금제가 필요하며, 이를 통해 프로젝트가 유지되고 발전할 수 있습니다." - } + }, + "progress": "설정 진행 상황" }, "wayfernBlocked": { "title": "브라우저 자동화가 일시 중지됨", @@ -4125,16 +4126,17 @@ "progress": "{{total}}개 중 {{read}}개 읽음", "unread": "읽지 않음", "nextShort": "다음", - "inline": { - "label": "기능 팁", + "coach": { "eyebrow": "팁", "more": "팁 옵션 더 보기", "allTips": "모든 팁", "turnOff": "팁 표시 안 함", - "dismiss": "팁 닫기", "offTitle": "팁이 꺼졌습니다", - "offBody": "팁({{shortcut}})에서 다시 켤 수 있습니다." - } + "offBody": "팁({{shortcut}})에서 다시 켤 수 있습니다.", + "gotIt": "확인", + "announce": "팁: {{title}}" + }, + "showMe": "위치 보기" }, "paidWelcome": { "title": "{{plan}}에 오신 것을 환영합니다", diff --git a/src/i18n/locales/pt.json b/src/i18n/locales/pt.json index ec33297f..43c1fa34 100644 --- a/src/i18n/locales/pt.json +++ b/src/i18n/locales/pt.json @@ -3016,15 +3016,15 @@ }, "buttons": { "skip": "Pular", - "back": "Voltar", - "next": "Próximo", "finish": "Concluir" }, "thankYou": { "title": "Obrigado por escolher o Donut Browser", "body": "Com sorte, deixará sua navegação mais privada: cada identidade separada e nada saindo do seu dispositivo. Boa navegação!", "cta": "Começar a navegar" - } + }, + "stepOf": "Passo {{current}} de {{total}}", + "progress": "Progresso do tour" }, "welcome": { "title": "Boas-vindas ao Donut Browser", @@ -3078,7 +3078,8 @@ "commercialTitle": "Uso comercial", "trialBadge": "2 semanas grátis", "commercialDesc": "Gratuito durante uma avaliação de 2 semanas. Depois, um plano pago mantém o projeto ativo e próspero." - } + }, + "progress": "Progresso da configuração" }, "wayfernBlocked": { "title": "Automação do navegador pausada", @@ -4168,16 +4169,17 @@ "progress": "{{read}} de {{total}} lidas", "unread": "Ainda não lida", "nextShort": "Próxima", - "inline": { - "label": "Dica de recurso", + "coach": { "eyebrow": "Dica", "more": "Mais opções da dica", "allTips": "Todas as dicas", "turnOff": "Não mostrar dicas", - "dismiss": "Dispensar dica", "offTitle": "Dicas desativadas", - "offBody": "Ative-as de novo em Dicas ({{shortcut}})." - } + "offBody": "Ative-as de novo em Dicas ({{shortcut}}).", + "gotIt": "Entendi", + "announce": "Dica: {{title}}" + }, + "showMe": "Mostrar onde" }, "paidWelcome": { "title": "Bem-vindo ao {{plan}}", diff --git a/src/i18n/locales/ru.json b/src/i18n/locales/ru.json index a595aedc..f33db638 100644 --- a/src/i18n/locales/ru.json +++ b/src/i18n/locales/ru.json @@ -3035,15 +3035,15 @@ }, "buttons": { "skip": "Пропустить", - "back": "Назад", - "next": "Далее", "finish": "Готово" }, "thankYou": { "title": "Спасибо, что выбрали Donut Browser", "body": "Пусть он сделает ваш просмотр интернета более приватным — каждая личность отдельно, и ничего не покидает ваше устройство. Приятного использования!", "cta": "Начать работу" - } + }, + "stepOf": "Шаг {{current}} из {{total}}", + "progress": "Ход обзора" }, "welcome": { "title": "Добро пожаловать в Donut Browser", @@ -3097,7 +3097,8 @@ "commercialTitle": "Коммерческое использование", "trialBadge": "2 недели бесплатно", "commercialDesc": "Бесплатно в течение 2-недельного ознакомительного периода. После этого требуется платный план, что помогает поддерживать и развивать проект." - } + }, + "progress": "Ход настройки" }, "wayfernBlocked": { "title": "Автоматизация браузера приостановлена", @@ -4211,16 +4212,17 @@ "progress": "Прочитано {{read}} из {{total}}", "unread": "Не прочитано", "nextShort": "Далее", - "inline": { - "label": "Подсказка о функции", + "coach": { "eyebrow": "Подсказка", "more": "Другие действия с подсказкой", "allTips": "Все подсказки", "turnOff": "Не показывать подсказки", - "dismiss": "Скрыть подсказку", "offTitle": "Подсказки выключены", - "offBody": "Включить их снова можно в разделе «Подсказки» ({{shortcut}})." - } + "offBody": "Включить их снова можно в разделе «Подсказки» ({{shortcut}}).", + "gotIt": "Понятно", + "announce": "Подсказка: {{title}}" + }, + "showMe": "Показать" }, "paidWelcome": { "title": "Добро пожаловать в {{plan}}", diff --git a/src/i18n/locales/tr.json b/src/i18n/locales/tr.json index 376ab8e9..de6e1770 100644 --- a/src/i18n/locales/tr.json +++ b/src/i18n/locales/tr.json @@ -2997,15 +2997,15 @@ }, "buttons": { "skip": "Atla", - "back": "Geri", - "next": "İleri", "finish": "Bitir" }, "thankYou": { "title": "Donut Browser'ı seçtiğiniz için teşekkürler", "body": "Umarız gezinmenizi daha gizli hale getirmenize yardımcı olur — her kimlik kendine ait kalır ve hiçbir şey makinenizden dışarı çıkmaz. İyi kullanımlar.", "cta": "Gezinmeye başla" - } + }, + "stepOf": "Adım {{current}}/{{total}}", + "progress": "Tur ilerlemesi" }, "welcome": { "title": "Donut Browser'a Hoş Geldiniz", @@ -3059,7 +3059,8 @@ "commercialTitle": "Ticari kullanım", "trialBadge": "2 hafta ücretsiz", "commercialDesc": "2 haftalık değerlendirme için ücretsiz. Sonrasında ücretli bir plan, projenin bakımını ve gelişimini sürdürmesini sağlar." - } + }, + "progress": "Kurulum ilerlemesi" }, "wayfernBlocked": { "title": "Tarayıcı otomasyonu duraklatıldı", @@ -4125,16 +4126,17 @@ "progress": "{{total}} ipucundan {{read}} tanesi okundu", "unread": "Henüz okunmadı", "nextShort": "İleri", - "inline": { - "label": "Özellik ipucu", + "coach": { "eyebrow": "İpucu", "more": "Diğer ipucu seçenekleri", "allTips": "Tüm ipuçları", "turnOff": "İpuçlarını gösterme", - "dismiss": "İpucunu kapat", "offTitle": "İpuçları kapalı", - "offBody": "İpuçları bölümünden ({{shortcut}}) yeniden açabilirsiniz." - } + "offBody": "İpuçları bölümünden ({{shortcut}}) yeniden açabilirsiniz.", + "gotIt": "Anladım", + "announce": "İpucu: {{title}}" + }, + "showMe": "Göster" }, "paidWelcome": { "title": "{{plan}} planına hoş geldiniz", diff --git a/src/i18n/locales/vi.json b/src/i18n/locales/vi.json index b2859c44..5aa208ac 100644 --- a/src/i18n/locales/vi.json +++ b/src/i18n/locales/vi.json @@ -2997,15 +2997,15 @@ }, "buttons": { "skip": "Bỏ qua", - "back": "Quay lại", - "next": "Tiếp theo", "finish": "Hoàn tất" }, "thankYou": { "title": "Cảm ơn bạn đã chọn Donut Browser", "body": "Hy vọng nó giúp việc duyệt web của bạn riêng tư hơn — mỗi danh tính được giữ riêng, và không có gì rời khỏi máy của bạn. Chúc bạn duyệt web vui vẻ.", "cta": "Bắt đầu duyệt web" - } + }, + "stepOf": "Bước {{current}}/{{total}}", + "progress": "Tiến độ hướng dẫn" }, "welcome": { "title": "Chào mừng đến với Donut Browser", @@ -3059,7 +3059,8 @@ "commercialTitle": "Sử dụng thương mại", "trialBadge": "2 tuần miễn phí", "commercialDesc": "Miễn phí dùng thử 2 tuần. Sau đó, gói trả phí giúp dự án được duy trì và phát triển." - } + }, + "progress": "Tiến độ thiết lập" }, "wayfernBlocked": { "title": "Tự động hóa trình duyệt đã tạm dừng", @@ -4125,16 +4126,17 @@ "progress": "Đã đọc {{read}}/{{total}}", "unread": "Chưa đọc", "nextShort": "Tiếp", - "inline": { - "label": "Mẹo tính năng", + "coach": { "eyebrow": "Mẹo", "more": "Thêm tùy chọn cho mẹo", "allTips": "Tất cả mẹo", "turnOff": "Không hiện mẹo", - "dismiss": "Ẩn mẹo", "offTitle": "Đã tắt mẹo", - "offBody": "Bật lại trong Mẹo ({{shortcut}})." - } + "offBody": "Bật lại trong Mẹo ({{shortcut}}).", + "gotIt": "Đã hiểu", + "announce": "Mẹo: {{title}}" + }, + "showMe": "Chỉ cho tôi" }, "paidWelcome": { "title": "Chào mừng đến với {{plan}}", diff --git a/src/i18n/locales/zh.json b/src/i18n/locales/zh.json index cbacab69..d75922a8 100644 --- a/src/i18n/locales/zh.json +++ b/src/i18n/locales/zh.json @@ -2997,15 +2997,15 @@ }, "buttons": { "skip": "跳过", - "back": "返回", - "next": "下一步", "finish": "完成" }, "thankYou": { "title": "感谢您选择 Donut Browser", "body": "希望它能让您的网络浏览更加私密——每个身份彼此独立,数据不会离开您的设备。祝您使用愉快!", "cta": "开始浏览" - } + }, + "stepOf": "第 {{current}} 步,共 {{total}} 步", + "progress": "导览进度" }, "welcome": { "title": "欢迎使用 Donut Browser", @@ -3059,7 +3059,8 @@ "commercialTitle": "商业使用", "trialBadge": "2 周免费", "commercialDesc": "在 2 周评估期内免费。之后需要付费方案,这有助于本项目的持续维护与发展。" - } + }, + "progress": "设置进度" }, "wayfernBlocked": { "title": "浏览器自动化已暂停", @@ -4125,16 +4126,17 @@ "progress": "已读 {{read}} / {{total}}", "unread": "未读", "nextShort": "下一条", - "inline": { - "label": "功能小贴士", + "coach": { "eyebrow": "小贴士", "more": "更多小贴士选项", "allTips": "全部小贴士", "turnOff": "不再显示小贴士", - "dismiss": "关闭小贴士", "offTitle": "小贴士已关闭", - "offBody": "可在小贴士 ({{shortcut}}) 中重新开启。" - } + "offBody": "可在小贴士 ({{shortcut}}) 中重新开启。", + "gotIt": "知道了", + "announce": "小贴士:{{title}}" + }, + "showMe": "带我看看" }, "paidWelcome": { "title": "欢迎使用 {{plan}}", diff --git a/src/lib/coach-mark.test.mjs b/src/lib/coach-mark.test.mjs new file mode 100644 index 00000000..5fa14078 --- /dev/null +++ b/src/lib/coach-mark.test.mjs @@ -0,0 +1,89 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + anchorSelector, + COACH_ANCHORS, + isRailAnchor, + mostlyVisible, + placeCoachMark, + visiblePart, +} from "./coach-mark.ts"; + +const VIEWPORT = { width: 1000, height: 700 }; +const CARD = { width: 300, height: 200 }; + +test("the card takes the preferred side when it fits there", () => { + const anchor = { x: 400, y: 50, width: 100, height: 30 }; + const placed = placeCoachMark(anchor, CARD, VIEWPORT, { side: "bottom" }); + assert.equal(placed.side, "bottom"); + assert.equal(placed.y, 50 + 30 + 12); + assert.equal(placed.x, 450 - 150, "centered on the anchor"); + assert.equal(placed.arrow, 150, "the arrow points at the anchor's middle"); + assert.equal(placed.fits, true); +}); + +test("the card flips to the opposite side at the bottom of the window", () => { + const anchor = { x: 400, y: 600, width: 100, height: 30 }; + const placed = placeCoachMark(anchor, CARD, VIEWPORT, { side: "bottom" }); + assert.equal(placed.side, "top"); + assert.equal(placed.y, 600 - 12 - 200); +}); + +test("a rail anchor gets its card on the right, slid down to stay on screen", () => { + const anchor = { x: 6, y: 20, width: 28, height: 28 }; + const placed = placeCoachMark(anchor, CARD, VIEWPORT, { side: "right" }); + assert.equal(placed.side, "right"); + assert.equal(placed.x, 6 + 28 + 12); + assert.equal(placed.y, 8, "held inside the margin"); + assert.equal(placed.arrow, 34 - 8, "the arrow still meets the anchor"); +}); + +test("the arrow never runs into a rounded corner", () => { + const anchor = { x: 990, y: 50, width: 8, height: 20 }; + const placed = placeCoachMark(anchor, CARD, VIEWPORT, { + side: "bottom", + arrowInset: 16, + }); + assert.equal(placed.x, 1000 - 8 - 300); + assert.equal(placed.arrow, 300 - 16); +}); + +test("with no room anywhere the card takes the roomiest side and stays on screen", () => { + const tiny = { width: 320, height: 220 }; + const anchor = { x: 10, y: 10, width: 300, height: 200 }; + const placed = placeCoachMark(anchor, CARD, tiny, { side: "bottom" }); + assert.equal(placed.fits, false); + assert.ok(placed.x >= 8 && placed.x + CARD.width <= tiny.width); +}); + +test("an anchor scrolled mostly out of its container does not count", () => { + const clip = { x: 0, y: 0, width: 200, height: 100 }; + assert.equal( + mostlyVisible({ x: 10, y: 10, width: 50, height: 20 }, [clip]), + true, + ); + assert.equal( + mostlyVisible({ x: 180, y: 10, width: 50, height: 20 }, [clip]), + false, + ); + assert.equal( + visiblePart({ x: 300, y: 0, width: 10, height: 10 }, [clip]), + null, + ); + assert.deepEqual( + visiblePart({ x: 190, y: 90, width: 20, height: 20 }, [clip]), + { + x: 190, + y: 90, + width: 10, + height: 10, + }, + ); +}); + +test("anchor ids are unique and map to their attribute", () => { + assert.equal(new Set(COACH_ANCHORS).size, COACH_ANCHORS.length); + assert.equal(anchorSelector("rail-more"), '[data-tip-anchor="rail-more"]'); + assert.ok(isRailAnchor("rail-settings")); + assert.ok(!isRailAnchor("column-dns")); +}); diff --git a/src/lib/coach-mark.ts b/src/lib/coach-mark.ts new file mode 100644 index 00000000..b1a8adfb --- /dev/null +++ b/src/lib/coach-mark.ts @@ -0,0 +1,221 @@ +/** + * Where a coach mark can point. Each id is a `data-tip-anchor` attribute on a + * real element; the coach mark looks the element up by it, so a component + * becomes an anchor with one attribute and no wiring. + */ +export const COACH_ANCHORS = [ + "rail-profiles", + "rail-proxies", + "rail-extensions", + "rail-groups", + "rail-cookieBot", + "rail-agent", + "rail-integrations", + "rail-account", + "rail-more", + "rail-settings", + "profile-search", + "new-profile", + "group-strip", + "column-state", + "column-proxy", + "column-ext", + "column-dns", + "row-dns", + "row-inspect", +] as const; + +export type CoachAnchorId = (typeof COACH_ANCHORS)[number]; + +/** The rail is always on screen, so a rail anchor is a safe last choice. */ +export function isRailAnchor(id: CoachAnchorId): boolean { + return id.startsWith("rail-"); +} + +export const ANCHOR_ATTRIBUTE = "data-tip-anchor"; + +/** A closing coach mark plays its exit this long; its owner unmounts it after. */ +export const COACH_MARK_EXIT_MS = 180; + +export function anchorSelector(id: CoachAnchorId): string { + return `[${ANCHOR_ATTRIBUTE}="${id}"]`; +} + +export type Side = "top" | "right" | "bottom" | "left"; + +/** + * The attributes that make an element a coach-mark anchor, with the side its + * card prefers. Typed, so an id the coach marks do not know fails to compile. + */ +export function tipAnchor( + id: CoachAnchorId, + side?: Side, +): Record { + return side + ? { [ANCHOR_ATTRIBUTE]: id, "data-tip-side": side } + : { [ANCHOR_ATTRIBUTE]: id }; +} + +export interface Box { + x: number; + y: number; + width: number; + height: number; +} + +export interface Size { + width: number; + height: number; +} + +export interface Placement { + side: Side; + x: number; + y: number; + /** Where the arrow meets the card, along the edge that faces the anchor. */ + arrow: number; + /** False when no side had room and the card was pushed over the anchor. */ + fits: boolean; +} + +const OPPOSITE: Record = { + top: "bottom", + bottom: "top", + left: "right", + right: "left", +}; + +const PERPENDICULAR: Record = { + top: ["right", "left"], + bottom: ["right", "left"], + left: ["bottom", "top"], + right: ["bottom", "top"], +}; + +function clamp(value: number, min: number, max: number): number { + return Math.min(Math.max(value, min), Math.max(min, max)); +} + +/** The room between the anchor and the viewport edge on one side. */ +function room(anchor: Box, viewport: Size, side: Side): number { + switch (side) { + case "top": + return anchor.y; + case "bottom": + return viewport.height - (anchor.y + anchor.height); + case "left": + return anchor.x; + case "right": + return viewport.width - (anchor.x + anchor.width); + } +} + +/** + * Puts a card of `card` size next to `anchor`, `gap` away, inside the + * viewport with `margin` to spare. The preferred side wins when the card + * fits there; then the opposite side, then the two others. Along the edge the + * card centers on the anchor and slides to stay on screen, and `arrow` keeps + * pointing at the anchor's middle, never closer than `arrowInset` to a corner. + */ +export function placeCoachMark( + anchor: Box, + card: Size, + viewport: Size, + { + side: preferred = "bottom", + gap = 12, + margin = 8, + arrowInset = 16, + }: { + side?: Side; + gap?: number; + margin?: number; + arrowInset?: number; + } = {}, +): Placement { + const order: Side[] = [ + preferred, + OPPOSITE[preferred], + ...PERPENDICULAR[preferred], + ]; + const needs = (side: Side) => + (side === "top" || side === "bottom" ? card.height : card.width) + + gap + + margin; + const chosen = + order.find((side) => room(anchor, viewport, side) >= needs(side)) ?? null; + const side = + chosen ?? + order.reduce((best, side) => + room(anchor, viewport, side) - needs(side) > + room(anchor, viewport, best) - needs(best) + ? side + : best, + ); + + const centerX = anchor.x + anchor.width / 2; + const centerY = anchor.y + anchor.height / 2; + let x: number; + let y: number; + if (side === "top" || side === "bottom") { + x = clamp( + centerX - card.width / 2, + margin, + viewport.width - margin - card.width, + ); + y = + side === "bottom" + ? anchor.y + anchor.height + gap + : anchor.y - gap - card.height; + y = clamp(y, margin, viewport.height - margin - card.height); + } else { + y = clamp( + centerY - card.height / 2, + margin, + viewport.height - margin - card.height, + ); + x = + side === "right" + ? anchor.x + anchor.width + gap + : anchor.x - gap - card.width; + x = clamp(x, margin, viewport.width - margin - card.width); + } + + const along = side === "top" || side === "bottom"; + const length = along ? card.width : card.height; + const arrow = clamp( + (along ? centerX - x : centerY - y) || length / 2, + Math.min(arrowInset, length / 2), + length - Math.min(arrowInset, length / 2), + ); + return { side, x, y, arrow, fits: chosen !== null }; +} + +/** + * The part of `box` that is not clipped by `clips` (scroll containers and + * the viewport), or null when nothing of it shows. + */ +export function visiblePart(box: Box, clips: readonly Box[]): Box | null { + let left = box.x; + let top = box.y; + let right = box.x + box.width; + let bottom = box.y + box.height; + for (const clip of clips) { + left = Math.max(left, clip.x); + top = Math.max(top, clip.y); + right = Math.min(right, clip.x + clip.width); + bottom = Math.min(bottom, clip.y + clip.height); + } + if (right - left < 1 || bottom - top < 1) return null; + return { x: left, y: top, width: right - left, height: bottom - top }; +} + +/** + * An anchor counts as on screen when most of it shows. A column header half + * scrolled away is not a place to point at. + */ +export function mostlyVisible(box: Box, clips: readonly Box[]): boolean { + const part = visiblePart(box, clips); + if (!part || box.width <= 0 || box.height <= 0) return false; + return (part.width * part.height) / (box.width * box.height) >= 0.6; +} diff --git a/src/lib/onboarding-signal.ts b/src/lib/onboarding-signal.ts index aec6a94b..f1152741 100644 --- a/src/lib/onboarding-signal.ts +++ b/src/lib/onboarding-signal.ts @@ -11,13 +11,3 @@ export function setOnboardingActive(value: boolean): void { export function isOnboardingActive(): boolean { return active; } - -// Dispatched on `window` when the product tour reaches its end and the user -// clicks "Finish" (not when they skip early). The page listens for it to show -// the celebratory thank-you dialog. -export const ONBOARDING_TOUR_FINISHED_EVENT = "donut:onboarding-tour-finished"; - -// Dispatched when the product tour is either finished or explicitly skipped. -// This is separate from the finished event because both outcomes complete the -// one-shot onboarding, while only a full finish earns the celebration. -export const ONBOARDING_TOUR_CLOSED_EVENT = "donut:onboarding-tour-closed"; diff --git a/src/lib/tips.test.mjs b/src/lib/tips.test.mjs index a032b12e..a1742f43 100644 --- a/src/lib/tips.test.mjs +++ b/src/lib/tips.test.mjs @@ -3,6 +3,7 @@ import { readdirSync, readFileSync } from "node:fs"; import path from "node:path"; import test from "node:test"; import { fileURLToPath } from "node:url"; +import { COACH_ANCHORS, isRailAnchor } from "./coach-mark.ts"; import { actionStaysOn, inlineTipCandidates, @@ -318,3 +319,28 @@ test("the pick skips tips in use and gives up when nothing is left", () => { null, ); }); + +test("every tip points somewhere, and its last anchor is always on the Profiles page", () => { + for (const tip of TIPS) { + assert.ok(tip.anchors.length > 0, `${tip.id} has an anchor`); + for (const anchor of tip.anchors) { + assert.ok(COACH_ANCHORS.includes(anchor), `${tip.id}: ${anchor}`); + } + assert.equal( + new Set(tip.anchors).size, + tip.anchors.length, + `${tip.id} lists each anchor once`, + ); + const last = tip.anchors.at(-1); + assert.ok( + isRailAnchor(last) || last === "profile-search", + `${tip.id} falls back to the rail or the search box, not ${last}`, + ); + } + const dns = TIPS.find((tip) => tip.id === "dnsBlocklist"); + assert.deepEqual( + dns.anchors, + ["column-dns", "rail-settings"], + "a feature on the page is pointed at before the page that holds its settings", + ); +}); diff --git a/src/lib/tips.ts b/src/lib/tips.ts index 7b86462f..3f9e58ba 100644 --- a/src/lib/tips.ts +++ b/src/lib/tips.ts @@ -1,4 +1,5 @@ import type { AppPage } from "@/components/rail-nav"; +import type { CoachAnchorId } from "@/lib/coach-mark"; import type { BrowserProfile, Entitlements } from "@/types"; /** @@ -139,6 +140,12 @@ export interface TipDefinition { id: TipId; section: TipSection; action: TipAction; + /** + * Where the day's tip points on the Profiles page, best first: the feature + * itself when it is on the page (a column, the search box), then the rail + * item that leads to it. The last one is always on screen. + */ + anchors: readonly CoachAnchorId[]; requires?: TipRequirement; /** * The feature changes team data, which a launcher seat may not do, so the @@ -146,7 +153,7 @@ export interface TipDefinition { */ editsTeamData?: true; /** - * True when the user already uses the feature, so the inline tip skips it. + * True when the user already uses the feature, so the day's tip skips it. * Every tip has one; a fact the app cannot read counts as "not in use". */ inUse: (usage: FeatureUsage) => boolean; @@ -173,8 +180,8 @@ function noted(feature: NotedFeature) { /** * Every tip, in the order the catalog lists them: grouped by section, and * the plan tips last, each one skipped for a plan that lacks the capability. - * The inline tip does not follow this order: it picks at random (see - * `pickInlineTip`). + * The day's tip on the Profiles page does not follow this order: it picks at + * random (see `pickInlineTip`). * * The copy lives under `tips.items.` in every locale: `label`, `title`, * `body` and `action`. `src/lib/tips.test.mjs` checks that no tip is missing its text. @@ -184,18 +191,21 @@ export const TIPS: readonly TipDefinition[] = [ id: "commandPalette", section: "everyday", action: { kind: "palette" }, + anchors: ["profile-search"], inUse: noted("commandPalette"), }, { id: "searchSyntax", section: "everyday", action: { kind: "search", query: "proxy:none" }, + anchors: ["profile-search"], inUse: noted("searchSyntax"), }, { id: "shortcuts", section: "everyday", action: { kind: "page", page: "shortcuts" }, + anchors: ["rail-more"], inUse: noted("shortcuts"), }, { @@ -203,6 +213,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "everyday", editsTeamData: true, action: { kind: "page", page: "groups" }, + anchors: ["group-strip", "rail-groups"], inUse: (usage) => usage.groupCount > 0 || anyProfile(usage, (profile) => !!profile.group_id), @@ -212,6 +223,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "everyday", editsTeamData: true, action: { kind: "page", page: "profiles" }, + anchors: ["column-state", "row-inspect", "rail-profiles"], inUse: (usage) => anyProfile(usage, (profile) => !!profile.status_id), }, { @@ -219,6 +231,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "everyday", editsTeamData: true, action: { kind: "createProfile" }, + anchors: ["new-profile", "row-inspect", "rail-profiles"], inUse: (usage) => usage.templatesSaved === true, }, { @@ -226,6 +239,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "everyday", editsTeamData: true, action: { kind: "page", page: "extensions" }, + anchors: ["column-ext", "rail-extensions"], inUse: (usage) => usage.extensionGroupCount > 0 || anyProfile(usage, (profile) => !!profile.extension_group_id), @@ -235,12 +249,14 @@ export const TIPS: readonly TipDefinition[] = [ section: "everyday", editsTeamData: true, action: { kind: "page", page: "import" }, + anchors: ["rail-more"], inUse: noted("importProfiles"), }, { id: "proxyCheck", section: "network", action: { kind: "page", page: "proxies" }, + anchors: ["column-proxy", "rail-proxies"], inUse: (usage) => usage.proxyChecked === true, }, { @@ -248,6 +264,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "network", editsTeamData: true, action: { kind: "page", page: "vpns" }, + anchors: ["rail-proxies"], inUse: (usage) => usage.vpnCount > 0 || anyProfile(usage, (profile) => !!profile.vpn_id), }, @@ -256,18 +273,21 @@ export const TIPS: readonly TipDefinition[] = [ section: "network", editsTeamData: true, action: { kind: "settings", section: "dns" }, + anchors: ["column-dns", "rail-settings"], inUse: (usage) => anyProfile(usage, (profile) => !!profile.dns_blocklist), }, { id: "fingerprintGate", section: "network", action: { kind: "settings", section: "advanced" }, + anchors: ["rail-settings"], inUse: (usage) => usage.fingerprintGateChanged === true, }, { id: "requireRoute", section: "network", action: { kind: "settings", section: "privacy" }, + anchors: ["rail-settings"], inUse: (usage) => usage.routeRequired === true, }, { @@ -275,6 +295,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "privacy", editsTeamData: true, action: { kind: "page", page: "profiles" }, + anchors: ["row-inspect", "rail-profiles"], inUse: (usage) => anyProfile(usage, (profile) => profile.password_protected === true), }, @@ -283,6 +304,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "privacy", editsTeamData: true, action: { kind: "page", page: "profiles" }, + anchors: ["row-inspect", "rail-profiles"], inUse: (usage) => anyProfile(usage, (profile) => profile.clear_on_close === true), }, @@ -291,6 +313,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "privacy", editsTeamData: true, action: { kind: "totp" }, + anchors: ["row-inspect", "rail-profiles"], inUse: noted("totp"), }, { @@ -298,36 +321,42 @@ export const TIPS: readonly TipDefinition[] = [ section: "privacy", editsTeamData: true, action: { kind: "page", page: "trash" }, + anchors: ["rail-more"], inUse: (usage) => usage.trashUsed === true, }, { id: "selfHostedSync", section: "privacy", action: { kind: "page", page: "account" }, + anchors: ["rail-account"], inUse: (usage) => usage.syncServerConfigured || syncsProfiles(usage), }, { id: "defaultBrowser", section: "connect", action: { kind: "settings", section: "default" }, + anchors: ["rail-settings"], inUse: (usage) => usage.isDefaultBrowser === true, }, { id: "localApi", section: "connect", action: { kind: "page", page: "integrations" }, + anchors: ["rail-integrations"], inUse: (usage) => usage.apiEnabled === true, }, { id: "feedback", section: "connect", action: { kind: "feedback" }, + anchors: ["rail-more"], inUse: (usage) => usage.feedbackSent === true, }, { id: "cloudBackup", section: "plan", action: { kind: "page", page: "account" }, + anchors: ["rail-account"], requires: "cloudBackup", inUse: syncsProfiles, }, @@ -335,6 +364,7 @@ export const TIPS: readonly TipDefinition[] = [ id: "cookieBot", section: "plan", action: { kind: "page", page: "cookieBot" }, + anchors: ["rail-cookieBot"], requires: "cookieBot", inUse: (usage) => usage.cookieBotEnrolled, }, @@ -343,6 +373,7 @@ export const TIPS: readonly TipDefinition[] = [ section: "plan", editsTeamData: true, action: { kind: "page", page: "profiles" }, + anchors: ["row-inspect", "new-profile", "rail-profiles"], requires: "crossOsFingerprints", inUse: (usage) => anyProfile(usage, (profile) => { @@ -355,6 +386,7 @@ export const TIPS: readonly TipDefinition[] = [ id: "synchronizer", section: "plan", action: { kind: "page", page: "profiles" }, + anchors: ["row-inspect", "rail-profiles"], requires: "crossOsFingerprints", inUse: noted("synchronizer"), }, @@ -362,6 +394,7 @@ export const TIPS: readonly TipDefinition[] = [ id: "automation", section: "plan", action: { kind: "page", page: "integrations" }, + anchors: ["rail-integrations"], requires: "browserAutomation", inUse: (usage) => usage.apiEnabled === true, }, @@ -369,6 +402,7 @@ export const TIPS: readonly TipDefinition[] = [ id: "agent", section: "plan", action: { kind: "page", page: "agent" }, + anchors: ["rail-agent"], requires: "remoteControl", inUse: (usage) => usage.agentUsed === true, }, @@ -376,6 +410,7 @@ export const TIPS: readonly TipDefinition[] = [ id: "team", section: "plan", action: { kind: "page", page: "account" }, + anchors: ["rail-account"], requires: "teamCollaboration", inUse: (usage) => usage.inTeam, }, @@ -383,12 +418,13 @@ export const TIPS: readonly TipDefinition[] = [ id: "remoteControl", section: "plan", action: { kind: "page", page: "integrations" }, + anchors: ["rail-integrations"], requires: "remoteControl", inUse: (usage) => usage.remoteControlEnabled === true, }, ]; -/** How long the profiles page has to sit quiet before the inline tip enters. */ +/** How long the Profiles page has to sit quiet before the day's tip enters. */ export const INLINE_TIP_DELAY_MS = 4000; /** @@ -467,7 +503,7 @@ export function pickInlineTip( /** * Whether the action would only lead to `page`, where the person already is. - * The inline tip on that page then shows no action button: the copy says + * The coach mark on that page then shows no action button: it points at * where the feature is. */ export function actionStaysOn(action: TipAction, page: AppPage): boolean {