feat: add tips

This commit is contained in:
zhom committed 2026-09-09 06:16:14 -07:00
1 parent 3a0e5a41c0
commit a01901ef07
83 files changed
+8032 -2732

No files matched your search

+2
View File
@@ -12,6 +12,7 @@ import {
LuCookie,
LuInfo,
LuKeyboard,
LuLightbulb,
LuPlay,
LuPlug,
LuPlus,
@@ -65,6 +66,7 @@ interface CommandPaletteProps {
const ICONS: Record<ShortcutId, React.ComponentType<{ className?: string }>> = {
openPalette: LuKeyboard,
openShortcuts: LuKeyboard,
openTips: LuLightbulb,
importProfile: FaDownload,
goProfiles: LuUser,
goProxies: FiWifi,
+8 -2
View File
@@ -192,6 +192,10 @@ export function ImportProfileDialog({
const [isImporting, setIsImporting] = useState(false);
const [progress, setProgress] = useState<ProfileImportProgress | null>(null);
const activeImportItems = useRef<ImportProfileItem[]>([]);
// Each run's summary toast gets its own id. Re-using one id after
// dismissing it merges the new toast into the one still sliding out, and
// a fast retry's summary was never seen.
const resultsToastId = useRef<string | null>(null);
const [sourceProgress, setSourceProgress] = useState<
Record<string, ProfileImportProgress["status"]>
>({});
@@ -404,7 +408,7 @@ export function ImportProfileDialog({
setCurrentStep("importing");
setIsImporting(true);
setProgress(null);
toast.dismiss("profile-import-results");
if (resultsToastId.current) toast.dismiss(resultsToastId.current);
// A retry covers only the failed subset, so the earlier results are still
// the truth for everything else and must not be thrown away.
const previous = retryPaths ? result : null;
@@ -435,13 +439,14 @@ export function ImportProfileDialog({
? toast.warning
: toast.error
: toast.success;
resultsToastId.current = `profile-import-results-${Date.now()}`;
notify(
t("importProfile.resultsSummary", {
imported: combined.imported_count,
skipped: combined.skipped_count,
failed: combined.failed_count,
}),
{ id: "profile-import-results" },
{ id: resultsToastId.current },
);
if (
batchResult.imported_count > 0 &&
@@ -977,6 +982,7 @@ export function ImportProfileDialog({
<OperationFlow
label={t("importProfile.importingTitle")}
active={isImporting ? 1 : 2}
busy={isImporting}
failed={!isImporting && !!result?.failed_count}
steps={[
{
+18 -2
View File
@@ -72,10 +72,26 @@ export function IntegrationDiagnostics({
</div>
<OperationFlow
label={t("appFeedback.connectionTest")}
active={busy ? 1 : result?.authorized ? 2 : result?.reachable ? 1 : 0}
// The station the probe stopped at: nothing configured stops at
// "configured", unreachable at "reachable", a refused token at
// "authorized".
active={
busy
? 1
: !result
? 0
: !result.configured
? 0
: result.reachable === false
? 1
: 2
}
busy={busy}
failed={
!!result &&
(result.reachable === false || result.authorized === false)
(!result.configured ||
result.reachable === false ||
result.authorized === false)
}
steps={[
{
+157
View File
@@ -0,0 +1,157 @@
"use client";
import confetti from "canvas-confetti";
import { motion, useReducedMotion } from "motion/react";
import { useEffect } from "react";
import { useTranslation } from "react-i18next";
import { LuChevronRight } from "react-icons/lu";
import { Logo } from "@/components/icons/logo";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { useInputModality } from "@/hooks/use-input-modality";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { isMacOS } from "@/lib/platform";
import { type TipDefinition, type TipId, tipTextKeys } from "@/lib/tips";
const spring = { type: "spring", stiffness: 240, damping: 22 } as const;
/** "pro" reads as "Pro" in the title; an unknown plan keeps its own spelling. */
function displayPlan(plan: string): string {
return plan ? plan.charAt(0).toLocaleUpperCase() + plan.slice(1) : plan;
}
/**
* The first thing a freshly paid account sees: what the plan unlocked, each
* item opening the tip that shows it working. Shown once per account.
*/
export function PaidWelcomeDialog({
open,
plan,
tips,
onOpenChange,
onOpenTip,
}: {
open: boolean;
plan: string;
/** The plan tips this account is entitled to, in catalog order. */
tips: TipDefinition[];
onOpenChange: (open: boolean) => void;
onOpenTip: (id: TipId) => void;
}) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const modality = useInputModality();
const animate = !reduceMotion && modality === "pointer";
const mod = isMacOS() ? "⌘" : "Ctrl";
useEffect(() => {
if (!open || reduceMotion || document.hidden) return;
const fire = (options: confetti.Options) => {
if (document.hidden) return;
void confetti({
origin: { y: 0.65 },
disableForReducedMotion: true,
...options,
});
};
fire({ particleCount: 80, spread: 66, startVelocity: 42 });
const second = window.setTimeout(
() => fire({ particleCount: 40, spread: 100, decay: 0.92 }),
220,
);
return () => window.clearTimeout(second);
}, [open, reduceMotion]);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-slot="paid-welcome"
className="p-5 sm:max-w-md sm:p-6"
>
<div className="flex min-w-0 flex-col gap-5">
<div className="flex flex-col items-center gap-3 text-center">
<motion.div
initial={animate ? { scale: 0.92, rotate: -6 } : false}
animate={{ scale: 1, rotate: 0 }}
transition={animate ? spring : { duration: 0 }}
className="text-foreground"
>
<Logo className="size-12" />
</motion.div>
<DialogTitle className="text-2xl font-semibold tracking-tight text-balance">
{t("paidWelcome.title", { plan: displayPlan(plan) })}
</DialogTitle>
<p className="max-w-[40ch] text-sm/6 text-pretty text-muted-foreground">
{t("paidWelcome.body")}
</p>
</div>
{tips.length > 0 && (
<ul
data-slot="paid-welcome-list"
className="flex min-w-0 flex-col gap-0.5"
>
{tips.map((tip, index) => {
const keys = tipTextKeys(tip.id);
return (
<li key={tip.id} className="min-w-0">
<motion.button
type="button"
data-slot="paid-welcome-item"
data-tip-id={tip.id}
onClick={() => onOpenTip(tip.id)}
initial={animate ? { y: 8 } : false}
animate={{ y: 0 }}
transition={{
delay: animate ? 0.05 * index : 0,
duration: animate ? 0.3 : 0,
ease: MOTION_EASE_OUT,
}}
className="flex w-full min-w-0 cursor-pointer items-center justify-between gap-3 rounded-md px-3 py-2 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
<span className="flex min-w-0 flex-1 flex-col">
<span className="truncate text-sm font-medium">
{t(keys.title, { mod })}
</span>
<span className="truncate text-xs text-muted-foreground">
{t(keys.body, { mod })}
</span>
</span>
<LuChevronRight
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground"
/>
</motion.button>
</li>
);
})}
</ul>
)}
<div className="flex flex-wrap items-center justify-between gap-2">
<Button
type="button"
variant="ghost"
size="sm"
className="text-muted-foreground hover:text-foreground"
onClick={() => onOpenChange(false)}
>
{t("paidWelcome.later")}
</Button>
<Button
type="button"
size="sm"
data-slot="paid-welcome-cta"
disabled={tips.length === 0}
onClick={() => {
if (tips[0]) onOpenTip(tips[0].id);
}}
>
{t("paidWelcome.cta")}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
);
}
@@ -45,6 +45,7 @@ export function ProfileLaunchActivity({
<OperationFlow
label={t("appFeedback.launchActivity")}
active={active}
busy={!terminal}
failed={current.stage === "failed"}
steps={[
{
+82 -1
View File
@@ -23,6 +23,7 @@ import {
import { translateBackendError } from "@/lib/backend-errors";
import { formatRelativeTime } from "@/lib/flag-utils";
import { runProxyCheck, useProxyCheck } from "@/lib/proxy-check-store";
import { cn } from "@/lib/utils";
import type { ProxyCheckHistoryEntry, StoredProxy, UdpSupport } from "@/types";
const COPIED_MARK_MS = 1600;
@@ -74,6 +75,83 @@ export function ProxyUdpBadge({ proxy }: { proxy: StoredProxy }) {
);
}
/** How many remembered checks the strip draws; the list below keeps them all. */
const TREND_BARS = 40;
/**
* The remembered checks as one thin bar per check, oldest on the left, so a
* proxy that is slowing down or starting to fail shows as a shape before
* anyone reads a line. A failed check has no latency and carries a cross at
* the baseline instead of a bar; the newest check is the dark one.
*/
function LatencyStrip({ history }: { history: ProxyCheckHistoryEntry[] }) {
const { t } = useTranslation();
const entries = history.slice(0, TREND_BARS).reverse();
const peak = entries.reduce(
(max, entry) =>
entry.ok && typeof entry.latency_ms === "number"
? Math.max(max, entry.latency_ms)
: max,
0,
);
if (entries.length < 2 || peak === 0) return null;
return (
<div data-slot="proxy-check-trend" className="space-y-1">
<div
role="img"
aria-label={t("proxyCheck.trendLabel", { count: entries.length })}
className="flex h-10 items-end gap-0.5"
>
{entries.map((entry, index) => {
const latest = index === entries.length - 1;
const ms =
entry.ok && typeof entry.latency_ms === "number"
? entry.latency_ms
: null;
return (
<Tooltip key={`${entry.timestamp}-${index}`}>
<TooltipTrigger asChild>
<span
data-slot="proxy-check-bar"
data-ok={entry.ok}
className="flex h-full w-1.5 flex-col justify-end"
>
{ms === null ? (
<FiX
aria-hidden="true"
className="size-1.5 shrink-0 text-destructive-text"
/>
) : (
<span
className={cn(
"w-full rounded-t-[3px]",
latest ? "bg-foreground" : "bg-muted-foreground/45",
)}
style={{ height: `${Math.max(8, (ms / peak) * 100)}%` }}
/>
)}
</span>
</TooltipTrigger>
<TooltipContent>
<p>
{ms === null
? t("proxyCheck.historyFailed")
: t("proxyCheck.latencyValue", { ms })}
{" · "}
{formatRelativeTime(entry.timestamp)}
</p>
</TooltipContent>
</Tooltip>
);
})}
</div>
<p className="text-[11px] text-muted-foreground tabular-nums">
{t("proxyCheck.trendPeak", { ms: peak })}
</p>
</div>
);
}
/** One remembered check, as a single line. */
function HistoryRow({ entry }: { entry: ProxyCheckHistoryEntry }) {
const { t } = useTranslation();
@@ -302,7 +380,9 @@ export function ProxyCheckButton({
<h3 className="break-words text-sm font-medium">{proxy.name}</h3>
<OperationFlow
label={t("appFeedback.routeDetails")}
active={checking ? 1 : result?.is_valid ? 2 : 0}
// A failed check stops at the proxy: the device end is fine.
active={checking ? 1 : result?.is_valid ? 2 : result ? 1 : 0}
busy={checking}
failed={!checking && !!result && !result.is_valid}
steps={[
{
@@ -419,6 +499,7 @@ export function ProxyCheckButton({
<h4 className="font-medium text-foreground">
{t("proxyCheck.historyTitle")}
</h4>
{history && <LatencyStrip history={history} />}
{history && history.length > 0 ? (
<ul className="max-h-48 divide-y divide-border overflow-y-auto">
{history.map((entry, index) => (
+26
View File
@@ -12,6 +12,7 @@ import {
LuCookie,
LuInfo,
LuKeyboard,
LuLightbulb,
LuPlug,
LuPuzzle,
LuTrash2,
@@ -218,6 +219,8 @@ interface RailNavProps {
currentPage: AppPage;
onNavigate: (page: AppPage) => void;
onOpenAbout: () => void;
/** Opens the feature tips catalog. */
onOpenTips: () => void;
/**
* A remote session is running right now. The Cookie Bot item carries a dot so
* the state is legible from every other page — an overnight job you cannot
@@ -291,6 +294,7 @@ export function RailNav({
currentPage,
onNavigate,
onOpenAbout,
onOpenTips,
cookieBotRunning = false,
}: RailNavProps) {
const { t } = useTranslation();
@@ -496,6 +500,28 @@ export function RailNav({
</span>
</button>
))}
<button
type="button"
role="menuitem"
data-slot="rail-open-tips"
onClick={() => {
setMoreOpen(false);
onOpenTips();
}}
className="flex w-full cursor-pointer items-center gap-2 rounded-md px-2 py-1.5 text-left transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<span className="grid size-5 shrink-0 place-items-center text-muted-foreground">
<LuLightbulb className="size-3" />
</span>
<span className="flex min-w-0 flex-col">
<span className="truncate text-xs font-medium text-foreground">
{t("rail.more.tips")}
</span>
<span className="truncate text-[10px] text-muted-foreground">
{t("rail.more.tipsHint")}
</span>
</span>
</button>
<button
type="button"
role="menuitem"
+104 -15
View File
@@ -4,6 +4,7 @@ import { invoke } from "@tauri-apps/api/core";
import { writeText as writeClipboardText } from "@tauri-apps/plugin-clipboard-manager";
import { openUrl } from "@tauri-apps/plugin-opener";
import Color from "color";
import { motion, useReducedMotion } from "motion/react";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { BsCamera, BsMic } from "react-icons/bs";
@@ -49,11 +50,13 @@ import {
} from "@/components/ui/select";
import { useCloudAuth } from "@/hooks/use-cloud-auth";
import { useCommercialTrial } from "@/hooks/use-commercial-trial";
import { useInputModality } from "@/hooks/use-input-modality";
import { useLanguage } from "@/hooks/use-language";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { translateBackendError } from "@/lib/backend-errors";
import { effectivePlanOf } from "@/lib/entitlements";
import { MOTION_SPRING_POSITION } from "@/lib/motion";
import {
applyThemeColors,
clearThemeColors,
@@ -111,6 +114,8 @@ interface SettingsDialogProps {
onClose: () => void;
onIntegrationsOpen?: () => void;
subPage?: boolean;
/** A section to scroll to and focus as soon as the page opens. */
initialSection?: string | null;
}
export function SettingsDialog({
@@ -118,6 +123,7 @@ export function SettingsDialog({
onClose,
onIntegrationsOpen,
subPage,
initialSection = null,
}: SettingsDialogProps) {
const [settings, setSettings] = useState<AppSettings>({
set_as_default_browser: false,
@@ -145,8 +151,19 @@ export function SettingsDialog({
const [saveError, setSaveError] = useState<string | null>(null);
const [saved, setSaved] = useState(false);
const [search, setSearch] = useState("");
const [jumpTo, setJumpTo] = useState<string | null>(null);
const [jumpTo, setJumpTo] = useState<string | null>(initialSection);
const sectionsRef = useRef<HTMLFieldSetElement>(null);
const scrollerRef = useRef<HTMLDivElement>(null);
// The section the reader is in, so the nav can point at it. A click pins
// its target until the reader scrolls away, because the last sections can
// never reach the top of the scroller on their own.
const [activeSection, setActiveSection] = useState<string | null>(null);
const pinnedSectionRef = useRef<{ id: string; scrollTop: number } | null>(
null,
);
const reduceMotion = useReducedMotion();
const inputModality = useInputModality();
const animateNav = !reduceMotion && inputModality === "pointer";
const [isSettingDefault, setIsSettingDefault] = useState(false);
const [isClearingCache, setIsClearingCache] = useState(false);
const [isClearingTraffic, setIsClearingTraffic] = useState(false);
@@ -884,6 +901,44 @@ export function SettingsDialog({
});
const sectionVisible = (id: string) =>
matchingSections.some(([key]) => key === id);
const visibleSectionIds = matchingSections.map(([id]) => id).join(",");
const syncActiveSection = useCallback(() => {
const scroller = scrollerRef.current;
const root = sectionsRef.current;
if (!scroller || !root) return;
const nodes = [
...root.querySelectorAll<HTMLElement>("[data-settings-section]"),
].filter((node) => !node.hidden);
if (nodes.length === 0) {
setActiveSection(null);
return;
}
const pinned = pinnedSectionRef.current;
if (pinned && Math.abs(scroller.scrollTop - pinned.scrollTop) < 4) {
setActiveSection(pinned.id);
return;
}
pinnedSectionRef.current = null;
const atBottom =
scroller.scrollTop + scroller.clientHeight >= scroller.scrollHeight - 2;
if (atBottom) {
setActiveSection(nodes[nodes.length - 1].dataset.settingsSection ?? null);
return;
}
const threshold = scroller.getBoundingClientRect().top + 12;
let current = nodes[0];
for (const node of nodes) {
if (node.getBoundingClientRect().top <= threshold) current = node;
else break;
}
setActiveSection(current.dataset.settingsSection ?? null);
}, []);
// biome-ignore lint/correctness/useExhaustiveDependencies: the section list changes with the search filter and the settings load, and the sync must run again then
useEffect(() => {
syncActiveSection();
}, [syncActiveSection, visibleSectionIds, isLoading]);
useEffect(() => {
if (!jumpTo) return;
@@ -892,6 +947,13 @@ export function SettingsDialog({
);
section?.scrollIntoView({ block: "start" });
section?.focus({ preventScroll: true });
if (scrollerRef.current) {
pinnedSectionRef.current = {
id: jumpTo,
scrollTop: scrollerRef.current.scrollTop,
};
}
setActiveSection(jumpTo);
setJumpTo(null);
}, [jumpTo]);
@@ -924,21 +986,46 @@ export function SettingsDialog({
/>
<nav
aria-label={t("settings.title")}
className="flex flex-wrap gap-x-3 gap-y-1"
className="flex flex-wrap gap-x-1 gap-y-1"
>
{sections.map(([id, label]) => (
<button
type="button"
key={id}
onClick={() => {
setSearch("");
setJumpTo(id);
}}
className="rounded-sm py-1 text-xs text-muted-foreground hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring"
>
{t(label)}
</button>
))}
{sections.map(([id, label]) => {
const active = activeSection === id;
return (
<button
type="button"
key={id}
data-slot="settings-nav-item"
data-section={id}
aria-current={active ? "location" : undefined}
onClick={() => {
setSearch("");
setJumpTo(id);
}}
className={cn(
"relative isolate rounded-md px-2 py-1 text-xs transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
active
? "text-accent-foreground"
: "text-muted-foreground hover:text-foreground",
)}
>
{active && (
<motion.span
aria-hidden="true"
data-slot="settings-nav-indicator"
layoutId={
animateNav ? "settings-nav-indicator" : undefined
}
initial={false}
transition={
animateNav ? MOTION_SPRING_POSITION : { duration: 0 }
}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
{t(label)}
</button>
);
})}
</nav>
</div>
@@ -946,6 +1033,8 @@ export function SettingsDialog({
side gutters); the width cap lives on the inner column. Fusing
them was the dead-wheel-zone bug. */}
<div
ref={scrollerRef}
onScroll={syncActiveSection}
className={cn(
"min-h-0 flex-1 overflow-y-auto",
subPage ? "py-2" : "py-4",
+322
View File
@@ -0,0 +1,322 @@
"use client";
import { AnimatePresence, motion, useReducedMotion } from "motion/react";
import { useEffect, useId, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
import { TipScene } from "@/components/tips/scene-for";
import { AnimatedSwitch } from "@/components/ui/animated-switch";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog";
import { Label } from "@/components/ui/label";
import { useInputModality } from "@/hooks/use-input-modality";
import { MOTION_EASE_OUT, MOTION_SPRING_POSITION } from "@/lib/motion";
import { isMacOS } from "@/lib/platform";
import {
isPlanTip,
type TipAction,
type TipDefinition,
type TipId,
tipTextKeys,
} from "@/lib/tips";
import { cn } from "@/lib/utils";
export type TipsDialogMode = "browse" | "single";
interface TipsDialogProps {
open: boolean;
/** `browse` shows the whole catalog beside the tip; `single` is one card. */
mode: TipsDialogMode;
tips: TipDefinition[];
seen: string[];
initialTipId: TipId | null;
/** True when the automatic flow opened the dialog, not the user. */
auto: boolean;
autoShow: boolean;
onOpenChange: (open: boolean) => void;
onTipShown: (id: TipId, auto: boolean) => void;
onAutoShowChange: (enabled: boolean) => void;
onAction: (action: TipAction) => void;
}
function isTypingTarget(target: EventTarget | null): boolean {
if (!(target instanceof HTMLElement)) return false;
return (
target.isContentEditable ||
["INPUT", "TEXTAREA", "SELECT"].includes(target.tagName)
);
}
/**
* Feature tips: a drawing of the feature in motion, a few lines on what it
* does for the user, and a button into the place it lives. The catalog on
* the left is only there in browse mode; the automatic flow is one card.
*/
export function TipsDialog({
open,
mode,
tips,
seen,
initialTipId,
auto,
autoShow,
onOpenChange,
onTipShown,
onAutoShowChange,
onAction,
}: TipsDialogProps) {
const { t } = useTranslation();
const reduceMotion = useReducedMotion();
const modality = useInputModality();
const animate = !reduceMotion && modality === "pointer";
const listId = useId();
const autoShowId = useId();
const total = tips.length;
const initialIndex = Math.max(
0,
initialTipId ? tips.findIndex((tip) => tip.id === initialTipId) : 0,
);
const [index, setIndex] = useState(initialIndex);
const [direction, setDirection] = useState<1 | -1>(1);
const tip = tips[Math.min(index, Math.max(0, total - 1))];
const mod = isMacOS() ? "⌘" : "Ctrl";
// Each tip is reported once as it comes on screen, so the automatic flow
// never repeats one and the catalog can tell seen from new.
const reportedRef = useRef<string | null>(null);
useEffect(() => {
if (!open || !tip || reportedRef.current === tip.id) return;
reportedRef.current = tip.id;
onTipShown(tip.id, auto && tip.id === initialTipId);
}, [open, tip, auto, initialTipId, onTipShown]);
if (!tip) return null;
const keys = tipTextKeys(tip.id);
const isLast = index >= total - 1;
const go = (next: number) => {
const clamped = Math.max(0, Math.min(total - 1, next));
if (clamped === index) return;
setDirection(clamped > index ? 1 : -1);
setIndex(clamped);
};
const essentials = tips
.map((item, itemIndex) => ({ item, itemIndex }))
.filter(({ item }) => !isPlanTip(item));
const planTips = tips
.map((item, itemIndex) => ({ item, itemIndex }))
.filter(({ item }) => isPlanTip(item));
const sections = [
{ key: "essentials", label: t("tips.essentials"), entries: essentials },
...(planTips.length > 0
? [{ key: "plan", label: t("tips.planSection"), entries: planTips }]
: []),
];
const detail = (
<section
data-slot="tip-detail"
data-tip-id={tip.id}
className="flex min-h-0 min-w-0 flex-col gap-4 overflow-y-auto p-5"
>
<DialogTitle className="pr-6 text-lg font-semibold tracking-tight text-balance">
{t(keys.title, { mod })}
</DialogTitle>
<div
data-slot="tip-scene-panel"
className="relative h-40 overflow-hidden rounded-lg bg-muted/35"
>
<AnimatePresence mode="popLayout" initial={false}>
<motion.div
key={tip.id}
className="absolute inset-0 p-3"
initial={animate ? { x: direction * 24 } : false}
animate={{ x: 0 }}
exit={animate ? { x: -direction * 24, opacity: 0 } : { opacity: 0 }}
transition={{ duration: 0.22, ease: MOTION_EASE_OUT }}
>
<TipScene id={tip.id} />
</motion.div>
</AnimatePresence>
</div>
<div className="flex flex-col gap-1.5">
{isPlanTip(tip) && (
<p className="text-xs text-muted-foreground">
{t("tips.planSection")}
</p>
)}
<p className="text-sm/6 text-pretty text-muted-foreground">
{t(keys.body, { mod })}
</p>
</div>
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="flex items-center gap-1">
<Button
type="button"
variant="ghost"
size="sm"
className="size-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("tips.previous")}
data-slot="tip-previous"
disabled={index === 0}
onClick={() => go(index - 1)}
>
<LuChevronLeft className="size-4" />
</Button>
<span
className="min-w-[5ch] text-center text-xs text-muted-foreground tabular-nums"
aria-live="polite"
>
{t("tips.count", { current: index + 1, total })}
</span>
<Button
type="button"
variant="ghost"
size="sm"
className="size-8 p-0 text-muted-foreground hover:text-foreground"
aria-label={t("tips.next")}
data-slot="tip-next"
disabled={isLast}
onClick={() => go(index + 1)}
>
<LuChevronRight className="size-4" />
</Button>
</div>
<div className="flex items-center gap-2">
<Button
type="button"
variant="ghost"
size="sm"
data-slot="tip-action"
onClick={() => onAction(tip.action)}
>
{t(keys.action)}
</Button>
<Button
type="button"
size="sm"
data-slot="tip-advance"
onClick={() => {
if (isLast) onOpenChange(false);
else go(index + 1);
}}
>
{t(isLast ? "tips.done" : "tips.next")}
</Button>
</div>
</div>
<div className="flex items-center gap-2">
<AnimatedSwitch
id={autoShowId}
data-slot="tips-auto-show"
checked={autoShow}
onCheckedChange={onAutoShowChange}
/>
<Label
htmlFor={autoShowId}
className="cursor-pointer text-xs font-normal text-muted-foreground"
>
{t("tips.autoShow")}
</Label>
</div>
</section>
);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
data-slot="tips-dialog"
data-mode={mode}
className={cn(
"max-h-[calc(100vh-3rem)] gap-0 overflow-hidden p-0",
mode === "browse" ? "sm:max-w-2xl" : "sm:max-w-md",
)}
onKeyDown={(event) => {
if (isTypingTarget(event.target)) return;
if (event.key === "ArrowRight") {
event.preventDefault();
go(index + 1);
} else if (event.key === "ArrowLeft") {
event.preventDefault();
go(index - 1);
}
}}
>
{mode === "browse" ? (
<div className="grid max-h-[calc(100vh-3rem)] min-h-0 sm:grid-cols-[11rem_minmax(0,1fr)]">
<aside className="hidden min-h-0 overflow-hidden border-r sm:flex sm:flex-col">
<h2 className="px-4 pt-5 pb-1 text-sm font-semibold">
{t("tips.title")}
</h2>
<nav
aria-label={t("tips.title")}
className="min-h-0 flex-1 overflow-y-auto px-2 pb-3"
>
{sections.map((section) => (
<div key={section.key}>
<p className="px-2 pt-3 pb-1 text-[11px] font-medium text-muted-foreground">
{section.label}
</p>
<ul data-slot="tips-list">
{section.entries.map(({ item, itemIndex }) => {
const active = itemIndex === index;
return (
<li key={item.id}>
<button
type="button"
data-slot="tips-list-item"
data-tip-id={item.id}
data-seen={seen.includes(item.id)}
aria-current={active ? "true" : undefined}
onClick={() => go(itemIndex)}
className={cn(
"relative isolate flex w-full cursor-pointer items-center rounded-md px-2 py-1.5 text-left text-sm transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
active
? "text-accent-foreground"
: seen.includes(item.id)
? "text-muted-foreground hover:text-foreground"
: "text-foreground",
)}
>
{active && (
<motion.span
aria-hidden="true"
data-slot="tips-list-indicator"
layoutId={
animate ? `${listId}-indicator` : undefined
}
initial={false}
transition={
animate
? MOTION_SPRING_POSITION
: { duration: 0 }
}
className="absolute inset-0 -z-10 rounded-md bg-accent"
/>
)}
<span className="truncate">
{t(tipTextKeys(item.id).label)}
</span>
</button>
</li>
);
})}
</ul>
</div>
))}
</nav>
</aside>
{detail}
</div>
) : (
<div className="grid max-h-[calc(100vh-3rem)] min-h-0">{detail}</div>
)}
</DialogContent>
</Dialog>
);
}
+73
View File
@@ -0,0 +1,73 @@
"use client";
import { isMacOS } from "@/lib/platform";
import type { TipId } from "@/lib/tips";
import {
ApiScene,
ConsistencyScene,
DnsScene,
ExtensionsScene,
GroupsScene,
ImportScene,
LinkRouteScene,
LockScene,
PaletteScene,
ProxyRouteScene,
SweepScene,
SyncScene,
TrashScene,
} from "./scenes-essentials";
import {
AgentScene,
CloudSyncScene,
CookieBotScene,
CrossOsScene,
RemoteScene,
TeamScene,
} from "./scenes-plan";
/** The drawing for one tip. Every tip id has one; the switch is exhaustive. */
export function TipScene({ id }: { id: TipId }) {
switch (id) {
case "dnsBlocklist":
return <DnsScene />;
case "proxyCheck":
return <ProxyRouteScene />;
case "groups":
return <GroupsScene />;
case "commandPalette":
return <PaletteScene modLabel={isMacOS() ? "⌘" : "Ctrl"} />;
case "fingerprintGate":
return <ConsistencyScene />;
case "profilePassword":
return <LockScene />;
case "clearOnClose":
return <SweepScene />;
case "defaultBrowser":
return <LinkRouteScene />;
case "extensionGroups":
return <ExtensionsScene />;
case "selfHostedSync":
return <SyncScene />;
case "trash":
return <TrashScene />;
case "localApi":
return <ApiScene variant="api" />;
case "importProfiles":
return <ImportScene />;
case "cloudBackup":
return <CloudSyncScene />;
case "cookieBot":
return <CookieBotScene />;
case "crossOs":
return <CrossOsScene />;
case "automation":
return <ApiScene variant="run" />;
case "agent":
return <AgentScene />;
case "team":
return <TeamScene />;
case "remoteControl":
return <RemoteScene />;
}
}
+368
View File
@@ -0,0 +1,368 @@
"use client";
import {
motion,
type TargetAndTransition,
type Transition,
useReducedMotion,
} from "motion/react";
import type { ReactNode } from "react";
import { cn } from "@/lib/utils";
export const VIEW_W = 320;
export const VIEW_H = 160;
/**
* Shared timing for one looping scene. Every element in a scene keys off the
* same duration and its own `times`, so the parts stay in step without any
* orchestration. With reduced motion a scene shows its resting frame: the
* last keyframe of every value, no loop.
*/
export function useScene(duration: number) {
const reduce = useReducedMotion() ?? false;
const kf = <T,>(values: T[]): T | T[] =>
reduce ? (values[values.length - 1] as T) : values;
const tr = (times: number[], extra?: Transition): Transition =>
reduce
? { duration: 0 }
: {
duration,
times,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
...extra,
};
return { reduce, kf, tr };
}
export interface Point {
x: number;
y: number;
}
/** Points along a cubic bezier, so a dot can travel a drawn wire. */
export function bezier(
p0: Point,
p1: Point,
p2: Point,
p3: Point,
steps: number,
): Point[] {
const out: Point[] = [];
for (let i = 0; i <= steps; i += 1) {
const t = i / steps;
const mt = 1 - t;
out.push({
x:
mt ** 3 * p0.x +
3 * mt ** 2 * t * p1.x +
3 * mt * t ** 2 * p2.x +
t ** 3 * p3.x,
y:
mt ** 3 * p0.y +
3 * mt ** 2 * t * p1.y +
3 * mt * t ** 2 * p2.y +
t ** 3 * p3.y,
});
}
return out;
}
/**
* Keyframes that hold at the first point, travel through every point between
* `from` and `to` (as fractions of the loop), then hold at the last point.
*/
export function travel(points: Point[], from: number, to: number) {
const last = points.length - 1;
const times = [
0,
...points.map((_, index) => from + ((to - from) * index) / last),
1,
];
return {
cx: [points[0].x, ...points.map((p) => p.x), points[last].x],
cy: [points[0].y, ...points.map((p) => p.y), points[last].y],
times,
};
}
export function Scene({
children,
className,
}: {
children: ReactNode;
className?: string;
}) {
return (
<svg
data-slot="tip-scene"
viewBox={`0 0 ${VIEW_W} ${VIEW_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>
);
}
/** A browser window: a frame, a title bar with one tab, and some text lines. */
export function Window({
x,
y,
width,
height,
lines = 3,
className,
children,
}: {
x: number;
y: number;
width: number;
height: number;
lines?: number;
className?: string;
children?: ReactNode;
}) {
const barY = y + 14;
return (
<g className={className}>
<rect x={x} y={y} width={width} height={height} rx={6} />
<path d={`M${x} ${barY} H${x + width}`} />
<rect
x={x + 6}
y={y + 4}
width={Math.min(24, width / 3)}
height={6}
rx={2}
/>
{Array.from({ length: lines }, (_, index) => {
const lineY = barY + 14 + index * 12;
const lineWidth = (width - 24) * (index % 2 === 0 ? 0.8 : 0.55);
return lineY < y + height - 8 ? (
<path
key={index}
d={`M${x + 12} ${lineY} h${lineWidth}`}
strokeWidth={2}
/>
) : null;
})}
{children}
</g>
);
}
export function Laptop({ x, y }: { x: number; y: number }) {
return (
<g>
<rect x={x} y={y} width={56} height={36} rx={3} />
<path d={`M${x - 6} ${y + 41} H${x + 62}`} strokeWidth={2} />
</g>
);
}
export function Monitor({ x, y }: { x: number; y: number }) {
return (
<g>
<rect x={x} y={y} width={56} height={40} rx={3} />
<path d={`M${x + 28} ${y + 40} v8 M${x + 18} ${y + 48} h20`} />
</g>
);
}
export function Server({ x, y }: { x: number; y: number }) {
return (
<g>
<rect x={x} y={y} width={40} height={56} rx={3} />
<path d={`M${x} ${y + 19} h40 M${x} ${y + 37} h40`} />
{[9, 28, 47].map((offset) => (
<circle
key={offset}
cx={x + 32}
cy={y + offset}
r={1.5}
fill="currentColor"
stroke="none"
/>
))}
</g>
);
}
export function Person({ cx, cy }: { cx: number; cy: number }) {
return (
<g>
<circle cx={cx} cy={cy - 9} r={6} />
<path d={`M${cx - 12} ${cy + 10} a12 12 0 0 1 24 0`} />
</g>
);
}
export function Cloud({ cx, cy }: { cx: number; cy: number }) {
return (
<path
d={`M${cx - 22} ${cy + 8} a9 9 0 0 1 2 -17 a14 14 0 0 1 27 -5 a10 10 0 0 1 10 22 z`}
/>
);
}
export function Globe({ cx, cy, r }: { cx: number; cy: number; r: number }) {
return (
<g>
<circle cx={cx} cy={cy} r={r} />
<ellipse cx={cx} cy={cy} rx={r * 0.42} ry={r} />
<path d={`M${cx - r} ${cy} h${r * 2}`} />
<path
d={`M${cx - r * 0.86} ${cy - r * 0.5} q${r * 0.86} ${r * 0.28} ${r * 1.72} 0`}
/>
</g>
);
}
export function Bin({ x, y }: { x: number; y: number }) {
return (
<path
d={`M${x} ${y} h22 M${x + 3} ${y} v17 a2 2 0 0 0 2 2 h12 a2 2 0 0 0 2 -2 v-17 M${x + 8} ${y} v-3 h6 v3 M${x + 8} ${y + 5} v9 M${x + 14} ${y + 5} v9`}
/>
);
}
/** A padlock; the shackle is its own element so a scene can lift it. */
export function Padlock({
x,
y,
shackle,
className,
}: {
x: number;
y: number;
/** Motion props for the shackle group. */
shackle?: { animate: TargetAndTransition; transition: Transition };
className?: string;
}) {
return (
<g className={className}>
<rect x={x} y={y} width={16} height={12} rx={2} />
<circle cx={x + 8} cy={y + 6} r={1.5} fill="currentColor" stroke="none" />
<motion.path
d={`M${x + 4} ${y} v-4 a4 4 0 0 1 8 0 v4`}
initial={false}
{...shackle}
/>
</g>
);
}
/** A puzzle piece with a bump on top and one on the right, for extensions. */
export function puzzlePath(x: number, y: number, size: number): string {
const r = size * 0.16;
const side = size / 2 - r;
return `M${x} ${y} h${side} a${r} ${r} 0 1 1 ${r * 2} 0 h${side} v${side} a${r} ${r} 0 1 1 0 ${r * 2} v${side} h-${size} z`;
}
export function Puzzle({
x,
y,
size,
className,
}: {
x: number;
y: number;
size: number;
className?: string;
}) {
return <path d={puzzlePath(x, y, size)} className={className} />;
}
export function Keycap({
x,
y,
width,
label,
className,
animate,
transition,
}: {
x: number;
y: number;
width: number;
label: string;
className?: string;
animate?: TargetAndTransition;
transition?: Transition;
}) {
return (
<motion.g
className={className}
initial={false}
animate={animate}
transition={transition}
>
<rect x={x} y={y} width={width} height={18} rx={4} />
<text
x={x + width / 2}
y={y + 9}
textAnchor="middle"
dominantBaseline="central"
fontSize={9}
fontFamily="inherit"
fill="currentColor"
stroke="none"
>
{label}
</text>
</motion.g>
);
}
export function Cursor({
animate,
transition,
className,
}: {
animate: TargetAndTransition;
transition: Transition;
className?: string;
}) {
return (
<motion.path
d="M0 0 L0 11 L3 8.5 L5 13 L7 12 L5 7.5 L9 7.5 Z"
fill="currentColor"
className={className}
initial={false}
animate={animate}
transition={transition}
/>
);
}
export function Check({
x,
y,
size = 12,
className,
animate,
transition,
}: {
x: number;
y: number;
size?: number;
className?: string;
animate: TargetAndTransition;
transition: Transition;
}) {
return (
<motion.path
d={`M${x} ${y} l${size / 3} ${size / 3} l${(size * 2) / 3} -${(size * 2) / 3}`}
className={className}
strokeWidth={2}
initial={false}
animate={animate}
transition={transition}
/>
);
}
+745
View File
@@ -0,0 +1,745 @@
"use client";
import { motion } from "motion/react";
import {
Bin,
bezier,
Check,
Cursor,
Globe,
Keycap,
Laptop,
Padlock,
Puzzle,
puzzlePath,
Scene,
Server,
travel,
useScene,
Window,
} from "./scene-primitives";
/**
* Every scene here is decorative: the dialog text carries the meaning and the
* drawing shows it happening. Scenes loop on their own clock, render their
* resting frame under reduced motion, and never hide anything a reader needs.
*/
/** Requests leave a profile; the ones bound for ad and tracker hosts stop at the shield. */
export function DnsScene() {
const { kf, tr } = useScene(3.6);
return (
<Scene>
<Window x={16} y={32} width={92} height={96} />
<path
d="M160 42 l22 8 v22 c0 18 -10 30 -22 38 c-12 -8 -22 -20 -22 -38 v-22 z"
className="text-foreground"
/>
{[50, 84, 118].map((y) => (
<g key={y}>
<path d={`M108 ${y} H236`} strokeDasharray="2 5" />
<rect x={236} y={y - 12} width={68} height={24} rx={5} />
<path d={`M248 ${y} h24`} strokeWidth={2} />
</g>
))}
<motion.circle
r={3.5}
cy={50}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf([108, 108, 236, 236]) }}
transition={tr([0, 0.08, 0.55, 1])}
/>
{[84, 118].map((y, index) => {
const hit = 0.34 + index * 0.1;
return (
<g key={y} className="text-destructive-text">
<motion.circle
cy={y}
fill="currentColor"
stroke="none"
initial={false}
animate={{
cx: kf([108, 108, 134, 134, 134]),
r: kf([3.5, 3.5, 3.5, 0, 0]),
}}
transition={tr([0, 0.08, hit, hit + 0.05, 1])}
/>
<motion.path
d={`M130 ${y - 4} l8 8 M138 ${y - 4} l-8 8`}
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, hit, hit + 0.12, 1])}
/>
</g>
);
})}
</Scene>
);
}
const ROUTE = travel(
[
...bezier(
{ x: 82, y: 104 },
{ x: 118, y: 104 },
{ x: 128, y: 44 },
{ x: 160, y: 44 },
8,
),
...bezier(
{ x: 160, y: 44 },
{ x: 192, y: 44 },
{ x: 204, y: 104 },
{ x: 256, y: 104 },
8,
).slice(1),
],
0.08,
0.66,
);
/** A check travels this device, the proxy, the exit; the exit is confirmed. */
export function ProxyRouteScene() {
const { kf, tr } = useScene(3.4);
return (
<Scene>
<Laptop x={24} y={86} />
<path d="M82 104 C118 104 128 44 160 44 C192 44 204 104 256 104" />
<motion.circle
cx={160}
cy={44}
r={10}
className="text-foreground"
initial={false}
animate={{ r: kf([10, 10, 13, 10, 10]) }}
transition={tr([0, 0.3, 0.37, 0.44, 1])}
/>
<circle
cx={160}
cy={44}
r={2}
fill="currentColor"
stroke="none"
className="text-foreground"
/>
<Globe cx={272} cy={104} r={16} />
<motion.circle
r={3.5}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf(ROUTE.cx), cy: kf(ROUTE.cy) }}
transition={tr(ROUTE.times, { ease: "linear" })}
/>
<Check
x={280}
y={78}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.7, 0.82, 1])}
/>
</Scene>
);
}
const COLUMNS = [20, 118, 216];
/** Profiles settle into groups, then the group keys walk the columns. */
export function GroupsScene() {
const { kf, tr } = useScene(4.4);
return (
<Scene>
{COLUMNS.map((x, index) => {
const press = 0.5 + index * 0.14;
return (
<g key={x}>
<rect x={x} y={26} width={84} height={104} rx={6} />
<path d={`M${x + 10} 40 h${28 + index * 10}`} strokeWidth={2} />
<Keycap
x={x + 33}
y={138}
width={18}
label={String(index + 1)}
animate={{ y: kf([0, 0, 2, 0, 0]) }}
transition={tr([0, press, press + 0.05, press + 0.1, 1])}
/>
</g>
);
})}
<motion.rect
x={20}
y={26}
width={84}
height={104}
rx={6}
className="text-foreground"
initial={false}
animate={{ x: kf([0, 0, 98, 196, 196]) }}
transition={tr([0, 0.5, 0.64, 0.78, 1])}
/>
{Array.from({ length: 6 }, (_, index) => {
const column = Math.floor(index / 2);
const fromY = 52 + index * 12;
const toY = 52 + (index % 2) * 12;
const start = 0.1 + index * 0.05;
return (
<motion.rect
key={index}
x={32}
y={toY}
width={60}
height={8}
rx={3}
strokeWidth={2}
initial={false}
animate={{
x: kf([0, 0, COLUMNS[column] - 20, COLUMNS[column] - 20]),
y: kf([fromY - toY, fromY - toY, 0, 0]),
}}
transition={tr([0, start, start + 0.2, 1])}
/>
);
})}
</Scene>
);
}
/** The chord opens the palette; two typed letters narrow it to one entry. */
export function PaletteScene({ modLabel }: { modLabel: string }) {
const { kf, tr } = useScene(4);
const press = (at: number) => ({
animate: { y: kf([0, 0, 2, 0, 0]) },
transition: tr([0, at, at + 0.04, at + 0.1, 1]),
});
return (
<Scene>
<Keycap x={22} y={118} width={30} label={modLabel} {...press(0.06)} />
<Keycap x={58} y={118} width={22} label="K" {...press(0.08)} />
<rect x={110} y={22} width={190} height={116} rx={8} />
<path d="M110 48 H300" />
<motion.path
d="M124 35 h26"
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.25, 0.4, 1])}
/>
<motion.path
d="M154 30 v10"
className="text-foreground"
initial={false}
animate={{ x: kf([-26, -26, 0, 0]) }}
transition={tr([0, 0.25, 0.4, 1])}
/>
{[62, 80, 98, 116].map((y, index) => (
<g key={y}>
<circle cx={128} cy={y} r={3} />
<path d={`M140 ${y} h${40 + ((index * 23) % 50)}`} strokeWidth={2} />
</g>
))}
<motion.rect
x={116}
y={54}
width={178}
height={16}
rx={4}
className="text-foreground"
initial={false}
animate={{ y: kf([0, 0, 36, 36]) }}
transition={tr([0, 0.45, 0.6, 1])}
/>
</Scene>
);
}
const CLOCK = { cx: 212, cy: 82, r: 16 };
function hand(deg: number, length: number) {
return {
x: CLOCK.cx + Math.sin((deg * Math.PI) / 180) * length,
y: CLOCK.cy - Math.cos((deg * Math.PI) / 180) * length,
};
}
/** The profile clock turns to the exit's timezone; the mismatch mark gives way. */
export function ConsistencyScene() {
const { kf, tr } = useScene(4);
const wrong = hand(-110, 9);
const right = hand(60, 9);
return (
<Scene>
<Globe cx={76} cy={82} r={40} />
<g className="text-foreground">
<circle cx={98} cy={62} r={3.5} fill="currentColor" stroke="none" />
<path d="M98 62 v-14" />
</g>
<path d="M118 62 C140 46 156 46 176 58" strokeDasharray="2 5" />
<Window x={176} y={34} width={122} height={94} lines={0}>
<circle cx={CLOCK.cx} cy={CLOCK.cy} r={CLOCK.r} />
<path
d={`M${CLOCK.cx} ${CLOCK.cy} L${CLOCK.cx + 5} ${CLOCK.cy - 11}`}
/>
<motion.line
x1={CLOCK.cx}
y1={CLOCK.cy}
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{
x2: kf([wrong.x, wrong.x, right.x, right.x]),
y2: kf([wrong.y, wrong.y, right.y, right.y]),
}}
transition={tr([0, 0.3, 0.55, 1])}
/>
<path d="M244 118 h40" strokeWidth={2} />
</Window>
<motion.g
className="text-warning-text"
initial={false}
animate={{ opacity: kf([1, 1, 0, 0]) }}
transition={tr([0, 0.4, 0.55, 1])}
>
<path d="M268 68 l9 16 h-18 z" />
<path d="M268 74 v5 M268 82 v0.01" strokeWidth={2} />
</motion.g>
<Check
x={262}
y={78}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.6, 0.75, 1])}
/>
</Scene>
);
}
const CIPHER_LINES = [64, 78, 92, 106];
/** The padlock drops and the profile's text turns to cipher. */
export function LockScene() {
const { kf, tr } = useScene(4.2);
const swap = tr([0, 0.3, 0.42, 1], { repeatDelay: 0.8 });
return (
<Scene>
<Window x={96} y={30} width={128} height={100} lines={0}>
<motion.g
initial={false}
animate={{ opacity: kf([1, 1, 0.15, 0.15]) }}
transition={swap}
>
{CIPHER_LINES.map((y, index) => (
<path
key={y}
d={`M108 ${y} h${70 - (index % 2) * 24}`}
strokeWidth={2}
/>
))}
</motion.g>
<motion.g
initial={false}
animate={{ opacity: kf([0.15, 0.15, 1, 1]) }}
transition={swap}
>
{CIPHER_LINES.map((y, index) => (
<path
key={y}
d={`M108 ${y} h${70 - (index % 2) * 24}`}
strokeWidth={2}
strokeDasharray="3 3"
/>
))}
</motion.g>
</Window>
<Padlock
x={200}
y={112}
className="text-foreground"
shackle={{
animate: { y: kf([-5, -5, 0, 0]) },
transition: tr([0, 0.3, 0.4, 1], { repeatDelay: 0.8 }),
}}
/>
</Scene>
);
}
const CRUMBS = [
{ x: 100, y: 72, at: 0.4 },
{ x: 132, y: 66, at: 0.46 },
{ x: 166, y: 76, at: 0.52 },
];
/** The window closes and its cookies and storage fall away. */
export function SweepScene() {
const { kf, tr } = useScene(4);
const fall = (at: number) => tr([0, at, at + 0.22, 1]);
return (
<Scene>
<Window x={70} y={26} width={150} height={108} lines={0}>
<motion.path
d="M203 40 l8 8 M211 40 l-8 8"
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.28, 0.36, 1])}
/>
</Window>
{CRUMBS.map((crumb) => (
<motion.g
key={crumb.x}
initial={false}
animate={{ y: kf([0, 0, 90, 90]), opacity: kf([1, 1, 0, 0]) }}
transition={fall(crumb.at)}
>
<circle cx={crumb.x} cy={crumb.y} r={6} />
<circle
cx={crumb.x - 2}
cy={crumb.y - 2}
r={0.8}
fill="currentColor"
stroke="none"
/>
<circle
cx={crumb.x + 2.5}
cy={crumb.y + 1.5}
r={0.8}
fill="currentColor"
stroke="none"
/>
</motion.g>
))}
{[
{ x: 96, at: 0.5 },
{ x: 150, at: 0.56 },
].map((store) => (
<motion.rect
key={store.x}
x={store.x}
y={98}
width={30}
height={12}
rx={3}
initial={false}
animate={{ y: kf([0, 0, 70, 70]), opacity: kf([1, 1, 0, 0]) }}
transition={fall(store.at)}
/>
))}
</Scene>
);
}
/** A link from another app passes the chooser and opens in the chosen profile. */
export function LinkRouteScene() {
const { kf, tr } = useScene(4.2);
const pop = tr([0, 0.55, 0.7, 1]);
return (
<Scene>
<rect x={16} y={56} width={64} height={48} rx={5} />
<path d="M26 70 h44 M26 82 h30" strokeWidth={2} />
<path d="M26 94 h24" strokeWidth={2} className="text-foreground" />
<path d="M80 94 H112" strokeDasharray="2 5" />
<motion.circle
r={3}
cy={94}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf([80, 80, 112, 112]) }}
transition={tr([0, 0.1, 0.3, 1])}
/>
<Window x={112} y={30} width={104} height={100} lines={0}>
{[52, 76, 100].map((y, index) => (
<g key={y}>
<circle cx={128} cy={y + 8} r={5} />
<path d={`M140 ${y + 8} h${28 + index * 12}`} strokeWidth={2} />
</g>
))}
</Window>
<motion.rect
x={118}
y={52}
width={92}
height={16}
rx={4}
className="text-foreground"
initial={false}
animate={{ y: kf([0, 0, 24, 24]) }}
transition={tr([0, 0.35, 0.5, 1])}
/>
<path d="M216 84 H240" strokeDasharray="2 5" />
<motion.g
initial={false}
animate={{ x: kf([12, 12, 0, 0]), y: kf([10, 10, 0, 0]) }}
transition={pop}
>
<motion.rect
x={240}
y={44}
rx={6}
initial={false}
animate={{
width: kf([40, 40, 64, 64]),
height: kf([48, 48, 80, 80]),
}}
transition={pop}
/>
<motion.path
d="M240 58 H304"
initial={false}
animate={{ pathLength: kf([0.6, 0.6, 1, 1]) }}
transition={pop}
/>
<path d="M250 74 h40 M250 86 h28" strokeWidth={2} />
</motion.g>
</Scene>
);
}
/** One extension group; each profile that uses it receives the pieces. */
export function ExtensionsScene() {
const { kf, tr } = useScene(4);
return (
<Scene>
<rect x={20} y={40} width={88} height={80} rx={8} />
<Puzzle x={50} y={66} size={28} className="text-foreground" />
{[28, 66, 104].map((y, index) => {
const at = 0.25 + index * 0.18;
return (
<g key={y}>
<path
d={`M108 80 C130 80 130 ${y + 15} 148 ${y + 15}`}
strokeDasharray="2 5"
/>
<rect x={148} y={y} width={152} height={30} rx={6} />
<path
d={`M162 ${y + 15} h${50 + (index % 2) * 20}`}
strokeWidth={2}
/>
<motion.path
d={puzzlePath(268, y + 8, 14)}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, at, at + 0.15, 1])}
/>
</g>
);
})}
</Scene>
);
}
/** A packet is sealed on its way to the server; the server confirms it. */
export function SyncScene() {
const { kf, tr } = useScene(4.2);
return (
<Scene>
<Laptop x={24} y={62} />
<path d="M86 80 H244" />
<Server x={244} y={52} />
<motion.rect
x={90}
y={74}
width={12}
height={9}
rx={2}
className="text-foreground"
initial={false}
animate={{ x: kf([0, 0, 140, 140]) }}
transition={tr([0, 0.1, 0.6, 1], { ease: "linear" })}
/>
<Padlock
x={158}
y={96}
className="text-foreground"
shackle={{
animate: { y: kf([-5, -5, 0, 0]) },
transition: tr([0, 0.3, 0.38, 1]),
}}
/>
<motion.circle
cx={276}
cy={61}
r={1.5}
className="text-success-text"
fill="currentColor"
stroke="none"
initial={false}
animate={{ r: kf([1.5, 1.5, 3, 3]) }}
transition={tr([0, 0.62, 0.7, 1])}
/>
</Scene>
);
}
/** A profile goes to the bin, the retention ring drains, and it comes back. */
export function TrashScene() {
const { kf, tr } = useScene(5);
const move = tr([0, 0.12, 0.34, 0.72, 0.94, 1]);
return (
<Scene>
<Bin x={240} y={70} />
<motion.circle
cx={251}
cy={78}
r={22}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([1, 1, 1, 0.12, 1, 1]) }}
transition={tr([0, 0.34, 0.4, 0.68, 0.8, 1])}
/>
<motion.g
initial={false}
animate={{
x: kf([0, 0, 150, 150, 0, 0]),
opacity: kf([1, 1, 0.3, 0.3, 1, 1]),
}}
transition={move}
>
<rect x={28} y={66} width={104} height={24} rx={6} />
<circle cx={44} cy={78} r={6} />
<path d="M58 74 h48 M58 82 h30" strokeWidth={2} />
</motion.g>
</Scene>
);
}
/** A command in a terminal opens a real profile; `run` also drives it. */
export function ApiScene({ variant }: { variant: "api" | "run" }) {
const { kf, tr } = useScene(4.4);
const pop = tr([0, 0.5, 0.64, 1]);
return (
<Scene>
<Window x={16} y={30} width={140} height={100} lines={0}>
<path d="M30 60 l6 5 l-6 5" className="text-foreground" />
<motion.path
d="M44 65 h72"
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.1, 0.36, 1])}
/>
<motion.g
initial={false}
animate={{ opacity: kf([0.2, 0.2, 1, 1]) }}
transition={tr([0, 0.42, 0.5, 1])}
>
<path d="M30 84 h50 M30 96 h34" strokeWidth={2} />
</motion.g>
</Window>
<path d="M156 80 H196" strokeDasharray="2 5" />
<motion.circle
r={3}
cy={80}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf([156, 156, 196, 196]) }}
transition={tr([0, 0.38, 0.5, 1])}
/>
{variant === "run" && (
<rect
x={212}
y={36}
width={92}
height={70}
rx={6}
strokeDasharray="3 3"
/>
)}
<motion.g
initial={false}
animate={{ x: kf([8, 8, 0, 0]), y: kf([8, 8, 0, 0]) }}
transition={pop}
>
<motion.rect
x={196}
y={44}
rx={6}
initial={false}
animate={{
width: kf([60, 60, 108, 108]),
height: kf([44, 44, 76, 76]),
}}
transition={pop}
/>
<motion.path
d="M196 58 H304"
initial={false}
animate={{ pathLength: kf([0.55, 0.55, 1, 1]) }}
transition={pop}
/>
<path d="M208 74 h48 M208 88 h30" strokeWidth={2} />
{variant === "run" && (
<Cursor
className="text-foreground"
animate={{
x: kf([232, 232, 262, 262]),
y: kf([92, 92, 76, 76]),
}}
transition={tr([0, 0.7, 0.9, 1])}
/>
)}
</motion.g>
<Check
x={274}
y={100}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.66, 0.78, 1])}
/>
</Scene>
);
}
const IMPORT_ARC = bezier(
{ x: 126, y: 84 },
{ x: 160, y: 26 },
{ x: 200, y: 26 },
{ x: 263, y: 84 },
12,
);
/** Cookies, logins and extensions cross from another browser into a profile. */
export function ImportScene() {
const { kf, tr } = useScene(4.6);
return (
<Scene>
<Window x={16} y={38} width={110} height={92} lines={4} />
<path d="M126 84 C160 26 200 26 263 84" strokeDasharray="2 5" />
<rect x={222} y={38} width={82} height={92} rx={6} />
<circle cx={263} cy={84} r={18} className="text-foreground" />
<circle cx={263} cy={84} r={7} className="text-foreground" />
{[0.1, 0.26, 0.42].map((at) => {
const trip = travel(IMPORT_ARC, at, at + 0.3);
return (
<motion.circle
key={at}
r={4}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf(trip.cx), cy: kf(trip.cy) }}
transition={tr(trip.times, { ease: "linear" })}
/>
);
})}
<Check
x={286}
y={50}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.76, 0.86, 1])}
/>
</Scene>
);
}
+332
View File
@@ -0,0 +1,332 @@
"use client";
import { motion } from "motion/react";
import { FaApple, FaLinux, FaWindows } from "react-icons/fa";
import {
bezier,
Check,
Cloud,
Cursor,
Laptop,
Monitor,
Padlock,
Person,
Scene,
travel,
useScene,
Window,
} from "./scene-primitives";
const UP = travel(
bezier(
{ x: 82, y: 96 },
{ x: 100, y: 70 },
{ x: 120, y: 56 },
{ x: 140, y: 56 },
8,
),
0.08,
0.34,
);
const DOWN = travel(
bezier(
{ x: 180, y: 56 },
{ x: 200, y: 56 },
{ x: 220, y: 70 },
{ x: 238, y: 96 },
8,
),
0.44,
0.7,
);
/** A profile goes up to the cloud from one machine and down to another. */
export function CloudSyncScene() {
const { kf, tr } = useScene(4.4);
return (
<Scene>
<Laptop x={24} y={78} />
<Cloud cx={160} cy={52} />
<Monitor x={238} y={74} />
<path d="M82 96 C100 70 120 56 140 56" strokeDasharray="2 5" />
<path d="M180 56 C200 56 220 70 238 96" strokeDasharray="2 5" />
<motion.circle
r={3.5}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf(UP.cx), cy: kf(UP.cy) }}
transition={tr(UP.times, { ease: "linear" })}
/>
<motion.circle
r={3.5}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ cx: kf(DOWN.cx), cy: kf(DOWN.cy) }}
transition={tr(DOWN.times, { ease: "linear" })}
/>
<Check
x={260}
y={92}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.72, 0.84, 1])}
/>
</Scene>
);
}
const SKY = travel(
bezier(
{ x: 40, y: 60 },
{ x: 100, y: -4 },
{ x: 220, y: -4 },
{ x: 280, y: 60 },
12,
),
0.05,
0.75,
);
/** The moon crosses the sky while the profile collects cookies and history. */
export function CookieBotScene() {
const { kf, tr } = useScene(5);
return (
<Scene>
<path d="M24 60 H296" strokeDasharray="2 5" />
<motion.g
className="text-foreground"
initial={false}
animate={{
x: kf(SKY.cx.map((x) => x - 40)),
y: kf(SKY.cy.map((y) => y - 60)),
}}
transition={tr(SKY.times, { ease: "linear" })}
>
<path d="M40 52 a8 8 0 1 0 6 13 a6 6 0 0 1 -6 -13 z" />
</motion.g>
<Window x={96} y={74} width={128} height={62} lines={0}>
{[0, 1, 2, 3, 4].map((index) => {
const at = 0.12 + index * 0.13;
return (
<motion.circle
key={index}
cx={116 + index * 22}
cy={104}
initial={false}
animate={{ r: kf([0, 0, 5, 5]) }}
transition={tr([0, at, at + 0.08, 1])}
/>
);
})}
<motion.path
d="M108 122 h104"
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, 0.08, 0.76, 1], { ease: "linear" })}
/>
</Window>
</Scene>
);
}
const OS_MARKS = [FaApple, FaWindows, FaLinux];
const OS_TIMES = [0, 0.28, 0.34, 0.61, 0.67, 0.94, 1];
const OS_VISIBLE = [
[1, 1, 0, 0, 0, 0, 1],
[0, 0, 1, 1, 0, 0, 0],
[0, 0, 0, 0, 1, 1, 0],
];
/** One profile presents as each operating system in turn. */
export function CrossOsScene() {
const { kf, tr } = useScene(5.4);
return (
<Scene>
<Window x={40} y={30} width={240} height={100} lines={3} />
<rect x={214} y={54} width={52} height={52} rx={8} />
{OS_MARKS.map((Mark, index) => (
<motion.g
key={Mark.name}
className="text-foreground"
initial={false}
animate={{ opacity: kf(OS_VISIBLE[index]) }}
transition={tr(OS_TIMES)}
>
<Mark x={226} y={66} width={28} height={28} stroke="none" />
</motion.g>
))}
<motion.path
d="M222 114 h12"
strokeWidth={2}
className="text-foreground"
initial={false}
animate={{ x: kf([0, 0, 12, 12, 24, 24, 0]) }}
transition={tr(OS_TIMES)}
/>
</Scene>
);
}
const AGENT_BUTTONS = [
{ x: 36, y: 56 },
{ x: 36, y: 82 },
{ x: 108, y: 108 },
];
const AGENT_CLICKS = [0.18, 0.42, 0.66];
/** The agent clicks through a page and writes each step into a recipe. */
export function AgentScene() {
const { kf, tr } = useScene(5.2);
return (
<Scene>
<Window x={16} y={26} width={168} height={108} lines={0}>
{AGENT_BUTTONS.map((button, index) => {
const at = AGENT_CLICKS[index];
return (
<g key={button.y}>
<rect x={button.x} y={button.y} width={52} height={14} rx={4} />
<motion.rect
x={button.x}
y={button.y}
width={52}
height={14}
rx={4}
fill="currentColor"
stroke="none"
className="text-foreground"
initial={false}
animate={{ opacity: kf([0, 0, 0.9, 0.9]) }}
transition={tr([0, at, at + 0.05, 1])}
/>
<Check
x={button.x + 58}
y={button.y + 6}
size={9}
className="text-success-text"
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, at + 0.04, at + 0.12, 1])}
/>
</g>
);
})}
</Window>
<Cursor
className="text-foreground"
animate={{
x: kf([150, 150, 58, 58, 58, 58, 130, 130]),
y: kf([120, 120, 60, 60, 86, 86, 112, 112]),
}}
transition={tr([
0,
0.06,
AGENT_CLICKS[0],
AGENT_CLICKS[0] + 0.1,
AGENT_CLICKS[1],
AGENT_CLICKS[1] + 0.1,
AGENT_CLICKS[2],
1,
])}
/>
<Window x={204} y={26} width={100} height={108} lines={0}>
{AGENT_CLICKS.map((at, index) => (
<g key={at}>
<circle
cx={214}
cy={58 + index * 22}
r={2}
fill="currentColor"
stroke="none"
/>
<motion.path
d={`M222 ${58 + index * 22} h${60 - index * 10}`}
strokeWidth={2}
initial={false}
animate={{ pathLength: kf([0, 0, 1, 1]) }}
transition={tr([0, at + 0.08, at + 0.2, 1])}
/>
</g>
))}
</Window>
</Scene>
);
}
/** One teammate holds the profile lock, releases it, and the other takes it. */
export function TeamScene() {
const { kf, tr } = useScene(5);
return (
<Scene>
<Person cx={44} cy={84} />
<Person cx={276} cy={84} />
<Window x={112} y={48} width={96} height={64} lines={2} />
<path d="M58 80 H112 M208 80 H262" />
<motion.path
d="M58 80 H112"
className="text-foreground"
strokeWidth={2.5}
initial={false}
animate={{ pathLength: kf([1, 1, 0, 0, 0]) }}
transition={tr([0, 0.4, 0.5, 0.52, 1])}
/>
<motion.path
d="M262 80 H208"
className="text-foreground"
strokeWidth={2.5}
initial={false}
animate={{ pathLength: kf([0, 0, 0, 1, 1]) }}
transition={tr([0, 0.55, 0.6, 0.72, 1])}
/>
<Padlock
x={152}
y={116}
className="text-foreground"
shackle={{
animate: { y: kf([0, 0, -5, -5, 0, 0]) },
transition: tr([0, 0.42, 0.5, 0.62, 0.7, 1]),
}}
/>
</Scene>
);
}
/** A request from the website crosses the bridge, drives the desktop, and reports back. */
export function RemoteScene() {
const { kf, tr } = useScene(4.8);
return (
<Scene>
<Cloud cx={64} cy={64} />
<Monitor x={232} y={62} />
<path d="M92 82 H232" strokeDasharray="2 5" />
<motion.rect
x={96}
y={78}
width={12}
height={8}
rx={2}
className="text-foreground"
initial={false}
animate={{ x: kf([0, 0, 124, 124, 0, 0]) }}
transition={tr([0, 0.08, 0.34, 0.56, 0.82, 1], { ease: "linear" })}
/>
<Cursor
className="text-foreground"
animate={{ x: kf([246, 246, 268, 268]), y: kf([88, 88, 74, 74]) }}
transition={tr([0, 0.36, 0.5, 1])}
/>
<motion.circle
cx={274}
cy={80}
className="text-foreground"
initial={false}
animate={{ r: kf([0, 0, 4, 7, 0, 0]) }}
transition={tr([0, 0.5, 0.53, 0.58, 0.62, 1])}
/>
</Scene>
);
}
+59 -3
View File
@@ -1,6 +1,7 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import { motion, useReducedMotion } from "motion/react";
import { useCallback, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { LuLock, LuRotateCcw, LuTrash2 } from "react-icons/lu";
@@ -27,10 +28,12 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { useInputModality } from "@/hooks/use-input-modality";
import { useTrashEvents } from "@/hooks/use-trash-events";
import { translateBackendError } from "@/lib/backend-errors";
import { getBrowserDisplayName } from "@/lib/browser-utils";
import { formatBytes } from "@/lib/format-bytes";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { showErrorToast, showSuccessToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, TrashedProfileSummary } from "@/types";
@@ -48,6 +51,51 @@ function daysUntil(expiresAt: number, nowSeconds: number): number {
return Math.max(0, Math.ceil((expiresAt - nowSeconds) / SECONDS_PER_DAY));
}
/**
* The retention period as a ring that drains from the top: what is left of
* it reads at a glance beside the day count, and an entry about to go is the
* one whose ring is nearly gone.
*/
function RetentionRing({
deletedAt,
expiresAt,
nowSeconds,
warning,
}: {
deletedAt: number;
expiresAt: number;
nowSeconds: number;
warning: boolean;
}) {
const reduceMotion = useReducedMotion();
const modality = useInputModality();
const animate = !reduceMotion && modality === "pointer";
const total = Math.max(1, expiresAt - deletedAt);
const left = Math.max(0, Math.min(1, (expiresAt - nowSeconds) / total));
return (
<svg
data-slot="trash-retention-ring"
viewBox="0 0 16 16"
className="size-3.5 shrink-0 -rotate-90"
fill="none"
strokeWidth={2}
strokeLinecap="round"
aria-hidden="true"
>
<circle cx={8} cy={8} r={6} className="stroke-border" />
<motion.circle
cx={8}
cy={8}
r={6}
className={warning ? "stroke-warning-text" : "stroke-foreground"}
initial={animate ? { pathLength: 0 } : false}
animate={{ pathLength: left }}
transition={{ duration: animate ? 0.6 : 0, ease: MOTION_EASE_OUT }}
/>
</svg>
);
}
export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
const { t, i18n } = useTranslation();
const { entries, isLoading, error } = useTrashEvents();
@@ -265,9 +313,17 @@ export function TrashPage({ isOpen, onClose, subPage }: TrashPageProps) {
: "text-muted-foreground",
)}
>
{daysLeft === 0
? t("trash.expiresToday")
: t("trash.expiresIn", { count: daysLeft })}
<span className="inline-flex items-center gap-1.5">
<RetentionRing
deletedAt={entry.deleted_at}
expiresAt={entry.expires_at}
nowSeconds={nowSeconds}
warning={daysLeft <= 1}
/>
{daysLeft === 0
? t("trash.expiresToday")
: t("trash.expiresIn", { count: daysLeft })}
</span>
</TableCell>
<TableCell className="hidden @xl:table-cell text-right text-sm tabular-nums text-muted-foreground">
{formatBytes(entry.size_bytes)}
+1 -1
View File
@@ -264,7 +264,7 @@ function DialogContent({
// w-[calc(100%-2rem)] (not w-full + max-w) keeps the 1rem window
// gutter even when callers override max-w-*: tailwind-merge drops
// a base max-w in favor of the caller's, but leaves width alone.
"fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100dvh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6",
"fixed top-[50%] left-[50%] z-10000 grid max-h-[calc(100vh-3rem)] w-[calc(100%-2rem)] max-w-lg -translate-[50%] gap-4 overflow-y-auto rounded-lg border bg-background p-6",
className,
)}
{...props}
+171 -36
View File
@@ -12,26 +12,56 @@ export interface OperationStep {
detail: ReactNode;
}
/** A measured relationship or operation, with labels independent of its marker. */
type NodeState = "done" | "active" | "failed" | "pending";
/**
* A measured relationship or operation as a row of stations.
*
* Every station before the current one is settled and wears a check; the
* current one is a ring, or a cross when the operation failed there; the
* ones after it wait as small dots. The wire between stations fills as they
* settle, and while the operation is busy a pulse travels the wire into the
* station being worked on. Reaching the last station with nothing failed
* settles the whole row.
*/
export function OperationFlow({
steps,
active,
failed = false,
busy = false,
label,
}: {
steps: OperationStep[];
/** The station the operation is at. */
active: number;
/** The operation stopped at `active`. */
failed?: boolean;
/** The operation is still working towards `active`. */
busy?: boolean;
label: string;
}) {
const reduced = useReducedMotion();
const modality = useInputModality();
const current = Math.max(0, Math.min(steps.length - 1, active));
const animate = !reduced && modality !== "keyboard";
const last = steps.length - 1;
const current = Math.max(0, Math.min(last, active));
if (steps.length < 2) return null;
const complete = !busy && !failed && current === last;
const stateOf = (index: number): NodeState => {
if (complete || index < current) return "done";
if (index === current) return failed ? "failed" : "active";
return "pending";
};
const at = (index: number) => `${(index / last) * 100}%`;
const settle = { duration: animate ? 0.35 : 0, ease: MOTION_EASE_OUT };
return (
<div
data-slot="operation-flow"
data-state={
failed ? "failed" : busy ? "busy" : complete ? "done" : "active"
}
role="group"
aria-label={label}
className="min-w-0 py-2"
@@ -39,30 +69,121 @@ export function OperationFlow({
<div
data-slot="operation-track"
aria-hidden="true"
className="relative mb-3 h-3"
className="relative mb-3 h-4"
style={{ marginInline: `${50 / steps.length}%` }}
>
<span className="absolute inset-x-0 top-[5px] h-0.5 rounded-full bg-border" />
{steps.map((step, index) => (
<span
key={step.id}
className="absolute top-1 size-1 -translate-x-1/2 rounded-full bg-muted-foreground"
style={{ left: `${(index / (steps.length - 1)) * 100}%` }}
/>
))}
<motion.span
data-slot="operation-marker"
initial={false}
animate={{ left: `${(current / (steps.length - 1)) * 100}%` }}
transition={{
duration: reduced || modality === "keyboard" ? 0 : 0.22,
ease: MOTION_EASE_OUT,
}}
className={cn(
"absolute top-0 size-3 -translate-x-1/2 rounded-full bg-foreground",
failed && "bg-destructive",
)}
/>
{steps.slice(0, -1).map((step, index) => {
// The wire into a station fills once that station is settled.
const filled =
complete ||
index + 1 < current ||
(index + 1 === current && !busy && !failed);
const pulsing = busy && index === current - 1;
return (
<span
key={step.id}
data-slot="operation-wire"
data-filled={filled}
className="absolute top-1/2 h-0.5 -translate-y-1/2 rounded-full bg-border"
style={{ left: at(index), width: `${100 / last}%` }}
>
<motion.span
className="absolute inset-y-0 left-0 rounded-full bg-foreground"
initial={false}
animate={{ width: filled ? "100%" : "0%" }}
transition={settle}
/>
{pulsing && (
<motion.span
data-slot="operation-pulse"
className="absolute top-1/2 size-2 -translate-x-1/2 -translate-y-1/2 rounded-full bg-foreground"
initial={false}
animate={{ left: animate ? ["0%", "100%"] : "100%" }}
transition={
animate
? {
duration: 1.1,
repeat: Number.POSITIVE_INFINITY,
ease: "easeInOut",
}
: { duration: 0 }
}
/>
)}
</span>
);
})}
{steps.map((step, index) => {
const state = stateOf(index);
return (
<span
key={step.id}
data-slot={
index === current ? "operation-marker" : "operation-node"
}
data-node-state={state}
className={cn(
"absolute top-1/2 grid -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full",
state === "pending"
? "size-1.5 bg-muted-foreground"
: "size-3.5",
state === "done" && "bg-foreground text-background",
state === "active" &&
"border-2 border-foreground bg-background",
state === "failed" &&
"border-2 border-destructive bg-background text-destructive-text",
)}
style={{ left: at(index) }}
>
{state === "done" && (
<svg
aria-hidden="true"
viewBox="0 0 10 10"
className="size-2"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
strokeLinejoin="round"
>
<motion.path
d="M2 5.2 L4.2 7.4 L8 3"
initial={animate ? { pathLength: 0 } : false}
animate={{ pathLength: 1 }}
transition={{
duration: animate ? 0.25 : 0,
ease: "easeOut",
}}
/>
</svg>
)}
{state === "failed" && (
<svg
aria-hidden="true"
viewBox="0 0 10 10"
className="size-2"
fill="none"
stroke="currentColor"
strokeWidth={2}
strokeLinecap="round"
>
<motion.path
d="M2.5 2.5 L7.5 7.5 M7.5 2.5 L2.5 7.5"
initial={animate ? { pathLength: 0 } : false}
animate={{ pathLength: 1 }}
transition={{
duration: animate ? 0.25 : 0,
ease: "easeOut",
}}
/>
</svg>
)}
{state === "active" && (
<span className="size-1 rounded-full bg-foreground" />
)}
</span>
);
})}
</div>
<ol
className="grid"
@@ -70,18 +191,32 @@ export function OperationFlow({
gridTemplateColumns: `repeat(${steps.length}, minmax(0, 1fr))`,
}}
>
{steps.map((step, index) => (
<li
key={step.id}
aria-current={index === current ? "step" : undefined}
className="min-w-0 px-1.5 text-center"
>
<p className="break-words text-xs font-medium">{step.label}</p>
<div className="mt-1 break-words text-xs leading-relaxed text-muted-foreground">
{step.detail}
</div>
</li>
))}
{steps.map((step, index) => {
const state = stateOf(index);
return (
<li
key={step.id}
aria-current={index === current ? "step" : undefined}
data-node-state={state}
className="min-w-0 px-1.5 text-center"
>
<p
className={cn(
"break-words text-xs font-medium transition-colors duration-200",
state === "pending"
? "text-muted-foreground"
: "text-foreground",
state === "failed" && "text-destructive-text",
)}
>
{step.label}
</p>
<div className="mt-1 break-words text-xs leading-relaxed text-muted-foreground">
{step.detail}
</div>
</li>
);
})}
</ol>
</div>
);