mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-20 14:37:30 +02:00
feat: coach-mark tips
This commit is contained in:
1 parent
e2f449dfdf
commit
c3b2d69c21
40 files changed
+3738
-1133
No files matched your search
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||
|
||||
Generated
-20
@@ -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
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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");
|
||||
}}
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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
@@ -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 ?? "",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}} へようこそ",
|
||||
|
||||
@@ -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}}에 오신 것을 환영합니다",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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}}",
|
||||
|
||||
@@ -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"));
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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";
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
Reference in new issue
Block a user