feat: coach-mark tips

This commit is contained in:
zhom committed 2026-10-18 03:53:27 +04:00
1 parent e2f449dfdf
commit c3b2d69c21
40 files changed
+3738 -1133

No files matched your search

+63 -28
View File
@@ -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="<id>"`, 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.<id>.{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.<id>.{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`)
+14 -16
View File
@@ -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);
+332 -37
View File
@@ -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");
+1 -2
View File
@@ -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",
-20
View File
@@ -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
+40 -58
View File
@@ -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");
}}
/>
<InlineTip
<TipCoachMark
flow={tipsFlow}
page={currentPage}
onAction={runTipAction}
@@ -2776,6 +2748,12 @@ export default function Home() {
needsSetup={profiles.length === 0}
onComplete={handleWelcomeComplete}
/>
<OnboardingTour
open={tour.open}
step={tour.step}
onSkip={skipTour}
onFinish={finishTour}
/>
<ThankYouDialog
isOpen={thankYouOpen}
onClose={() => 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);
}}
/>
<PaidWelcomeDialog
open={tipsFlow.paidWelcome.open}
+1 -4
View File
@@ -4,7 +4,6 @@ import { invoke } from "@tauri-apps/api/core";
import { MotionConfig } from "motion/react";
import { useEffect } from "react";
import { I18nProvider } from "@/components/i18n-provider";
import { OnboardingProvider } from "@/components/onboarding-provider";
import { CustomThemeProvider } from "@/components/theme-provider";
import { Toaster } from "@/components/ui/sonner";
import { TooltipProvider } from "@/components/ui/tooltip";
@@ -41,9 +40,7 @@ export function ClientProviders({ children }: { children: React.ReactNode }) {
<InputModalityProvider>
<WindowDragArea />
<TooltipProvider>
<LaunchActivityProvider>
<OnboardingProvider>{children}</OnboardingProvider>
</LaunchActivityProvider>
<LaunchActivityProvider>{children}</LaunchActivityProvider>
</TooltipProvider>
<Toaster />
</InputModalityProvider>
+3 -1
View File
@@ -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 = ({
<button
key="__all__"
type="button"
{...tipAnchor("group-strip")}
aria-current={active ? "true" : undefined}
onClick={() => {
onGroupSelect(ALL_FILTER_ID);
@@ -334,7 +336,7 @@ const HomeHeader = ({
<span className="shrink-0">
<Button
size="sm"
data-onborda="create-profile"
{...tipAnchor("new-profile")}
onClick={() => {
onCreateProfileDialogOpen(true);
}}
-117
View File
@@ -1,117 +0,0 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import type { CardComponentProps } from "onborda";
import { useOnborda } from "onborda";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/ui/button";
import { useInputModality } from "@/hooks/use-input-modality";
import {
ONBOARDING_TOUR_CLOSED_EVENT,
ONBOARDING_TOUR_FINISHED_EVENT,
} from "@/lib/onboarding-signal";
// Custom Onborda card, themed with the app's CSS variables. Closing always
// persists completion; finishing the last step also asks the page to celebrate.
export function OnboardingCard({
step,
currentStep,
totalSteps,
nextStep,
prevStep,
arrow,
}: CardComponentProps) {
const { t } = useTranslation();
const { closeOnborda } = useOnborda();
const reduceMotion = useReducedMotion();
const inputModality = useInputModality();
const animateEntry = !reduceMotion && inputModality === "pointer";
const isFirst = currentStep === 0;
const isLast = currentStep === totalSteps - 1;
// This step is completed by clicking the highlighted element (the "New"
// button), not by a "Next" button — advancing manually would jump to a step
// whose target doesn't exist yet and block the button. So hide "Next" here.
const requiresAction = step.selector === '[data-onborda="create-profile"]';
const closeTour = () => {
closeOnborda();
window.dispatchEvent(new Event(ONBOARDING_TOUR_CLOSED_EVENT));
};
return (
<motion.div
initial={animateEntry ? { scale: 0.98 } : false}
animate={{ scale: 1 }}
transition={
animateEntry
? { type: "spring", stiffness: 300, damping: 30 }
: { duration: 0 }
}
className="relative flex w-80 max-w-[calc(100vw-2rem)] flex-col gap-4 rounded-lg border bg-popover p-4 text-popover-foreground shadow-lg"
>
<div className="flex items-start justify-between gap-2">
<h3 className="text-base font-semibold text-balance sm:text-sm">
{step.title}
</h3>
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">
{currentStep + 1}/{totalSteps}
</span>
</div>
<div className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{step.content}
</div>
<div className="flex items-center justify-between gap-2">
{isLast ? (
<span />
) : (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={closeTour}
>
{t("onboarding.buttons.skip")}
</Button>
)}
<div className="flex items-center gap-2">
{!isFirst && !isLast && (
<Button
variant="outline"
size="sm"
onClick={() => {
prevStep();
}}
>
{t("onboarding.buttons.back")}
</Button>
)}
{isLast ? (
<Button
size="sm"
onClick={() => {
closeTour();
window.dispatchEvent(new Event(ONBOARDING_TOUR_FINISHED_EVENT));
}}
>
{t("onboarding.buttons.finish")}
</Button>
) : requiresAction ? null : (
<Button
size="sm"
onClick={() => {
nextStep();
}}
>
{t("onboarding.buttons.next")}
</Button>
)}
</div>
</div>
<span className="text-popover">{arrow}</span>
</motion.div>
);
}
-67
View File
@@ -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 (
<OnbordaProvider>
<Onborda
steps={tours}
cardComponent={OnboardingCard}
interact
shadowRgb="0,0,0"
shadowOpacity="0.55"
cardTransition={{ type: "spring", bounce: 0, duration: 0.35 }}
>
{children}
</Onborda>
</OnbordaProvider>
);
}
+126
View File
@@ -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 (
<CoachMark
open={open}
anchors={current.anchors}
spotlight
floatWithoutAnchor
stepKey={step}
onDismiss={onSkip}
announcement={t(`onboarding.steps.${current.key}.title`)}
data-slot="onboarding-tour"
data-step={step}
>
<CoachMarkScene>
<div key={step} className="absolute inset-0 p-2">
{step === 0 ? <NewProfileScene /> : <DnsScene />}
</div>
</CoachMarkScene>
<StepTransition transitionKey={step} direction={1}>
<CoachMarkHeader
eyebrow={t("onboarding.stepOf", {
current: step + 1,
total: STEPS.length,
})}
title={t(`onboarding.steps.${current.key}.title`)}
/>
<CoachMarkBody>
{t(`onboarding.steps.${current.key}.content`)}
</CoachMarkBody>
</StepTransition>
<CoachMarkFooter>
<StepDots
count={STEPS.length}
current={step}
label={t("onboarding.progress")}
className="ps-1"
/>
{last ? (
<Button
type="button"
size="sm"
data-slot="onboarding-tour-finish"
disabled={!open}
onClick={onFinish}
>
{t("onboarding.buttons.finish")}
</Button>
) : (
<Button
type="button"
size="sm"
variant="subtle"
data-slot="onboarding-tour-skip"
disabled={!open}
onClick={onSkip}
>
{t("onboarding.buttons.skip")}
</Button>
)}
</CoachMarkFooter>
</CoachMark>
);
}
+927
View File
@@ -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 = <T,>(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 (
<svg
data-slot="welcome-scene"
data-scene={slot}
viewBox={`0 0 ${WIDE_W} ${WIDE_H}`}
className={cn("h-full w-full text-muted-foreground", className)}
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
{children}
</svg>
);
}
/** 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 (
<g transform={`rotate(${turn} ${cx} ${cy})`}>
{[3, 6, 9].map((r, index) => (
<motion.path
key={r}
d={`M${cx - r} ${cy + 1} a${r} ${r} 0 1 1 ${r * 2} 0`}
initial={drawn ? { pathLength: 0 } : false}
animate={{ pathLength: 1 }}
transition={{
...transition,
delay:
((transition.delay as number | undefined) ?? 0) + index * 0.08,
}}
/>
))}
</g>
);
}
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 (
<WideScene slot="intro">
<motion.circle
cx={DONUT.x}
cy={DONUT.y}
r={42}
className="text-foreground opacity-40"
style={{ rotate: -90, originX: "50%", originY: "50%" }}
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={enter(0.15, 1.1)}
/>
<motion.g
className="text-foreground"
style={{ originX: "50%", originY: "50%" }}
initial={reduce ? false : { scale: 0.6, rotate: -40, opacity: 0 }}
animate={{ scale: 1, rotate: 0, opacity: 1 }}
transition={
reduce
? { duration: 0 }
: { type: "spring", stiffness: 180, damping: 16, mass: 0.8 }
}
>
<Logo
x={DONUT.x - 32}
y={DONUT.y - 32}
width={64}
height={64}
aria-hidden="true"
/>
</motion.g>
{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 (
<g key={profile.y}>
<motion.path
d={path}
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={enter(start, 0.6)}
/>
<motion.g
initial={reduce ? false : { x: -14, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={enter(start + 0.3, 0.6)}
>
<rect
x={WINDOW_X}
y={profile.y}
width={WINDOW_W}
height={WINDOW_H}
rx={7}
className="text-foreground"
/>
{[0, 1, 2].map((dot) => (
<circle
key={dot}
cx={WINDOW_X + 9 + dot * 6}
cy={profile.y + 8}
r={1.3}
fill="currentColor"
stroke="none"
/>
))}
<g className="text-foreground">
<Fingerprint
cx={WINDOW_X + 18}
cy={profile.y + 23}
turn={profile.turn}
drawn={!reduce}
transition={enter(start + 0.6, 0.5)}
/>
</g>
<path
d={`M${WINDOW_X + 38} ${profile.y + 18} h${64 - index * 10}`}
strokeWidth={2}
/>
<path
d={`M${WINDOW_X + 38} ${profile.y + 27} h${40 + index * 8}`}
strokeWidth={2}
className="opacity-60"
/>
<g className="text-foreground">
<Globe
cx={WINDOW_X + WINDOW_W - 20}
cy={profile.y + WINDOW_H / 2}
r={9}
/>
</g>
<motion.circle
cx={WINDOW_X + WINDOW_W - 20 + profile.pin.x}
cy={profile.y + WINDOW_H / 2 + profile.pin.y}
r={2.4}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
style={{ originX: "50%", originY: "50%" }}
transition={pop(start + 0.9)}
/>
</motion.g>
{!reduce && (
<motion.circle
r={3}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={{ opacity: 0, cx: signal.cx[0], cy: signal.cy[0] }}
animate={{
cx: signal.cx,
cy: signal.cy,
opacity: kf(
signal.times.map((t) => (t > 0.06 && t < 0.48 ? 1 : 0)),
),
}}
transition={ambient(3.4, 2 + index * 0.5, signal.times, {
ease: "linear",
repeatDelay: 0.6,
})}
/>
)}
</g>
);
})}
</WideScene>
);
}
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 (
<WideScene slot="license">
<motion.g
initial={reduce ? false : { y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={enter(0.05, 0.6)}
>
<rect x={40} y={22} width={180} height={116} rx={12} />
<motion.path
d="M60 46 c-4-5-12-4-12 3 c0 6 8 10 12 13 c4-3 12-7 12-13 c0-7-8-8-12-3z"
className="text-success-text"
fill="currentColor"
fillOpacity={0.18}
initial={false}
animate={{ scale: kf([1, 1.14, 1, 1.08, 1]) }}
style={{ originX: "50%", originY: "50%" }}
transition={ambient(1.4, 1.2, [0, 0.15, 0.3, 0.45, 1], {
repeatDelay: 1.4,
})}
/>
<path d="M86 50 h56" strokeWidth={2} className="text-foreground" />
<path d="M86 60 h34" strokeWidth={2} />
<motion.path
d="M130 96 C150 116 180 116 180 96 C180 76 150 76 130 96 C110 116 80 116 80 96 C80 76 110 76 130 96 Z"
strokeWidth={2}
className="text-foreground"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={enter(0.4, 1.2)}
/>
{!reduce && (
<motion.circle
r={3.2}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={{ cx: LOOP.cx[0], cy: LOOP.cy[0], opacity: 0 }}
animate={{ cx: LOOP.cx, cy: LOOP.cy, opacity: 1 }}
transition={{
cx: ambient(3.2, 1.6, LOOP.times, {
ease: "linear",
repeatDelay: 0,
}),
cy: ambient(3.2, 1.6, LOOP.times, {
ease: "linear",
repeatDelay: 0,
}),
opacity: enter(1.6, 0.3),
}}
/>
)}
</motion.g>
<motion.g
initial={reduce ? false : { y: 10, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={enter(0.18, 0.6)}
>
<rect x={260} y={22} width={180} height={116} rx={12} />
<g className="text-foreground">
<rect x={278} y={40} width={20} height={15} rx={3} />
<path d="M284 40 v-3 h8 v3 M278 47 h20" />
</g>
<path d="M308 44 h56" strokeWidth={2} className="text-foreground" />
<path d="M308 54 h34" strokeWidth={2} />
{CALENDAR.map(({ day, x, y }) => {
const at = 0.04 + day * 0.048;
return (
<g key={day}>
<rect
x={x}
y={y}
width={14}
height={14}
rx={4}
className="opacity-50"
/>
<motion.rect
x={x}
y={y}
width={14}
height={14}
rx={4}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={reduce ? false : { opacity: 0 }}
animate={{ opacity: kf([0, 0, 0.85, 0.85]) }}
transition={ambient(3.2, 0.8, [0, at, at + 0.05, 1], {
repeatType: "mirror",
repeatDelay: 0.5,
})}
/>
</g>
);
})}
<motion.g
className="text-success-text"
initial={reduce ? false : { scale: 0 }}
animate={{ scale: kf([0, 0, 1, 1]) }}
style={{ originX: "50%", originY: "50%" }}
transition={ambient(3.2, 0.8, [0, 0.74, 0.84, 1], {
repeatType: "mirror",
repeatDelay: 0.5,
})}
>
<circle
cx={424}
cy={38}
r={9}
fill="currentColor"
fillOpacity={0.16}
/>
<path d="M419.5 38.5 l3 3 l6 -6" strokeWidth={2} />
</motion.g>
</motion.g>
</WideScene>
);
}
/**
* 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 (
<WideScene slot="permissions">
<motion.g
initial={reduce ? false : { y: 8, opacity: 0 }}
animate={{ y: 0, opacity: 1 }}
transition={enter(0.05, 0.6)}
>
<rect x={104} y={14} width={272} height={132} rx={10} />
<path d="M104 34 H376" />
{[0, 1, 2].map((dot) => (
<circle
key={dot}
cx={116 + dot * 7}
cy={24}
r={1.6}
fill="currentColor"
stroke="none"
/>
))}
<rect
x={150}
y={20}
width={120}
height={8}
rx={4}
className="opacity-60"
/>
<path
d="M124 52 h70 M124 64 h52 M124 120 h96 M124 132 h60"
strokeWidth={2}
className="opacity-60"
/>
<motion.circle
cx={362}
cy={24}
r={3.2}
fill="currentColor"
stroke="none"
className="text-destructive-text"
initial={false}
animate={{ opacity: kf([1, 0, 0, 0, 0, 0, 1, 1]) }}
transition={cycle(CLICK)}
/>
</motion.g>
<motion.g
initial={reduce ? false : { y: -8, scale: 0.94, opacity: 0 }}
animate={{ y: 0, scale: 1, opacity: 1 }}
style={{ originX: "50%", originY: "0%" }}
transition={enter(0.35, 0.6)}
>
<rect
x={214}
y={44}
width={146}
height={70}
rx={9}
className="text-foreground"
style={{ fill: "var(--background)" }}
/>
<g className="text-foreground">
<rect x={229} y={56} width={10} height={16} rx={5} />
<path d="M226 66 a8 8 0 0 0 16 0 M234 74 v4" />
<rect x={252} y={60} width={18} height={12} rx={3} />
<path d="M270 64 l6 -3 v10 l-6 -3" />
</g>
<path d="M286 62 h56 M286 71 h38" strokeWidth={2} />
<rect x={226} y={88} width={52} height={16} rx={5} />
<motion.rect
x={290}
y={88}
width={58}
height={16}
rx={5}
fill="currentColor"
className="text-foreground"
initial={false}
animate={{
scale: kf([1, 1, 1, 1, 1, 0.92, 1, 1]),
opacity: kf([0.9, 0.25, 0.25, 0.25, 0.25, 0.5, 0.9, 0.9]),
}}
style={{ originX: "50%", originY: "50%" }}
transition={cycle(CLICK)}
/>
</motion.g>
{[0, 1, 2].map((wave) => (
<motion.path
key={wave}
d={`M${206 - wave * 8} ${55 - wave * 4} a${12 + wave * 6} ${12 + wave * 6} 0 0 0 0 ${22 + wave * 8}`}
className="text-foreground"
initial={false}
animate={{
opacity: kf([
0.8 - wave * 0.2,
0,
0,
0.8 - wave * 0.2,
0.8 - wave * 0.2,
]),
}}
transition={cycle([
0,
0.1,
0.48 + wave * 0.05,
0.56 + wave * 0.05,
1,
])}
/>
))}
<g className="text-foreground">
<Cursor
animate={{
x: kf([318, 318, 420, 420, 318, 318, 318, 318]),
y: kf([96, 96, 150, 150, 96, 98, 96, 96]),
opacity: kf([1, 0, 0, 1, 1, 1, 1, 1]),
}}
transition={cycle(CLICK)}
/>
</g>
</WideScene>
);
}
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 (
<WideScene slot="setup">
<motion.g
initial={reduce ? false : { x: -10, opacity: 0 }}
animate={{ x: 0, opacity: ready ? 0.4 : 1 }}
transition={enter(0.05, 0.6)}
>
<path d="M86 86 a12 12 0 0 1 3 -23 a18 18 0 0 1 34 -6 a13 13 0 0 1 8 25 z" />
<path d="M108 70 v14 m-5 -5 l5 5 l5 -5" className="text-foreground" />
</motion.g>
{phase === "downloading" &&
!reduce &&
[0, 1, 2].map((packet) => (
<motion.circle
key={packet}
r={2.6}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={{ cx: PACKET.cx[0], cy: PACKET.cy[0], opacity: 0 }}
animate={{
cx: PACKET.cx,
cy: PACKET.cy,
opacity: PACKET.times.map((t) => (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,
}}
/>
))}
<path
d="M132 72 C168 52 186 62 206 74"
strokeDasharray="2 5"
className={cn(ready && "opacity-40")}
/>
<circle cx={240} cy={80} r={38} className="opacity-40" strokeWidth={3} />
<motion.circle
cx={240}
cy={80}
r={38}
strokeWidth={3}
className={failed ? "text-destructive-text" : "text-foreground"}
style={{ rotate: -90, originX: "50%", originY: "50%" }}
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: fill }}
transition={settle}
/>
{phase === "extracting" && !reduce && (
<motion.circle
cx={240}
cy={80}
r={44}
strokeDasharray="3 9"
className="opacity-50"
initial={false}
animate={{ rotate: 360 }}
style={{ originX: "50%", originY: "50%" }}
transition={{
duration: 14,
ease: "linear",
repeat: Number.POSITIVE_INFINITY,
}}
/>
)}
<g className={failed ? "text-destructive-text" : "text-foreground"}>
<motion.circle
cx={240}
cy={80}
r={20}
initial={false}
animate={{ pathLength: Math.max(0.2, globe) }}
transition={settle}
/>
<motion.ellipse
cx={240}
cy={80}
rx={8.4}
ry={20}
initial={false}
animate={{ pathLength: globe }}
transition={settle}
/>
<motion.path
d="M220 80 h40 M222.8 70 q17.2 5.6 34.4 0 M222.8 90 q17.2 -5.6 34.4 0"
initial={false}
animate={{ pathLength: globe }}
transition={settle}
/>
</g>
{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 (
<motion.path
key={ray}
d={`M${x1} ${y1} L${x2} ${y2}`}
strokeWidth={2}
className="text-foreground"
initial={reduce ? false : { pathLength: 0, opacity: 1 }}
animate={
reduce
? { opacity: 0 }
: { pathLength: [0, 1, 1], opacity: [1, 1, 0] }
}
transition={
reduce
? { duration: 0 }
: {
duration: 0.9,
times: [0, 0.4, 1],
ease: MOTION_EASE_OUT,
delay: 0.15,
}
}
/>
);
})}
<motion.g
className="text-success-text"
initial={reduce ? false : { scale: 0 }}
animate={{ scale: 1 }}
style={{ originX: "50%", originY: "50%" }}
transition={pop(0.2)}
>
<circle
cx={268}
cy={52}
r={10}
style={{ fill: "var(--background)" }}
/>
<circle
cx={268}
cy={52}
r={10}
fill="currentColor"
fillOpacity={0.16}
/>
<Check
x={263}
y={52.5}
size={10}
animate={{ pathLength: 1 }}
transition={enter(0.35, 0.4)}
/>
</motion.g>
<motion.g
initial={reduce ? false : { x: -24, opacity: 0 }}
animate={{ x: 0, opacity: 1 }}
transition={enter(0.45, 0.7)}
>
<rect
x={318}
y={54}
width={124}
height={52}
rx={8}
className="text-foreground"
/>
<path d="M318 68 h124" />
<circle
cx={327}
cy={61}
r={1.4}
fill="currentColor"
stroke="none"
/>
<circle
cx={333}
cy={61}
r={1.4}
fill="currentColor"
stroke="none"
/>
<g className="text-foreground">
<Fingerprint
cx={334}
cy={88}
turn={30}
drawn={!reduce}
transition={enter(0.9, 0.5)}
/>
</g>
<path d="M352 84 h56 M352 93 h36" strokeWidth={2} />
</motion.g>
<motion.path
d="M290 80 h22"
strokeDasharray="2 4"
initial={reduce ? false : { pathLength: 0 }}
animate={{ pathLength: 1 }}
transition={enter(0.4, 0.4)}
/>
</>
)}
</WideScene>
);
}
/**
* 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 (
<svg
data-slot="tour-scene"
viewBox="0 0 320 160"
className="h-full w-full text-muted-foreground"
fill="none"
stroke="currentColor"
strokeWidth={1.5}
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<motion.g
className="text-foreground"
initial={false}
animate={{ scale: kf([1, 1, 1, 0.92, 1, 1]) }}
style={{ originX: "50%", originY: "50%" }}
transition={cycle([0, 0.1, 0.22, 0.27, 0.32, 1])}
>
<rect
x={28}
y={66}
width={70}
height={28}
rx={8}
fill="currentColor"
fillOpacity={0.1}
/>
<path d="M44 80 h10 M49 75 v10" strokeWidth={2} />
<path d="M62 80 h24" strokeWidth={2} />
</motion.g>
<motion.path
d="M98 80 C128 80 132 80 150 80"
strokeDasharray="2 4"
initial={false}
animate={{ pathLength: kf([1, 0, 0, 0, 1, 1]) }}
transition={cycle([0, 0.1, 0.12, 0.3, 0.42, 1])}
/>
<motion.g
initial={false}
animate={{
x: kf([0, 0, -30, -30, 0, 0]),
opacity: kf([1, 0, 0, 0, 1, 1]),
scale: kf([1, 1, 0.92, 0.92, 1, 1]),
}}
style={{ originX: "0%", originY: "50%" }}
transition={cycle([0, 0.1, 0.12, 0.32, 0.5, 1])}
>
<rect
x={156}
y={34}
width={140}
height={92}
rx={10}
className="text-foreground"
/>
<path d="M156 54 h140" />
{[0, 1, 2].map((dot) => (
<circle
key={dot}
cx={168 + dot * 7}
cy={44}
r={1.6}
fill="currentColor"
stroke="none"
/>
))}
<g className="text-foreground">
<Fingerprint
cx={182}
cy={84}
turn={20}
drawn={!reduce}
transition={enter(0.6, 0.5)}
/>
</g>
<path d="M202 78 h64 M202 90 h42" strokeWidth={2} />
<Globe cx={276} cy={110} r={8} />
</motion.g>
<g className="text-foreground">
<Cursor
animate={{
x: kf([72, 72, 120, 120, 70, 70, 72, 72]),
y: kf([84, 84, 130, 130, 84, 86, 84, 84]),
opacity: kf([1, 0, 0, 1, 1, 1, 1, 1]),
}}
transition={cycle([0, 0.08, 0.1, 0.14, 0.24, 0.27, 0.32, 1])}
/>
</g>
</svg>
);
}
+17 -1
View File
@@ -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<Record<string, CoachAnchorId>> = {
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({
<PopoverTrigger asChild>
<button
type="button"
data-onborda="dns-blocklist"
{...tipAnchor("row-dns")}
disabled={isSaving}
className="flex h-6 w-full items-center rounded-md px-2 text-left text-sm transition-colors duration-100 hover:bg-foreground/6 disabled:opacity-50"
title={
@@ -3511,6 +3525,7 @@ export function ProfilesDataTable({
className="size-7 rounded-md p-0"
data-slot="profile-inspect-trigger"
data-profile-id={profile.id}
{...tipAnchor("row-inspect")}
disabled={!meta.isClient}
onClick={(event) => {
const method = event.detail === 0 ? "keyboard" : "pointer";
@@ -3846,6 +3861,7 @@ export function ProfilesDataTable({
<TableHead
key={header.id}
role="columnheader"
{...columnAnchor(header.column.id)}
aria-sort={
header.column.getIsSorted() === "asc"
? "ascending"
+2 -2
View File
@@ -78,7 +78,7 @@ export function ProfileIsolationDemo({
</div>
)}
<div className="grid grid-cols-2 gap-3 rounded-xl bg-muted/35 p-2 sm:gap-5 sm:p-3">
<div className="grid grid-cols-2 gap-3 rounded-xl bg-foreground/[0.035] p-2 ring-1 ring-foreground/6 ring-inset sm:gap-5 sm:p-3">
{EXAMPLE_IDS.map((profileId) => {
const profile = examples[profileId];
const active = selected === profileId;
@@ -101,7 +101,7 @@ export function ProfileIsolationDemo({
onClick={() => setSelected(profileId)}
className={cn(
"h-auto min-h-9 w-full justify-start gap-2 px-2 py-2 whitespace-normal",
active && "bg-accent text-accent-foreground",
active && "bg-foreground/6 text-foreground",
)}
>
<LuUserRound className="size-4 shrink-0" aria-hidden="true" />
+2
View File
@@ -19,6 +19,7 @@ import {
LuSearch,
LuX,
} from "react-icons/lu";
import { tipAnchor } from "@/lib/coach-mark";
import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion";
import {
findProfileSearchField,
@@ -764,6 +765,7 @@ export function ProfileSearchInput({
<div
ref={wrapperRef}
data-slot="profile-search"
{...tipAnchor("profile-search")}
className="relative h-7 w-36 shrink-0 rounded-lg bg-foreground/5 transition-[width,background-color,box-shadow] duration-200 ease-out focus-within:w-56 focus-within:bg-foreground/7 focus-within:ring-2 focus-within:ring-foreground/15 hover:bg-foreground/7 motion-reduce:transition-none min-[860px]:w-52 min-[860px]:focus-within:w-80"
>
<LuSearch className="pointer-events-none absolute top-1/2 left-2.5 size-3.5 -translate-y-1/2 text-muted-foreground" />
+50 -9
View File
@@ -21,6 +21,7 @@ import {
LuUsers,
} from "react-icons/lu";
import { useInputModality } from "@/hooks/use-input-modality";
import { type CoachAnchorId, tipAnchor } from "@/lib/coach-mark";
import { launchDonutClone } from "@/lib/donut-physics";
import { MOTION_SPRING_POSITION } from "@/lib/motion";
import { cn } from "@/lib/utils";
@@ -253,17 +254,54 @@ interface RailItem {
page: AppPage;
Icon: React.ComponentType<{ className?: string }>;
labelKey: string;
/** Where a feature tip points when its feature lives on this page. */
anchor: CoachAnchorId;
}
const TOP_ITEMS: RailItem[] = [
{ page: "profiles", Icon: LuUser, labelKey: "rail.profiles" },
{ page: "proxies", Icon: FiWifi, labelKey: "rail.network" },
{ page: "extensions", Icon: LuPuzzle, labelKey: "rail.extensions" },
{ page: "groups", Icon: LuUsers, labelKey: "rail.groups" },
{ page: "cookieBot", Icon: LuCookie, labelKey: "rail.cookieBot" },
{ page: "agent", Icon: LuBot, labelKey: "rail.agent" },
{ page: "integrations", Icon: LuPlug, labelKey: "rail.integrations" },
{ page: "account", Icon: LuCloud, labelKey: "rail.account" },
{
page: "profiles",
Icon: LuUser,
labelKey: "rail.profiles",
anchor: "rail-profiles",
},
{
page: "proxies",
Icon: FiWifi,
labelKey: "rail.network",
anchor: "rail-proxies",
},
{
page: "extensions",
Icon: LuPuzzle,
labelKey: "rail.extensions",
anchor: "rail-extensions",
},
{
page: "groups",
Icon: LuUsers,
labelKey: "rail.groups",
anchor: "rail-groups",
},
{
page: "cookieBot",
Icon: LuCookie,
labelKey: "rail.cookieBot",
anchor: "rail-cookieBot",
},
{ page: "agent", Icon: LuBot, labelKey: "rail.agent", anchor: "rail-agent" },
{
page: "integrations",
Icon: LuPlug,
labelKey: "rail.integrations",
anchor: "rail-integrations",
},
{
page: "account",
Icon: LuCloud,
labelKey: "rail.account",
anchor: "rail-account",
},
];
interface MoreMenuItem {
@@ -381,7 +419,7 @@ export function RailNav({
<div className="my-1 h-px w-5 shrink-0 bg-border" />
<div className="flex min-h-0 w-full scrollbar-none flex-col items-center gap-1 overflow-y-auto [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden">
{TOP_ITEMS.map(({ page, Icon, labelKey }) => {
{TOP_ITEMS.map(({ page, Icon, labelKey, anchor }) => {
const active = currentPage === page;
const waiting = page === "agent" ? agentRequests : 0;
const label =
@@ -395,6 +433,7 @@ export function RailNav({
<TooltipTrigger asChild>
<button
type="button"
{...tipAnchor(anchor, "right")}
onClick={() => {
onNavigate(page);
}}
@@ -438,6 +477,7 @@ export function RailNav({
<TooltipTrigger asChild>
<button
type="button"
{...tipAnchor("rail-more", "right")}
onClick={() => {
setMoreOpen((v) => !v);
}}
@@ -460,6 +500,7 @@ export function RailNav({
<TooltipTrigger asChild>
<button
type="button"
{...tipAnchor("rail-settings", "right")}
onClick={() => {
onNavigate("settings");
}}
+57 -17
View File
@@ -3,7 +3,12 @@
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { type KeyboardEvent, useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuChevronLeft, LuChevronRight, LuX } from "react-icons/lu";
import {
LuChevronLeft,
LuChevronRight,
LuCrosshair,
LuX,
} from "react-icons/lu";
import { TipScene } from "@/components/tips/scene-for";
import { SCENE_GRID_CLASS } from "@/components/tips/scene-primitives";
import { TipIcon } from "@/components/tips/tip-icon";
@@ -41,6 +46,8 @@ interface TipsDialogProps {
onTipShown: (id: TipId) => void;
onAutoShowChange: (enabled: boolean) => void;
onAction: (action: TipAction) => void;
/** Closes the catalog and points the tip's coach mark at its feature. */
onShowMe: (id: TipId) => void;
}
function isTypingTarget(target: EventTarget | null): boolean {
@@ -56,6 +63,8 @@ function isTypingTarget(target: EventTarget | null): boolean {
* the ones not read yet marked. Each tip is a drawing of the feature at
* work, a few lines on what it does, and a button into the place it lives.
* The arrows page in either direction and the drawing travels the same way.
* "Show me" closes the catalog and points the tip's coach mark at the
* feature on the Profiles page.
*/
export function TipsDialog({
open,
@@ -67,6 +76,7 @@ export function TipsDialog({
onTipShown,
onAutoShowChange,
onAction,
onShowMe,
}: TipsDialogProps) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
@@ -248,14 +258,28 @@ export function TipsDialog({
<span className="min-w-0 flex-1 truncate">
{t(tipTextKeys(item.id).label)}
</span>
{!read && !active && (
<span
role="img"
aria-label={t("tips.unread")}
data-slot="tips-unread-dot"
className="size-1.5 shrink-0 rounded-full bg-primary"
/>
)}
<AnimatePresence initial={false}>
{!read && !active && (
<motion.span
role="img"
aria-label={t("tips.unread")}
data-slot="tips-unread-dot"
className="size-1.5 shrink-0 rounded-full bg-primary"
exit={
animate
? { scale: 0, opacity: 0 }
: {
opacity: 0,
transition: { duration: 0 },
}
}
transition={{
duration: 0.2,
ease: MOTION_EASE_OUT,
}}
/>
)}
</AnimatePresence>
</button>
</li>
);
@@ -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"
>
<div className="px-5 pt-5">
<div className="px-5 pt-5 [@media(max-height:640px)]:pt-4">
<div
data-slot="tip-scene-panel"
className={cn(
"relative aspect-[2/1] max-h-64 w-full overflow-hidden rounded-xl bg-foreground/[0.025] ring-1 ring-foreground/6 ring-inset",
"relative aspect-[2/1] max-h-64 w-full overflow-hidden rounded-xl bg-foreground/[0.025] ring-1 ring-foreground/6 ring-inset [@media(max-height:640px)]:max-h-40",
SCENE_GRID_CLASS,
)}
>
@@ -298,14 +322,20 @@ export function TipsDialog({
<motion.div
key={tip.id}
className="absolute inset-0 p-4"
initial={animate ? { x: direction * 40 } : false}
animate={{ x: 0, opacity: 1 }}
initial={
animate ? { x: direction * 36, scale: 0.98 } : false
}
animate={{ x: 0, scale: 1, opacity: 1 }}
exit={
animate
? { x: -direction * 40, opacity: 0 }
? { x: -direction * 36, scale: 0.98, opacity: 0 }
: { opacity: 0, transition: { duration: 0 } }
}
transition={{ duration: 0.26, ease: MOTION_EASE_OUT }}
transition={
animate
? { type: "spring", stiffness: 380, damping: 36 }
: { duration: 0 }
}
>
<TipScene id={tip.id} />
</motion.div>
@@ -322,7 +352,7 @@ export function TipsDialog({
<StepTransition
transitionKey={tip.id}
direction={direction}
className="flex flex-col gap-2 px-6 pt-5"
className="flex flex-col gap-2 px-6 pt-5 [@media(max-height:640px)]:pt-3"
>
<p
className={cn(
@@ -341,7 +371,7 @@ export function TipsDialog({
</p>
</StepTransition>
<div className="mt-auto flex flex-wrap items-center justify-between gap-3 px-5 pt-6 pb-5">
<div className="sticky bottom-0 mt-auto flex flex-wrap items-center justify-between gap-3 bg-background px-5 pt-5 pb-5 [@media(max-height:640px)]:pt-3 [@media(max-height:640px)]:pb-3">
<div className="flex items-center gap-0.5">
<Button
type="button"
@@ -375,6 +405,16 @@ export function TipsDialog({
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="subtle"
size="sm"
data-slot="tip-show-me"
onClick={() => onShowMe(tip.id)}
>
<LuCrosshair className="size-3.5" />
{t("tips.showMe")}
</Button>
<Button
type="button"
variant="soft"
-208
View File
@@ -1,208 +0,0 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuEllipsis, LuX } from "react-icons/lu";
import type { AppPage } from "@/components/rail-nav";
import { TipScene } from "@/components/tips/scene-for";
import {
SCENE_GRID_CLASS,
ScenePlayback,
} from "@/components/tips/scene-primitives";
import { TipIcon } from "@/components/tips/tip-icon";
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
} from "@/components/ui/dropdown-menu";
import { INLINE_TIP_EXIT_MS, type TipsFlow } from "@/hooks/use-tips";
import { MOTION_EASE_OUT } from "@/lib/motion";
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";
import { cn } from "@/lib/utils";
/** Tips that made their entrance this session; a page switch does not replay it. */
const entered = new Set<string>();
/** 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, as a slim card under the profile table. It takes its own
* row, so it never covers a profile, and it never takes the focus. It comes
* in once with a short rise, plays its drawing for a few loops, and leaves
* when it is dismissed, acted on, or no longer needed.
*/
export function InlineTip({
flow,
page,
onAction,
}: {
flow: TipsFlow;
page: AppPage;
onAction: (action: TipAction) => void;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion() ?? false;
const current = flow.inlineTip;
const id = current?.tip.id;
const [intro, setIntro] = useState(true);
const [hovered, setHovered] = useState(false);
const firstEntrance = id !== undefined && !entered.has(id);
useEffect(() => {
if (!id) return;
entered.add(id);
setIntro(true);
const timer = window.setTimeout(() => setIntro(false), SCENE_PLAY_MS);
return () => window.clearTimeout(timer);
}, [id]);
if (!current) return null;
const { tip, leaving } = current;
const keys = tipTextKeys(tip.id);
const mod = isMacOS() ? "⌘" : "Ctrl";
return (
<motion.aside
key={tip.id}
data-slot="inline-tip"
data-tip-id={tip.id}
data-state={leaving ? "leaving" : "shown"}
aria-label={t("tips.inline.label")}
className="@container mt-2 mb-2.5 shrink-0 [@media(max-height:420px)]:hidden"
initial={firstEntrance && !reduceMotion ? { y: 14, scale: 0.985 } : false}
animate={
leaving
? reduceMotion
? { opacity: 0 }
: { y: 8, scale: 0.985, opacity: 0 }
: { y: 0, scale: 1, opacity: 1 }
}
transition={{
duration: leaving ? (INLINE_TIP_EXIT_MS - 20) / 1000 : 0.42,
ease: MOTION_EASE_OUT,
}}
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
>
<div className="flex items-center gap-3.5 rounded-xl bg-foreground/[0.035] p-2 pr-2.5 ring-1 ring-foreground/5 ring-inset">
<div
aria-hidden="true"
data-slot="inline-tip-scene"
className={cn(
"relative hidden h-16 w-32 shrink-0 overflow-hidden rounded-lg bg-background/70 ring-1 ring-foreground/6 @2xl:block",
"[&_svg_*]:[stroke-width:3.25px] [&_text:not([data-scene-keep])]:hidden",
SCENE_GRID_CLASS,
)}
>
<ScenePlayback playing={!leaving && (intro || hovered)}>
<div className="absolute inset-0 p-1.5">
<TipScene id={tip.id} />
</div>
</ScenePlayback>
</div>
<span
aria-hidden="true"
className="grid size-9 shrink-0 place-items-center rounded-lg bg-primary/10 text-primary-text @2xl:hidden [&_svg]:size-4"
>
<TipIcon id={tip.id} />
</span>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<p className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
<span className="text-primary-text">
{t("tips.inline.eyebrow")}
</span>
<span aria-hidden="true">·</span>
<span className="truncate">
{t(`tips.sections.${tip.section}`)}
</span>
</p>
<p className="truncate text-sm leading-snug font-medium">
{t(keys.title, { mod })}
</p>
<p className="line-clamp-1 text-xs leading-relaxed text-pretty text-muted-foreground @4xl:line-clamp-2">
{t(keys.body, { mod })}
</p>
</div>
<div className="flex shrink-0 items-center gap-1">
{!actionStaysOn(tip.action, page) && (
<Button
type="button"
size="sm"
variant="soft"
data-slot="inline-tip-action"
disabled={leaving}
onClick={() => {
flow.dismissInlineTip();
onAction(tip.action);
}}
>
{t(keys.action)}
</Button>
)}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="subtle"
size="icon"
className="size-8"
aria-label={t("tips.inline.more")}
data-slot="inline-tip-menu"
disabled={leaving}
>
<LuEllipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-slot="inline-tip-all"
onSelect={() => flow.openTips(tip.id)}
>
{t("tips.inline.allTips")}
</DropdownMenuItem>
<DropdownMenuItem
data-slot="inline-tip-off"
onSelect={() => {
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")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<Button
type="button"
variant="subtle"
size="icon"
className="size-8"
aria-label={t("tips.inline.dismiss")}
data-slot="inline-tip-dismiss"
disabled={leaving}
onClick={flow.dismissInlineTip}
>
<LuX className="size-4" />
</Button>
</div>
</div>
</motion.aside>
);
}
+200
View File
@@ -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<string>();
/** 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 (
<CoachMark
key={`${tip.id}:${preview}`}
open={!leaving}
anchors={tip.anchors}
settled={settled}
onDismiss={flow.dismissPageTip}
onAnchorActivate={flow.dismissPageTip}
announcement={t("tips.coach.announce", { title })}
data-slot="tip-coach-mark"
data-tip-id={tip.id}
data-preview={preview}
>
<div
onPointerEnter={() => setHovered(true)}
onPointerLeave={() => setHovered(false)}
>
<CoachMarkScene>
<ScenePlayback playing={!leaving && (intro || hovered)}>
<div className="absolute inset-0 p-2">
<TipScene id={tip.id} />
</div>
</ScenePlayback>
</CoachMarkScene>
<CoachMarkHeader
eyebrow={
<>
<span className="grid size-4 place-items-center text-primary-text [&_svg]:size-3">
<TipIcon id={tip.id} />
</span>
<span className="text-primary-text">
{t("tips.coach.eyebrow")}
</span>
<span aria-hidden="true">·</span>
<span className="truncate">
{t(`tips.sections.${tip.section}`)}
</span>
</>
}
title={title}
aside={
<DropdownMenu modal={false}>
<DropdownMenuTrigger asChild>
<Button
type="button"
variant="subtle"
size="icon"
className="-mt-1 -mr-2 size-7"
aria-label={t("tips.coach.more")}
data-slot="tip-coach-menu"
disabled={leaving}
>
<LuEllipsis className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-slot="tip-coach-all"
onSelect={() => flow.openTips(tip.id)}
>
{t("tips.coach.allTips")}
</DropdownMenuItem>
{!preview && (
<DropdownMenuItem
data-slot="tip-coach-off"
onSelect={() => {
// 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")}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
}
/>
<CoachMarkBody>{t(keys.body, { mod })}</CoachMarkBody>
<CoachMarkFooter className="justify-end">
<Button
type="button"
size="sm"
variant={showAction ? "subtle" : "soft"}
data-slot="tip-coach-dismiss"
disabled={leaving}
onClick={flow.dismissPageTip}
>
{t("tips.coach.gotIt")}
</Button>
{showAction && (
<Button
type="button"
size="sm"
data-slot="tip-coach-action"
disabled={leaving}
onClick={() => {
flow.dismissPageTip();
onAction(tip.action);
}}
>
{t(keys.action)}
</Button>
)}
</CoachMarkFooter>
</div>
</CoachMark>
);
}
+793
View File
@@ -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<Side, number> = {
bottom: 0,
top: 180,
right: -90,
left: 90,
};
interface CoachMarkIds {
titleId: string;
bodyId: string;
}
const CoachMarkContext = createContext<CoachMarkIds | null>(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<HTMLElement>(
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<Layout>({
anchor: null,
side: null,
hidden: true,
});
const [spoken, setSpoken] = useState("");
const cardRef = useRef<HTMLElement>(null);
const anchorRef = useRef<HTMLElement | null>(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<number>, 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(
<CoachMarkContext.Provider value={{ titleId, bodyId }}>
{spotlight && (
<svg
aria-hidden="true"
data-slot="coach-mark-spotlight"
className="pointer-events-none fixed inset-0 z-[70] h-full w-full"
style={{ visibility }}
>
<motion.path
d={hole}
fillRule="evenodd"
className="pointer-events-auto"
style={{
fill: "color-mix(in oklab, var(--background) 70%, transparent)",
}}
initial={reduceMotion ? false : { opacity: 0 }}
animate={{ opacity: leaving ? 0 : 1 }}
transition={{
duration: leaving ? 0.16 : 0.32,
ease: MOTION_EASE_OUT,
}}
/>
</svg>
)}
{anchored && (
<svg
aria-hidden="true"
data-slot="coach-mark-ring"
className="pointer-events-none fixed inset-0 z-[71] h-full w-full overflow-visible"
>
<motion.rect
key={layout.anchor}
x={ringX}
y={ringY}
width={ringW}
height={ringH}
rx={ringR}
fill="none"
strokeWidth={1.5}
style={{
stroke: "color-mix(in oklab, var(--primary) 60%, transparent)",
}}
initial={quiet ? false : { pathLength: 0 }}
animate={{ pathLength: 1, opacity: leaving ? 0 : 1 }}
transition={{
pathLength: { duration: 0.7, ease: MOTION_EASE_OUT, delay: 0.1 },
opacity: { duration: 0.14 },
}}
/>
</svg>
)}
{anchored && (
<motion.div
aria-hidden="true"
data-slot="coach-mark-beacon"
className="pointer-events-none fixed top-0 left-0 z-[72] -mt-1.5 -ml-1.5 size-3"
style={{ x: beaconX, y: beaconY }}
>
{!quiet && !leaving && (
<motion.span
key={layout.anchor}
className="absolute inset-0 rounded-full bg-primary"
initial={{ scale: 1, opacity: 0 }}
animate={{ scale: [1, 2.8], opacity: [0.5, 0] }}
transition={{
duration: 1.6,
ease: "easeOut",
repeat: 4,
repeatDelay: 1,
delay: 0.4,
}}
/>
)}
<motion.span
className="absolute inset-0 rounded-full bg-primary ring-2 ring-background"
initial={quiet ? false : { scale: 0.4 }}
animate={{ scale: leaving ? 0.6 : 1, opacity: leaving ? 0 : 1 }}
transition={leaving ? { duration: 0.14 } : ENTER_SPRING}
/>
</motion.div>
)}
<motion.div
className="pointer-events-none fixed top-0 left-0 z-[73]"
style={{ x: cardX, y: cardY, visibility }}
>
<motion.section
ref={cardRef}
aria-labelledby={titleId}
aria-describedby={bodyId}
data-slot="coach-mark"
{...dataAttributes}
data-state={leaving ? "leaving" : "shown"}
data-anchor={layout.anchor ?? "none"}
data-side={layout.side ?? undefined}
className={cn(
// flow-root keeps a child's top margin inside the card, so the
// box that is measured is the box that is drawn.
"pointer-events-auto relative flow-root w-80 max-w-[calc(100vw-1rem)] rounded-xl bg-popover text-popover-foreground shadow-xl ring-1 ring-foreground/12",
className,
)}
style={{
x: enterX,
y: enterY,
scale: enterScale,
opacity: cardOpacity,
}}
>
{anchored && (
<motion.div
aria-hidden="true"
className="pointer-events-none absolute top-0 left-0"
style={{
x: arrowX,
y: arrowY,
rotate: ARROW_TURN[layout.side ?? "bottom"],
width: ARROW_W,
height: ARROW_H,
}}
>
<svg
aria-hidden="true"
viewBox={`0 0 ${ARROW_W} ${ARROW_H}`}
width={ARROW_W}
height={ARROW_H}
className="block overflow-visible"
>
<path
d={`M0 ${ARROW_H} L${ARROW_W / 2} 0.5 L${ARROW_W} ${ARROW_H}`}
strokeWidth={1}
strokeLinejoin="round"
style={{
fill: "var(--popover)",
stroke:
"color-mix(in oklab, var(--foreground) 12%, transparent)",
}}
/>
</svg>
</motion.div>
)}
{children}
</motion.section>
</motion.div>
<div role="status" aria-live="polite" className="sr-only">
{open && !hidden ? spoken : ""}
</div>
</CoachMarkContext.Provider>,
document.body,
);
}
/** The drawing at the top of a card. Decorative. */
export function CoachMarkScene({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
aria-hidden="true"
data-slot="coach-mark-scene"
className={cn(
"relative mx-2 mt-2 h-28 overflow-hidden rounded-lg bg-foreground/[0.03] ring-1 ring-foreground/6 ring-inset [@media(max-height:640px)]:h-24 [@media(max-height:460px)]:hidden",
"[background-image:radial-gradient(color-mix(in_oklab,var(--foreground)_10%,transparent)_1px,transparent_1.2px)] [background-size:14px_14px] [background-position:7px_7px]",
className,
)}
>
{children}
</div>
);
}
export function CoachMarkHeader({
eyebrow,
title,
aside,
}: {
eyebrow?: ReactNode;
title: ReactNode;
aside?: ReactNode;
}) {
const { titleId } = useCoachMarkIds();
return (
<div className="flex items-start gap-2 px-4 pt-3.5">
<div className="flex min-w-0 flex-1 flex-col gap-1">
{eyebrow && (
<p className="flex min-w-0 items-center gap-1.5 text-[11px] font-medium text-muted-foreground">
{eyebrow}
</p>
)}
<h2
id={titleId}
className="text-sm leading-snug font-semibold tracking-tight text-balance"
>
{title}
</h2>
</div>
{aside}
</div>
);
}
export function CoachMarkBody({ children }: { children: ReactNode }) {
const { bodyId } = useCoachMarkIds();
return (
<p
id={bodyId}
className="px-4 pt-1.5 text-xs leading-relaxed text-pretty text-muted-foreground"
>
{children}
</p>
);
}
export function CoachMarkFooter({
className,
children,
}: {
className?: string;
children: ReactNode;
}) {
return (
<div
className={cn("flex items-center justify-between gap-2 p-3", className)}
>
{children}
</div>
);
}
+66
View File
@@ -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 (
<div
role="progressbar"
aria-label={label}
aria-valuemin={1}
aria-valuemax={count}
aria-valuenow={current + 1}
data-slot="step-dots"
className={cn("flex items-center gap-1.5", className)}
>
{Array.from({ length: count }, (_, index) => {
const active = index === current;
return (
<span
key={index}
data-active={active}
className={cn(
"relative h-1.5 rounded-full transition-[background-color] duration-300",
active ? "w-4" : "w-1.5",
index < current ? "bg-foreground/35" : "bg-foreground/12",
)}
>
{active && (
<motion.span
aria-hidden="true"
layoutId={glide ? `${id}-current` : undefined}
initial={false}
transition={glide ? MOTION_SPRING_POSITION : { duration: 0 }}
className="absolute inset-0 rounded-full bg-primary"
/>
)}
</span>
);
})}
</div>
);
}
+485 -415
View File
@@ -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 ? (
<div
className="h-full rounded-full bg-primary transition-[width] duration-300 ease-[var(--ease-out)] motion-reduce:transition-none"
style={{ width: `${normalizedValue}%` }}
className="h-full origin-left rounded-full bg-primary transition-transform duration-300 ease-[var(--ease-out)] motion-reduce:transition-none"
style={{ transform: `scaleX(${normalizedValue / 100})` }}
/>
) : (
<div className="h-full w-1/3 rounded-full bg-primary motion-safe:animate-progress-indeterminate motion-reduce:translate-x-0" />
@@ -136,6 +118,44 @@ function SetupProgress({ value, label }: { value: number; label: string }) {
);
}
function StepHeading({
title,
body,
tone,
}: {
title: ReactNode;
body: ReactNode;
tone?: "error";
}) {
return (
<div className="flex flex-col items-center gap-2 text-center">
<h2
className={cn(
"flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance",
tone === "error" && "text-destructive-text",
)}
>
{title}
</h2>
<p className="mx-auto max-w-[52ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{body}
</p>
</div>
);
}
/**
* 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<WelcomeStep>("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" ? (
<IntroScene />
) : step === "license" ? (
<LicenseScene />
) : step === "permissions" ? (
<PermissionsScene />
) : step === "setup" ? (
<SetupScene
phase={setup.phase}
progress={
setup.phase === "extracting"
? setup.extractionPercent
: setup.downloadPercent
}
/>
) : null;
const skipButton = (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={advanceToSetup}
>
{t("welcome.skip")}
</Button>
);
const backButton = (
<Button
type="button"
variant="subtle"
size="icon"
className="size-8"
aria-label={t("common.buttons.back")}
data-slot="welcome-back"
onClick={back}
>
<LuArrowLeft className="size-4" />
</Button>
);
let body: ReactNode = null;
let leading: ReactNode = null;
let trailing: ReactNode = null;
switch (step) {
case "intro":
body = (
<>
<StepHeading title={t("welcome.title")} body={t("welcome.tagline")} />
<ul
aria-label={t("welcome.features.title")}
data-slot="welcome-features"
className="flex flex-wrap justify-center gap-1.5"
>
{FEATURES.map(({ key, Icon }, index) => (
<motion.li
key={key}
initial={animateSteps ? { y: 6, scale: 0.97 } : false}
animate={{ y: 0, scale: 1 }}
transition={
animateSteps
? {
type: "spring",
stiffness: 420,
damping: 28,
delay: 0.12 + index * 0.035,
}
: { duration: 0 }
}
className="flex items-center gap-1.5 rounded-full bg-foreground/5 px-2.5 py-1 text-xs text-foreground"
>
<Icon
className="size-3.5 shrink-0 text-muted-foreground"
aria-hidden="true"
/>
{t(key)}
</motion.li>
))}
</ul>
</>
);
leading = skipButton;
trailing = (
<Button size="sm" className="gap-1.5" onClick={() => go("isolation")}>
{t("welcome.next")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
);
break;
case "isolation":
body = (
<>
<StepHeading
title={t("isolationDemo.title")}
body={t("isolationDemo.description")}
/>
<ProfileIsolationDemo showHeading={false} />
</>
);
leading = skipButton;
trailing = (
<>
{backButton}
<Button size="sm" className="gap-1.5" onClick={() => go("license")}>
{t("welcome.next")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
</>
);
break;
case "license":
body = (
<>
<StepHeading
title={t("welcome.license.title")}
body={t("welcome.license.body")}
/>
<dl className="flex flex-col gap-2">
<div className="flex items-center gap-3 rounded-xl bg-foreground/[0.035] px-4 py-2.5 ring-1 ring-foreground/6 ring-inset">
<LuHeart className="size-4 shrink-0 text-success-text" />
<div className="flex min-w-0 flex-wrap items-baseline gap-x-2 text-left">
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.personalTitle")}
</dt>
<dd className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.license.personalDesc")}
</dd>
</div>
</div>
<div className="flex items-start gap-3 rounded-xl bg-foreground/[0.035] px-4 py-2.5 ring-1 ring-foreground/6 ring-inset">
<LuBriefcase className="mt-1 size-4 shrink-0 text-muted-foreground" />
<div className="flex min-w-0 flex-col text-left">
<dt className="flex flex-wrap items-center gap-2 text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.commercialTitle")}
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary-text">
{t("welcome.license.trialBadge")}
</span>
</dt>
<dd className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.license.commercialDesc")}
</dd>
</div>
</div>
</dl>
</>
);
leading = skipButton;
trailing = (
<>
{backButton}
<Button size="sm" className="gap-1.5" onClick={acceptLicense}>
{t("welcome.license.agree")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
</>
);
break;
case "permissions":
body = (
<StepHeading
title={t("welcome.permissions.title")}
body={t("welcome.permissions.desc")}
/>
);
leading = (
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={requesting}
onClick={advanceToSetup}
>
{t("welcome.permissions.skip")}
</Button>
);
trailing = (
<Button
size="sm"
className="gap-1.5"
disabled={requesting}
onClick={() => {
void requestPermissions();
}}
>
{requesting && (
<LuLoaderCircle className="size-4 shrink-0 motion-safe:animate-spin" />
)}
{requesting
? t("welcome.permissions.requesting")
: t("welcome.permissions.grant")}
</Button>
);
break;
case "setup":
if (setup.phase === "error") {
body = (
<>
<StepHeading
tone="error"
title={
<>
<LuTriangleAlert className="size-5 shrink-0" />
{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 && (
<p className="mx-auto max-w-[55ch] text-center text-sm/6 text-pretty text-foreground">
{translateBackendError(t, setup.error.detail)}
</p>
)}
</>
);
// No escape hatch here: a browser must finish downloading before
// onboarding can complete, so the only action on failure is to retry.
trailing = (
<Button size="sm" onClick={() => setup.retry()}>
{t("welcome.ready.retry")}
</Button>
);
break;
}
body = (
<>
<StepHeading
title={t("welcome.ready.title")}
body={
setup.phase === "ready"
? t("welcome.ready.descReady")
: setup.phase === "extracting"
? t("welcome.ready.descExtracting")
: t("welcome.ready.descDownloading")
}
/>
{setup.phase === "downloading" && (
<div className="mx-auto flex w-full max-w-xs flex-col gap-2">
<SetupProgress
value={setup.downloadPercent}
label={t("welcome.ready.downloading")}
/>
<div className="flex items-center justify-between text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 motion-safe:animate-spin" />
{t("welcome.ready.downloading")}
</span>
<span>{setup.downloadPercent}%</span>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground tabular-nums">
<span>
{setup.totalBytes != null
? t("welcome.ready.stats", {
downloaded: formatBytes(
setup.downloadedBytes,
i18n.language,
),
total: formatBytes(setup.totalBytes, i18n.language),
})
: formatBytes(setup.downloadedBytes, i18n.language)}
</span>
{setup.speedBytesPerSec > 0 && (
<span>
{t("welcome.ready.speed", {
speed: formatBytes(setup.speedBytesPerSec, i18n.language),
})}
</span>
)}
{setup.etaSeconds != null &&
Number.isFinite(setup.etaSeconds) &&
setup.etaSeconds > 0 && (
<span>
{t("welcome.ready.timeLeft", {
time: formatDuration(setup.etaSeconds, i18n.language),
})}
</span>
)}
</div>
</div>
)}
{setup.phase === "extracting" && (
<div className="mx-auto flex w-full max-w-xs flex-col gap-2">
{setup.extractionOvertime ? (
<div className="flex items-center justify-center gap-1.5 text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<LuLoaderCircle className="size-4 shrink-0 motion-safe:animate-spin" />
{t("welcome.ready.almostFinished")}
</div>
) : (
<>
<SetupProgress
value={setup.extractionPercent}
label={t("welcome.ready.extracting")}
/>
<div className="flex items-center justify-between text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 motion-safe:animate-spin" />
{t("welcome.ready.extracting")}
</span>
<span>{setup.extractionPercent}%</span>
</div>
</>
)}
</div>
)}
</>
);
if (setup.phase === "ready") {
trailing = (
<Button size="sm" className="gap-1.5" onClick={onComplete}>
{t("welcome.ready.cta")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
);
}
break;
}
return (
<Dialog open={isOpen} onOpenChange={() => {}}>
<DialogContent
dismissible={false}
className="overflow-x-hidden p-4 sm:max-w-xl sm:p-6"
data-slot="welcome-dialog"
data-step={step}
className="gap-0 overflow-x-hidden p-0 sm:max-w-xl"
>
<DialogTitle className="sr-only">{t("welcome.title")}</DialogTitle>
<div
role="progressbar"
aria-label={t("welcome.title")}
aria-valuemin={1}
aria-valuemax={visibleSteps.length}
aria-valuenow={currentStepIndex + 1}
className="mx-auto h-1 w-24 overflow-hidden rounded-full bg-muted"
>
<div
className="h-full rounded-full bg-primary transition-[width] duration-200 ease-[var(--ease-out)] motion-reduce:transition-none"
style={{
width: `${((currentStepIndex + 1) / visibleSteps.length) * 100}%`,
}}
/>
</div>
<AnimatePresence mode="popLayout" initial={false}>
{step === "intro" && (
<motion.div
key="intro"
variants={panelVariants}
initial={animatePanels ? "enter" : false}
animate="center"
transition={panelTransition}
className="flex flex-col gap-5"
>
<div className="flex flex-col items-center gap-4 text-center">
<Logo className="size-10 text-foreground" />
<div className="flex flex-col gap-2">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.tagline")}
</p>
</div>
</div>
<ProfileIsolationDemo />
<details className="group" data-slot="welcome-features">
<summary className="flex cursor-pointer list-none items-center gap-2 rounded-md py-1 text-sm font-medium text-muted-foreground outline-none hover:text-foreground focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<LuChevronDown
className="size-3.5 transition-transform duration-150 group-open:rotate-180 motion-reduce:transition-none"
aria-hidden="true"
/>
{t("welcome.features.title")}
</summary>
<dl className="mt-3 grid grid-cols-1 gap-x-6 gap-y-3 sm:grid-cols-2">
{FEATURES.map(({ key, Icon }) => (
<div
key={key}
className="flex min-w-0 items-center gap-2.5"
>
<Icon className="size-4 shrink-0 text-muted-foreground" />
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
{t(key)}
</dt>
</div>
))}
</dl>
</details>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={advanceToSetup}
>
{t("welcome.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
onClick={() => setStep("license")}
>
{t("welcome.next")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
</div>
</motion.div>
)}
{step === "license" && (
<motion.div
key="license"
variants={panelVariants}
initial={animatePanels ? "enter" : false}
animate="center"
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col gap-2 text-center">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.license.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.license.body")}
</p>
</div>
<dl className="flex flex-col gap-3">
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuHeart className="mt-0.5 size-4 shrink-0 text-success-text" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.personalTitle")}
</dt>
<dd className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.license.personalDesc")}
</dd>
</div>
</div>
<div className="flex items-start gap-3 rounded-lg border p-4">
<LuBriefcase className="mt-0.5 size-4 shrink-0 text-muted-foreground" />
<div className="flex flex-col gap-0.5 text-left">
<dt className="flex flex-wrap items-center gap-2 text-base/7 font-medium text-foreground sm:text-sm/6">
{t("welcome.license.commercialTitle")}
<span className="rounded-full bg-primary/10 px-2 py-0.5 text-xs font-medium text-primary-text">
{t("welcome.license.trialBadge")}
</span>
</dt>
<dd className="text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.license.commercialDesc")}
</dd>
</div>
</div>
</dl>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={advanceToSetup}
>
{t("welcome.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
onClick={() => {
if (!needsSetup) {
onComplete();
} else if (
getCurrentOS() === "macos" &&
!(isInitialized && permissionsGranted)
) {
setStep("permissions");
} else {
setStep("setup");
}
}}
>
{t("welcome.license.agree")}
<LuArrowRight className="size-4 shrink-0" />
</Button>
</div>
</motion.div>
)}
{step === "permissions" && (
<motion.div
key="permissions"
variants={panelVariants}
initial={animatePanels ? "enter" : false}
animate="center"
transition={panelTransition}
className="flex flex-col gap-7"
>
<div className="flex flex-col items-center gap-3 text-center">
<div
className="flex items-center justify-center gap-3 py-2 text-foreground"
aria-hidden="true"
>
<LuMic className="size-4 shrink-0" />
<LuCamera className="size-4 shrink-0" />
</div>
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.permissions.title")}
</h2>
<p className="mx-auto max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{t("welcome.permissions.desc")}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
disabled={requesting}
onClick={advanceToSetup}
>
{t("welcome.permissions.skip")}
</Button>
<Button
size="sm"
className="gap-1.5"
disabled={requesting}
onClick={() => {
void requestPermissions();
}}
>
{requesting && (
<LuLoaderCircle className="size-4 shrink-0 motion-safe:animate-spin" />
)}
{requesting
? t("welcome.permissions.requesting")
: t("welcome.permissions.grant")}
</Button>
</div>
</motion.div>
)}
{step === "setup" && (
<motion.div
key="setup"
variants={panelVariants}
initial={animatePanels ? "enter" : false}
animate="center"
transition={panelTransition}
className="flex flex-col items-center gap-6 text-center"
>
{setup.phase === "error" ? (
<>
<div className="flex flex-col items-center gap-2">
<h2 className="flex items-center justify-center gap-2 text-2xl font-semibold tracking-tight text-balance text-destructive-text">
<LuTriangleAlert className="size-5 shrink-0" />
{t("welcome.ready.errorTitle")}
</h2>
<p className="max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{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,
})}
</p>
{setup.error?.detail && (
<p className="max-w-[55ch] text-sm/6 text-pretty text-foreground">
{translateBackendError(t, setup.error.detail)}
</p>
)}
</div>
{/* No escape hatch here: a browser must finish downloading
before onboarding can complete, so the only action on
failure is to retry. */}
<Button
size="sm"
onClick={() => {
setup.retry();
}}
>
{t("welcome.ready.retry")}
</Button>
</>
) : (
<>
<div className="flex flex-col items-center gap-2">
<h2 className="text-2xl font-semibold tracking-tight text-balance">
{t("welcome.ready.title")}
</h2>
<p className="max-w-[55ch] text-base/7 text-pretty text-muted-foreground sm:text-sm/6">
{setup.phase === "ready"
? t("welcome.ready.descReady")
: setup.phase === "extracting"
? t("welcome.ready.descExtracting")
: t("welcome.ready.descDownloading")}
</p>
</div>
{setup.phase === "downloading" && (
<div className="flex w-full max-w-xs flex-col gap-2">
<SetupProgress
value={setup.downloadPercent}
label={t("welcome.ready.downloading")}
/>
<div className="flex items-center justify-between text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 motion-safe:animate-spin" />
{t("welcome.ready.downloading")}
</span>
<span>{setup.downloadPercent}%</span>
</div>
<div className="flex flex-wrap items-center justify-center gap-x-3 gap-y-0.5 text-sm text-muted-foreground tabular-nums">
<span>
{setup.totalBytes != null
? t("welcome.ready.stats", {
downloaded: formatBytes(
setup.downloadedBytes,
i18n.language,
),
total: formatBytes(
setup.totalBytes,
i18n.language,
),
})
: formatBytes(setup.downloadedBytes, i18n.language)}
</span>
{setup.speedBytesPerSec > 0 && (
<span>
{t("welcome.ready.speed", {
speed: formatBytes(
setup.speedBytesPerSec,
i18n.language,
),
})}
</span>
)}
{setup.etaSeconds != null &&
Number.isFinite(setup.etaSeconds) &&
setup.etaSeconds > 0 && (
<span>
{t("welcome.ready.timeLeft", {
time: formatDuration(
setup.etaSeconds,
i18n.language,
),
})}
</span>
)}
</div>
</div>
)}
{setup.phase === "extracting" && (
<div className="flex w-full max-w-xs flex-col gap-2">
{setup.extractionOvertime ? (
<div className="flex items-center justify-center gap-1.5 text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<LuLoaderCircle className="size-4 shrink-0 motion-safe:animate-spin" />
{t("welcome.ready.almostFinished")}
</div>
) : (
<>
<SetupProgress
value={setup.extractionPercent}
label={t("welcome.ready.extracting")}
/>
<div className="flex items-center justify-between text-base/7 text-muted-foreground tabular-nums sm:text-sm/6">
<span className="inline-flex items-center gap-1.5">
<LuLoaderCircle className="size-4 shrink-0 motion-safe:animate-spin" />
{t("welcome.ready.extracting")}
</span>
<span>{setup.extractionPercent}%</span>
</div>
</>
)}
</div>
)}
{setup.phase === "ready" && (
<Button size="sm" className="gap-1.5" onClick={onComplete}>
<LuArrowRight className="size-4 shrink-0" />
{t("welcome.ready.cta")}
</Button>
)}
</>
{scene && (
<div className="px-3 pt-3 sm:px-4 sm:pt-4 [@media(max-height:440px)]:hidden">
<div
data-slot="welcome-scene-panel"
className={cn(
"relative aspect-[3/1] w-full overflow-hidden rounded-xl bg-foreground/[0.025] ring-1 ring-foreground/6 ring-inset [@media(min-height:541px)_and_(max-height:640px)]:aspect-[4/1] [@media(max-height:540px)]:aspect-[5/1]",
SCENE_GRID_CLASS,
)}
</motion.div>
)}
</AnimatePresence>
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={step}
className="absolute inset-0 p-3"
initial={
animateSteps
? { x: direction * 40, opacity: 0, scale: 0.98 }
: false
}
animate={{ x: 0, opacity: 1, scale: 1 }}
exit={
animateSteps
? { x: -direction * 40, opacity: 0, scale: 0.98 }
: { opacity: 0, transition: { duration: 0 } }
}
transition={
animateSteps
? { type: "spring", stiffness: 320, damping: 34 }
: { duration: 0 }
}
>
{scene}
</motion.div>
</AnimatePresence>
</div>
</div>
)}
<StepTransition
transitionKey={step}
direction={direction}
className="flex min-h-36 flex-col gap-5 px-5 pt-5 sm:px-8 [@media(max-height:640px)]:gap-4 [@media(max-height:640px)]:pt-4"
>
{body}
</StepTransition>
<div className="sticky bottom-0 z-10 grid grid-cols-[1fr_auto_1fr] items-center gap-3 bg-background px-4 pt-5 pb-4 sm:px-5 sm:pb-5 [@media(max-height:640px)]:pt-4">
<div className="flex min-w-0 items-center justify-start">
{leading}
</div>
<StepDots
count={visibleSteps.length}
current={currentStepIndex}
label={t("welcome.progress")}
/>
<div className="flex min-w-0 items-center justify-end gap-1.5">
{trailing}
</div>
</div>
</DialogContent>
</Dialog>
);
-4
View File
@@ -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"
+61 -13
View File
@@ -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<PaidWelcomeState | null>(null);
const [inline, setInline] = useState<InlineState | null>(null);
const [preview, setPreview] = useState<InlineState | null>(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<TipsState>("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 ?? "",
+11 -9
View File
@@ -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}}",
+11 -9
View File
@@ -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}}",
+11 -9
View File
@@ -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}}",
+11 -9
View File
@@ -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}}",
+11 -9
View File
@@ -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}} へようこそ",
+11 -9
View File
@@ -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}}에 오신 것을 환영합니다",
+11 -9
View File
@@ -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}}",
+11 -9
View File
@@ -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}}",
+11 -9
View File
@@ -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",
+11 -9
View File
@@ -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}}",
+11 -9
View File
@@ -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}}",
+89
View File
@@ -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"));
});
+221
View File
@@ -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<string, string> {
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<Side, Side> = {
top: "bottom",
bottom: "top",
left: "right",
right: "left",
};
const PERPENDICULAR: Record<Side, [Side, Side]> = {
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;
}
-10
View File
@@ -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";
+26
View File
@@ -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",
);
});
+41 -5
View File
@@ -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.<id>` 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 {