feat: new About and egg

This commit is contained in:
zhom committed 2026-10-20 00:59:01 +04:00
1 parent d8e05cd250
commit 6a9b433b6c
22 files changed
+3080 -549

No files matched your search

+135 -62
View File
@@ -254,17 +254,6 @@ test("first-run profile cutaway is interactive, isolated, and readable at both w
);
});
async function openReplay(app) {
await app.clickSelector(`[aria-label="${en.rail.more.label}"]`);
await waitForSelector(app, '[role="menu"]');
await app.clickText(en.rail.more.about, {
exact: false,
roles: ["menuitem"],
});
await app.clickSelector(slot("isolation-demo-replay"));
await waitForSelector(app, slot("profile-isolation-demo"));
}
async function assertPopupReadable(app, selector) {
await app.waitFor(
() =>
@@ -386,9 +375,8 @@ test("paused CSS animations cannot retain dismissed selects or dropdowns", async
roles: ["menuitem"],
});
await waitForSelector(app, '[role="menu"]', false);
await app.clickSelector(slot("isolation-demo-replay"));
await waitForSelector(app, slot("profile-isolation-demo"));
await app.capture("paused-css-about-replay");
await assertPopupReadable(app, slot("about-diagnostics"));
await app.capture("paused-css-about");
} finally {
await app.execute(`
document.getElementById("donut-motion-paused-css")?.remove();
@@ -824,26 +812,11 @@ async function resumeAnimations(app) {
`);
}
test("profile replay, inspector, group gestures, and remote handoff preserve context", async () => {
test("profile inspector, group gestures, and remote handoff preserve context", async () => {
await withApp(
"motion-workspace",
async (app) => {
await resize(app, 1480, 900);
await openReplay(app);
await exerciseIsolation(app);
await app.capture("isolation-replay-wide");
await resize(app, 640, 480);
await assertContained(app, '[role="dialog"]');
await app.capture("isolation-replay-narrow");
await app.clickText(en.common.buttons.back, { roles: ["button"] });
await waitForSelector(app, slot("isolation-demo-replay"));
assert.equal(
await app.execute(`return document.activeElement?.dataset.slot;`),
"isolation-demo-replay",
);
await app.pressShortcut({ key: "Escape" });
await waitForSelector(app, '[role="dialog"]', false);
await resize(app, 1480, 900);
const group = await app.invoke("create_profile_group", {
name: "Motion research group",
@@ -2101,8 +2074,8 @@ test("partial import receipts survive retry without duplicating successful profi
);
});
test("About hides a snack drawer that responds to bites and resets on close", async () => {
await withApp("motion-donut-snack", async (app) => {
test("About shows diagnostics and hides a donut lab that takes bites inside the dialog", async () => {
await withApp("motion-donut-lab", async (app) => {
await resize(app, 760, 620);
const openAbout = async () => {
await app.clickSelector(`[aria-label="${en.rail.more.label}"]`);
@@ -2112,49 +2085,149 @@ test("About hides a snack drawer that responds to bites and resets on close", as
});
await waitForSelector(app, slot("about-logo"));
};
const openLab = () =>
app.execute(`
const logo = document.querySelector('[data-slot="about-logo"]');
logo.click();
logo.click();
logo.click();
`);
const lab = () =>
app.execute(`
const node = document.querySelector('[data-slot="donut-lab"]');
return node ? { bites: Number(node.dataset.bites), state: node.dataset.state } : null;
`);
await openAbout();
await app.clickSelector(slot("about-logo"));
await waitForSelector(app, slot("donut-snack"), false);
await app.execute(
`document.querySelector('[data-slot="about-logo"]').focus();`,
const system = await app.invoke("get_system_info");
await app.waitFor(
() =>
app.execute(
`return document.querySelector('[data-slot="about-build"]')?.textContent.includes(arguments[0]);`,
[`${system.os} ${system.arch}`],
),
{ description: "the build line" },
);
await app.pressShortcut({ key: "\uE006", shift: true });
await waitForSelector(app, slot("donut-snack"));
const biteCount = () =>
app.execute(
`return Number(document.querySelector('[data-slot="donut-snack"]').dataset.bites);`,
);
assert.equal(await biteCount(), 0);
await app.capture("secret-snack-drawer");
for (const count of [1, 2, 3]) {
await app.clickSelector(slot("donut-snack-bite"));
assert.equal(await biteCount(), count);
await assertContained(app, slot("donut-snack"));
if (count === 2) await app.capture("secret-snack-two-bites");
}
await app.waitForText(en.about.snack.finished);
await app.capture("secret-snack-crumbs");
await app.clickSelector(slot("donut-snack-bite"));
assert.equal(await biteCount(), 0);
await activateFocusedByKeyboard(app);
assert.equal(await biteCount(), 1);
const updateState = await app.waitFor(
() =>
app.execute(`
const state = document.querySelector('[data-slot="about-update-status"]')?.dataset.state;
return state && state !== "loading" && state !== "checking" ? state : null;
`),
{ description: "a settled update status" },
);
assert.ok(
["development", "current", "failed", "available"].includes(updateState),
updateState,
);
await assertContained(app, '[role="dialog"]');
await app.capture("about-wide");
// Copy details gives a plain block for a bug report, with nothing private.
await app.execute(`
window.__copied = [];
navigator.clipboard.writeText = async (text) => { window.__copied.push(text); };
`);
await app.clickSelector(slot("about-copy-details"));
const [details] = await app.waitFor(
async () => {
const copied = await app.execute("return window.__copied;");
return copied.length === 1 ? copied : null;
},
{ description: "copied details" },
);
assert.ok(details.startsWith(`Donut Browser ${system.app_version}\n`));
assert.ok(details.includes(`System: ${system.os} ${system.arch}`));
assert.ok(details.includes("Wayfern: "));
assert.doesNotMatch(details, /@|\/Users\/|\/home\/|\\Users\\/);
await app.waitFor(
() =>
app.execute(
`return document.querySelector('[data-slot="about-copy-details"]')?.textContent.includes(arguments[0]);`,
[en.common.buttons.copied],
),
{ description: "the copied state" },
);
// One click is only a press; three quick ones open the lab.
await app.clickSelector(slot("about-logo"));
await waitForSelector(app, slot("donut-lab"), false);
await openLab();
await waitForSelector(app, slot("donut-lab"));
assert.equal(
await app.execute(
`return Boolean(document.querySelector('[data-slot="dialog-content"] > [data-slot="donut-lab"] canvas[role="img"]'));`,
),
true,
"the lab is drawn inside the dialog",
);
await assertContained(app, slot("donut-lab"));
assert.equal(
await app.execute(`
const box = document.querySelector('[data-slot="dialog-content"]').getBoundingClientRect();
const hit = document.elementFromPoint(box.left + box.width / 2, box.top + box.height / 2);
return Boolean(hit?.closest('[data-slot="donut-lab"]'));
`),
true,
"the lab covers the About content",
);
assert.deepEqual(await lab(), { bites: 0, state: "whole" });
await app.capture("donut-lab");
await app.clickSelector(`${slot("donut-lab")} canvas`);
await app.waitFor(async () => (await lab()).bites === 1, {
description: "a bite from a click on the middle",
});
await app.execute(
`document.querySelector('[data-slot="donut-lab"] canvas').focus();`,
);
await app.pressShortcut({ key: "\uE007" });
await app.waitFor(async () => (await lab()).bites === 2, {
description: "a bite from Enter",
});
await freezeAnimations(app);
try {
await app.clickSelector(slot("donut-snack-bite"));
assert.equal(await biteCount(), 2);
await assertPopupReadable(app, slot("donut-snack"));
await app.pressShortcut({ key: "\uE007" });
await app.waitFor(async () => (await lab()).bites === 3, {
description: "a bite under a frozen clock",
});
await assertPopupReadable(app, slot("donut-lab"));
} finally {
await resumeAnimations(app);
}
await app.capture("donut-lab-bitten");
assert.deepEqual(
await app.invoke("list_browser_profiles"),
[],
"the snack never touches profiles",
"the donut never touches profiles",
);
// Escape puts the donut away and gives the focus back to the logo.
await app.pressShortcut({ key: "Escape" });
await waitForSelector(app, slot("donut-snack"), false);
await waitForSelector(app, slot("donut-lab"), false);
await waitForSelector(app, slot("about-diagnostics"));
assert.equal(
await app.execute(`return document.activeElement?.dataset.slot;`),
"about-logo",
);
// Closing About bakes a fresh donut for next time.
await openLab();
await waitForSelector(app, slot("donut-lab"));
await app.pressShortcut({ key: "Escape" });
await waitForSelector(app, slot("donut-lab"), false);
await app.pressShortcut({ key: "Escape" });
await waitForSelector(app, '[role="dialog"]', false);
await openAbout();
await waitForSelector(app, slot("donut-snack"), false);
await waitForSelector(app, slot("donut-lab"), false);
await openLab();
await waitForSelector(app, slot("donut-lab"));
assert.deepEqual(await lab(), { bites: 0, state: "whole" });
await resize(app, 640, 400);
await assertContained(app, '[role="dialog"]');
await assertContained(app, slot("donut-lab"));
await app.capture("donut-lab-small");
});
});
+8 -2
View File
@@ -2481,7 +2481,13 @@ test("About exposes a searchable, responsive third-party license inventory", asy
exact: false,
roles: ["menuitem"],
});
await app.waitForText("Open-source anti-detect browser.");
await app.waitFor(
() =>
app.execute(
`return Boolean(document.querySelector("[role='dialog'] [data-slot='about-diagnostics']"));`,
),
{ description: "About diagnostics" },
);
const aboutText = await app.execute(
`return document.querySelector("[role='dialog']")?.textContent ?? "";`,
@@ -2571,7 +2577,7 @@ test("About exposes a searchable, responsive third-party license inventory", asy
await app.waitFor(
() =>
app.execute(
`return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "About";`,
`return document.querySelector("[role='dialog'] [data-slot='dialog-title']")?.textContent === "Donut Browser";`,
),
{ description: "About view after returning from licenses" },
);
+2 -1
View File
@@ -10,7 +10,7 @@
"prebuild": "pnpm licenses:generate",
"build": "next build",
"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:motion && pnpm test:profile-store && pnpm test:licenses && pnpm test:xray-packaging && pnpm test:rust:unit && pnpm test:sync-e2e",
"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:motion && pnpm test:donut-lab && pnpm test:profile-store && 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 src/lib/coach-mark.test.mjs",
"test:window-decorations": "node --test src/lib/window-decorations.test.mjs",
@@ -30,6 +30,7 @@
"test:schedules": "node --test src/lib/schedules.test.mjs",
"test:team-activity": "node --test src/lib/team-activity.test.mjs",
"test:motion": "node --test src/lib/motion.test.mjs",
"test:donut-lab": "node --test src/lib/donut-lab.test.mjs",
"test:profile-store": "node --test src/lib/profile-list-store.test.mjs src/components/profile-table/traffic-snapshots.test.mjs",
"test:licenses": "node --test scripts/generate-licenses.test.mjs && node scripts/generate-licenses.mjs --check",
"test:xray-packaging": "node --test src-tauri/download-xray.test.mjs",
+10 -1
View File
@@ -1133,7 +1133,7 @@ export default function Home() {
const updateNotifications = useUpdateNotifications();
const { checkForUpdates, isUpdating } = updateNotifications;
useAppUpdateNotifications();
const appUpdate = useAppUpdateNotifications();
// Check for startup URLs but only process them once
const [hasCheckedStartupUrl, setHasCheckedStartupUrl] = useState(false);
@@ -3295,6 +3295,15 @@ export default function Home() {
onClose={() => {
setAboutDialogOpen(false);
}}
update={appUpdate}
onOpenFeedback={() => {
setAboutDialogOpen(false);
setFeedbackDialogOpen(true);
}}
onOpenShortcuts={() => {
setAboutDialogOpen(false);
handleRailNavigate("shortcuts");
}}
/>
<FeedbackDialog
+635 -242
View File
@@ -3,12 +3,39 @@
import { invoke } from "@tauri-apps/api/core";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useReducedMotion } from "motion/react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import {
type ReactNode,
useCallback,
useEffect,
useId,
useMemo,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { LuArrowLeft, LuExternalLink, LuSearch } from "react-icons/lu";
import { DonutSnack } from "@/components/donut-snack";
import { ProfileIsolationDemo } from "@/components/profile-isolation-demo";
import type { IconType } from "react-icons";
import {
LuArrowLeft,
LuArrowUpRight,
LuBug,
LuCircleAlert,
LuCircleArrowUp,
LuCircleCheck,
LuCopy,
LuExternalLink,
LuFolderOpen,
LuGithub,
LuGlobe,
LuInfo,
LuKeyboard,
LuLoaderCircle,
LuRefreshCw,
LuScale,
LuSearch,
} from "react-icons/lu";
import { DonutLab } from "@/components/donut-lab";
import { Button } from "@/components/ui/button";
import { ConfirmationMark } from "@/components/ui/confirmation-mark";
import {
Dialog,
DialogContent,
@@ -16,18 +43,26 @@ import {
DialogTitle,
} from "@/components/ui/dialog";
import { FadingScrollArea } from "@/components/ui/fading-scroll-area";
import { FOCUS_RING } from "@/components/ui/focus-ring";
import { Input } from "@/components/ui/input";
import { StepTransition } from "@/components/ui/step-transition";
import licenses from "@/generated/licenses.json";
import xraySource from "@/generated/xray-source.json";
import { launchDonutClone } from "@/lib/donut-physics";
import type { AppUpdateState } from "@/hooks/use-app-update-notifications";
import { MOTION_EASE_OUT } from "@/lib/motion";
import { showErrorToast } from "@/lib/toast-utils";
import { playUiSound } from "@/lib/ui-sounds";
import { cn } from "@/lib/utils";
import { Logo } from "./icons/logo";
import { RippleButton } from "./ui/ripple";
interface AboutDialogProps {
isOpen: boolean;
onClose: () => void;
}
const WEBSITE_URL = "https://donutbrowser.com";
const REPOSITORY_URL = "https://github.com/donutbrowser/donutbrowser";
const RELEASES_URL = `${REPOSITORY_URL}/releases`;
/** A check older than this runs again when About opens. */
const CHECK_TTL_MS = 10 * 60 * 1000;
/** Three clicks on the logo, each this soon after the last, open the lab. */
const EGG_CLICK_GAP_MS = 600;
const EGG_CLICKS = 3;
interface SystemInfo {
app_version: string;
@@ -36,31 +71,76 @@ interface SystemInfo {
portable: boolean;
}
type AboutView = "about" | "licenses" | "isolation";
type BuildChannel = "stable" | "nightly" | "development";
// Flywheel: each click adds spin; past this speed the donut escapes the
// dialog and bounces around the window (shared physics with the rail egg).
const SPIN_PER_CLICK = 540; // deg/s
const ESCAPE_VELOCITY = 2200; // deg/s
const SPIN_FRICTION = 1.1; // fraction of velocity lost per second
/** The channel, the version people read, and its release notes, from the build's version. */
function describeBuild(version: string) {
if (version.startsWith("dev-")) {
return {
channel: "development" as BuildChannel,
label: version.slice(4),
notesUrl: RELEASES_URL,
};
}
if (version.startsWith("nightly-")) {
return {
channel: "nightly" as BuildChannel,
label: version.slice(8),
notesUrl: RELEASES_URL,
};
}
const label = version.replace(/^v/, "");
return {
channel: "stable" as BuildChannel,
label,
notesUrl: `${RELEASES_URL}/tag/v${label}`,
};
}
export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
const { t } = useTranslation();
type CheckState = "idle" | "checking" | "done" | "failed";
type AboutView = "about" | "licenses";
interface AboutDialogProps {
isOpen: boolean;
onClose: () => void;
update: AppUpdateState;
onOpenFeedback: () => void;
onOpenShortcuts: () => void;
}
export function AboutDialog({
isOpen,
onClose,
update,
onOpenFeedback,
onOpenShortcuts,
}: AboutDialogProps) {
const { t, i18n } = useTranslation();
const reducedMotion = useReducedMotion();
const [systemInfo, setSystemInfo] = useState<SystemInfo | null>(null);
const [logoFlown, setLogoFlown] = useState(false);
const [snackOpen, setSnackOpen] = useState(false);
const [wayfernVersions, setWayfernVersions] = useState<string[] | null>(null);
const [view, setView] = useState<AboutView>("about");
const [licenseQuery, setLicenseQuery] = useState("");
const [check, setCheck] = useState<CheckState>("idle");
const [copied, setCopied] = useState(false);
const [lab, setLab] = useState<{ x: number; y: number } | null>(null);
const contentRef = useRef<HTMLDivElement>(null);
const logoRef = useRef<HTMLButtonElement>(null);
const rotationRef = useRef(0);
const velocityRef = useRef(0);
const rafRef = useRef(0);
const lastTimeRef = useRef(0);
const cancelLaunchRef = useRef<(() => void) | null>(null);
const logoClicksRef = useRef<number[]>([]);
const lastCheckAtRef = useRef(0);
const copiedTimerRef = useRef<number | undefined>(undefined);
const restoreLicensesButtonFocusRef = useRef(false);
const restoreExampleButtonFocusRef = useRef(false);
const restoreLogoFocusRef = useRef(false);
const diagnosticsId = useId();
const helpId = useId();
const build = useMemo(
() => (systemInfo ? describeBuild(systemInfo.app_version) : null),
[systemInfo],
);
const { checkForAppUpdatesManual } = update;
useEffect(() => {
if (!isOpen) return;
@@ -69,84 +149,133 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
.catch(() => {
setSystemInfo(null);
});
invoke<string[]>("get_downloaded_browser_versions", {
browserStr: "wayfern",
})
.then(setWayfernVersions)
.catch(() => {
setWayfernVersions([]);
});
}, [isOpen]);
const stopSpin = useCallback(() => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
velocityRef.current = 0;
const runCheck = useCallback(async () => {
setCheck("checking");
lastCheckAtRef.current = Date.now();
try {
await checkForAppUpdatesManual();
setCheck("done");
} catch (error) {
console.error("Failed to check for app updates:", error);
setCheck("failed");
}
}, [checkForAppUpdatesManual]);
// Opening About checks for an update, unless one is known or a check ran lately.
const channel = build?.channel;
const knownUpdate = update.updateInfo !== null;
useEffect(() => {
if (!isOpen || !channel || channel === "development" || knownUpdate) {
return;
}
if (check === "checking") return;
if (
check !== "idle" &&
Date.now() - lastCheckAtRef.current < CHECK_TTL_MS
) {
return;
}
void runCheck();
}, [isOpen, channel, knownUpdate, check, runCheck]);
useEffect(() => () => window.clearTimeout(copiedTimerRef.current), []);
const details = useMemo(() => {
if (!systemInfo || !build) return "";
return [
`Donut Browser ${systemInfo.app_version}`,
`Channel: ${build.channel}`,
`System: ${systemInfo.os} ${systemInfo.arch}`,
`Wayfern: ${wayfernVersions?.length ? wayfernVersions.join(", ") : "none"}`,
`Portable: ${systemInfo.portable ? "yes" : "no"}`,
`Language: ${i18n.language}`,
].join("\n");
}, [systemInfo, build, wayfernVersions, i18n.language]);
const copyDetails = useCallback(async () => {
try {
await navigator.clipboard.writeText(details);
playUiSound("copy");
setCopied(true);
window.clearTimeout(copiedTimerRef.current);
copiedTimerRef.current = window.setTimeout(() => setCopied(false), 2000);
} catch (error) {
console.error("Failed to copy details:", error);
showErrorToast(t("about.diagnostics.copyFailed"));
}
}, [details, t]);
const openLogs = useCallback(async () => {
try {
await invoke("open_log_directory");
} catch (error) {
console.error("Failed to open the log folder:", error);
showErrorToast(t("about.diagnostics.openLogsFailed"));
}
}, [t]);
const closeLab = useCallback(() => {
restoreLogoFocusRef.current = true;
setLab(null);
}, []);
const spinFrame = useCallback(
(time: number) => {
const el = logoRef.current;
if (!el) {
rafRef.current = 0;
return;
}
const dt = Math.min((time - lastTimeRef.current) / 1000, 0.05);
lastTimeRef.current = time;
rotationRef.current += velocityRef.current * dt;
velocityRef.current *= Math.max(0, 1 - SPIN_FRICTION * dt);
el.style.transform = `rotate(${rotationRef.current}deg)`;
if (velocityRef.current >= ESCAPE_VELOCITY) {
// The flywheel wins: the donut tears loose and joins the bounce sim,
// keeping its spin.
stopSpin();
setLogoFlown(true);
cancelLaunchRef.current = launchDonutClone(el, {
initialVX: Math.random() > 0.5 ? 420 : -420,
initialVY: -750,
spinSpeed: ESCAPE_VELOCITY,
onExit: () => {
cancelLaunchRef.current = null;
},
});
return;
}
if (velocityRef.current > 5) {
rafRef.current = requestAnimationFrame(spinFrame);
} else {
rafRef.current = 0;
velocityRef.current = 0;
}
},
[stopSpin],
);
useEffect(() => {
if (lab || !restoreLogoFocusRef.current) return;
restoreLogoFocusRef.current = false;
logoRef.current?.focus();
}, [lab]);
const handleLogoClick = useCallback(() => {
if (reducedMotion || logoFlown) return;
velocityRef.current += SPIN_PER_CLICK;
if (!rafRef.current) {
lastTimeRef.current = performance.now();
rafRef.current = requestAnimationFrame(spinFrame);
const now = performance.now();
const clicks = logoClicksRef.current;
const last = clicks[clicks.length - 1];
if (last !== undefined && now - last > EGG_CLICK_GAP_MS) clicks.length = 0;
clicks.push(now);
const logo = logoRef.current;
if (clicks.length >= EGG_CLICKS && logo) {
clicks.length = 0;
const rect = logo.getBoundingClientRect();
contentRef.current?.scrollTo({ top: 0 });
setLab({ x: rect.left + rect.width / 2, y: rect.top + rect.height / 2 });
return;
}
}, [reducedMotion, logoFlown, spinFrame]);
const resetLogo = useCallback(() => {
stopSpin();
cancelLaunchRef.current?.();
cancelLaunchRef.current = null;
rotationRef.current = 0;
if (logoRef.current) {
logoRef.current.style.transform = "";
logoRef.current.style.visibility = "";
// Each press squeezes a little harder, as if something were inside.
if (!reducedMotion && logo && typeof logo.animate === "function") {
const depth = clicks.length;
logo.animate(
[
{ transform: "scale(1) rotate(0deg)" },
{
transform: `scale(${0.93 - depth * 0.03}) rotate(${depth * -7}deg)`,
},
{ transform: "scale(1) rotate(0deg)" },
],
{
duration: 240,
easing: `cubic-bezier(${MOTION_EASE_OUT.join(",")})`,
},
);
}
setLogoFlown(false);
}, [stopSpin]);
}, [reducedMotion]);
const handleClose = useCallback(() => {
resetLogo();
setSnackOpen(false);
setLab(null);
logoClicksRef.current = [];
restoreLogoFocusRef.current = false;
setView("about");
setLicenseQuery("");
restoreLicensesButtonFocusRef.current = false;
restoreExampleButtonFocusRef.current = false;
onClose();
}, [onClose, resetLogo]);
}, [onClose]);
const handleLicensesButtonRef = useCallback(
(node: HTMLButtonElement | null) => {
@@ -158,18 +287,11 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
[],
);
const handleOpenLicenses = useCallback(() => {
resetLogo();
setLicenseQuery("");
setView("licenses");
}, [resetLogo]);
const handleBackToAbout = useCallback(() => {
restoreLicensesButtonFocusRef.current = view === "licenses";
restoreExampleButtonFocusRef.current = view === "isolation";
restoreLicensesButtonFocusRef.current = true;
setLicenseQuery("");
setView("about");
}, [view]);
}, []);
const filteredLicenses = useMemo(() => {
const query = licenseQuery.trim().toLocaleLowerCase();
@@ -181,12 +303,16 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
);
}, [licenseQuery]);
useEffect(() => {
return () => {
if (rafRef.current) cancelAnimationFrame(rafRef.current);
cancelLaunchRef.current?.();
};
}, []);
const meta = systemInfo
? [
build?.label,
build && t(`about.channel.${build.channel}`),
`${systemInfo.os} ${systemInfo.arch}`,
systemInfo.portable ? t("about.portable") : null,
]
.filter(Boolean)
.join(" · ")
: null;
return (
<Dialog
@@ -196,13 +322,20 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
}}
>
<DialogContent
className={
view === "isolation"
? "flex max-h-[calc(100dvh-3rem)] max-w-2xl flex-col overflow-y-auto"
: view === "licenses"
? "flex h-[min(80dvh,40rem)] max-w-lg flex-col overflow-hidden"
: "max-w-sm"
}
ref={contentRef}
hideClose={lab !== null}
onEscapeKeyDown={(event) => {
if (!lab) return;
event.preventDefault();
closeLab();
}}
className={cn(
"max-w-2xl",
view === "licenses"
? "flex h-[min(80dvh,40rem)] flex-col overflow-hidden"
: "@container",
lab && "overflow-hidden",
)}
>
<StepTransition
transitionKey={view}
@@ -210,145 +343,157 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
className={
view === "licenses"
? "flex min-h-0 flex-1 flex-col gap-4"
: "grid gap-4"
: "grid gap-5"
}
>
{view === "about" ? (
<>
<DialogHeader>
<DialogTitle>{t("about.title")}</DialogTitle>
</DialogHeader>
<div className="flex flex-col items-center gap-3 py-4">
{snackOpen ? (
<DonutSnack />
) : (
<button
ref={logoRef}
type="button"
data-slot="about-logo"
aria-label={t("header.donutLogo")}
onClick={(event) => {
if (event.shiftKey) {
resetLogo();
setSnackOpen(true);
} else {
handleLogoClick();
}
}}
onKeyDown={(event) => {
if (
event.shiftKey &&
(event.key === "Enter" || event.key === " ")
) {
event.preventDefault();
resetLogo();
setSnackOpen(true);
}
}}
className="grid size-16 cursor-pointer place-items-center rounded-full bg-transparent text-foreground select-none will-change-transform"
style={logoFlown ? { visibility: "hidden" } : undefined}
<div inert={lab !== null} className="contents">
<header className="flex items-start gap-4 pr-8">
<button
ref={logoRef}
type="button"
data-slot="about-logo"
aria-label={t("header.donutLogo")}
onClick={handleLogoClick}
className={cn(
"grid size-14 shrink-0 cursor-pointer place-items-center rounded-2xl bg-foreground/5 text-foreground select-none",
FOCUS_RING,
)}
>
<Logo className="size-9" aria-hidden="true" />
</button>
<div className="min-w-0 flex-1 pt-0.5">
<DialogTitle className="text-lg leading-tight">
Donut Browser
</DialogTitle>
<p
data-slot="about-build"
className="mt-1 min-h-4 text-xs text-muted-foreground"
>
<Logo className="size-14" />
</button>
)}
<div className="text-center">
<p className="text-lg font-semibold">Donut Browser</p>
{systemInfo && (
<>
<p className="text-sm text-muted-foreground">
{t("about.version", {
version: systemInfo.app_version,
})}
{systemInfo.portable && (
<span className="ml-1.5 rounded border border-border bg-foreground/5 px-1 py-px text-[11px] align-middle">
{t("about.portableBadge")}
</span>
)}
</p>
<p className="text-xs text-muted-foreground">
{systemInfo.os} {systemInfo.arch}
</p>
</>
{meta}
</p>
{build && (
<LinkButton
className="mt-1.5"
onClick={() => void openUrl(build.notesUrl)}
>
{t("about.whatsNew")}
</LinkButton>
)}
</div>
</header>
<p className="text-center text-xs text-muted-foreground">
{t("about.licenseNotice")}
</p>
<UpdateStatus
channel={build?.channel ?? null}
check={check}
update={update}
onCheck={() => void runCheck()}
/>
<div className="flex flex-wrap justify-center gap-2">
<Button
variant="outline"
size="sm"
onClick={() => void openUrl("https://donutbrowser.com")}
>
{t("about.website")}
</Button>
<Button
variant="outline"
size="sm"
onClick={() =>
void openUrl(
"https://github.com/donutbrowser/donutbrowser",
)
}
>
{t("about.github")}
</Button>
<Button
ref={handleLicensesButtonRef}
variant="outline"
size="sm"
onClick={handleOpenLicenses}
>
{t("about.licenses")}
</Button>
</div>
<Button
data-slot="isolation-demo-replay"
ref={(node) => {
if (node && restoreExampleButtonFocusRef.current) {
restoreExampleButtonFocusRef.current = false;
node.focus();
}
}}
variant="ghost"
size="sm"
onClick={() => {
resetLogo();
setView("isolation");
}}
<div className="grid gap-4 @lg:grid-cols-[minmax(0,1.1fr)_minmax(0,1fr)]">
<section
data-slot="about-diagnostics"
aria-labelledby={diagnosticsId}
className="rounded-lg bg-foreground/[0.035] p-3"
>
{t("isolationDemo.replay")}
</Button>
<SectionLabel id={diagnosticsId}>
{t("about.diagnostics.title")}
</SectionLabel>
<dl className="mt-2 grid grid-cols-[auto_minmax(0,1fr)] gap-x-4 gap-y-1.5 text-xs">
<DiagnosticRow label={t("about.diagnostics.version")}>
{systemInfo?.app_version}
</DiagnosticRow>
<DiagnosticRow label={t("about.diagnostics.channel")}>
{build && t(`about.channel.${build.channel}`)}
</DiagnosticRow>
<DiagnosticRow label={t("about.diagnostics.system")}>
{systemInfo && `${systemInfo.os} ${systemInfo.arch}`}
</DiagnosticRow>
<DiagnosticRow label="Wayfern">
{wayfernVersions &&
(wayfernVersions.length > 0
? wayfernVersions.join(", ")
: t("about.diagnostics.notInstalled"))}
</DiagnosticRow>
<DiagnosticRow label={t("about.portable")}>
{systemInfo &&
(systemInfo.portable
? t("about.diagnostics.yes")
: t("about.diagnostics.no"))}
</DiagnosticRow>
</dl>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
size="sm"
variant="soft"
data-slot="about-copy-details"
disabled={!details}
onClick={() => void copyDetails()}
>
{copied ? (
<ConfirmationMark />
) : (
<LuCopy aria-hidden="true" />
)}
<span aria-live="polite">
{copied
? t("common.buttons.copied")
: t("about.diagnostics.copy")}
</span>
</Button>
<Button
type="button"
size="sm"
variant="subtle"
onClick={() => void openLogs()}
>
<LuFolderOpen aria-hidden="true" />
{t("about.diagnostics.openLogs")}
</Button>
</div>
</section>
<nav aria-labelledby={helpId} className="px-1 @lg:pt-3">
<SectionLabel id={helpId} className="px-2">
{t("about.help.title")}
</SectionLabel>
<ul className="mt-1.5 grid gap-0.5">
<HelpItem icon={LuBug} onClick={onOpenFeedback}>
{t("about.help.reportBug")}
</HelpItem>
<HelpItem icon={LuKeyboard} onClick={onOpenShortcuts}>
{t("rail.more.keyboardShortcuts")}
</HelpItem>
<HelpItem
icon={LuScale}
buttonRef={handleLicensesButtonRef}
onClick={() => {
setLicenseQuery("");
setView("licenses");
}}
>
{t("about.licenses")}
</HelpItem>
<HelpItem
icon={LuGlobe}
external
onClick={() => void openUrl(WEBSITE_URL)}
>
{t("about.website")}
</HelpItem>
</ul>
</nav>
</div>
<div className="flex justify-end">
<RippleButton variant="outline" onClick={handleClose}>
{t("common.buttons.close")}
</RippleButton>
</div>
</>
) : view === "isolation" ? (
<>
<DialogHeader className="flex-row items-center gap-2">
<Button
type="button"
variant="ghost"
size="icon"
className="-ml-2 size-8"
aria-label={t("common.buttons.back")}
onClick={handleBackToAbout}
autoFocus
>
<LuArrowLeft aria-hidden="true" />
</Button>
<DialogTitle>{t("isolationDemo.replay")}</DialogTitle>
</DialogHeader>
<ProfileIsolationDemo />
</>
<footer className="flex flex-wrap items-center justify-between gap-x-4 gap-y-1 border-t border-border pt-3 text-[11px] text-muted-foreground">
<p>{t("about.footer")}</p>
<LinkButton onClick={() => void openUrl(REPOSITORY_URL)}>
<LuGithub aria-hidden="true" className="size-3.5" />
{t("about.sourceCode")}
</LinkButton>
</footer>
</div>
) : (
<>
<DialogHeader className="flex-row items-center gap-2">
@@ -373,6 +518,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
<Input
autoFocus
type="search"
variant="soft"
value={licenseQuery}
onChange={(event) => {
setLicenseQuery(event.target.value);
@@ -394,7 +540,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
{filteredLicenses.map(({ name, license }) => (
<li
key={`${name}-${license}`}
className="grid grid-cols-[minmax(0,1fr)_minmax(7rem,45%)] items-baseline gap-3 border-b border-border/60 py-2.5 last:border-b-0"
className="grid grid-cols-[minmax(0,1fr)_minmax(7rem,45%)] items-baseline gap-3 rounded-md px-2 py-2 hover:bg-foreground/5"
>
{name === "Xray-core" ? (
<button
@@ -418,7 +564,7 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
{name}
</span>
)}
<code className="break-words text-right text-xs text-muted-foreground">
<code className="break-words text-right font-mono text-xs text-muted-foreground">
{license}
</code>
</li>
@@ -436,7 +582,254 @@ export function AboutDialog({ isOpen, onClose }: AboutDialogProps) {
</>
)}
</StepTransition>
{lab && view === "about" && (
<DonutLab origin={lab} onClose={closeLab} />
)}
</DialogContent>
</Dialog>
);
}
function SectionLabel({
id,
className,
children,
}: {
id: string;
className?: string;
children: ReactNode;
}) {
return (
<h3
id={id}
className={cn(
"text-[11px] font-medium tracking-wide text-muted-foreground uppercase",
className,
)}
>
{children}
</h3>
);
}
function DiagnosticRow({
label,
children,
}: {
label: string;
children: ReactNode;
}) {
return (
<>
<dt className="text-muted-foreground">{label}</dt>
<dd
className="min-h-4 truncate font-mono text-foreground"
title={typeof children === "string" ? children : undefined}
>
{children}
</dd>
</>
);
}
function LinkButton({
className,
onClick,
children,
}: {
className?: string;
onClick: () => void;
children: ReactNode;
}) {
return (
<button
type="button"
onClick={onClick}
className={cn(
"inline-flex items-center gap-1 rounded-sm text-xs text-muted-foreground underline-offset-4 transition-colors hover:text-foreground hover:underline",
FOCUS_RING,
className,
)}
>
{children}
<LuArrowUpRight aria-hidden="true" className="size-3 shrink-0" />
</button>
);
}
function HelpItem({
icon: Icon,
external = false,
buttonRef,
onClick,
children,
}: {
icon: IconType;
external?: boolean;
buttonRef?: (node: HTMLButtonElement | null) => void;
onClick: () => void;
children: ReactNode;
}) {
return (
<li>
<button
ref={buttonRef}
type="button"
onClick={onClick}
className={cn(
"group flex h-8 w-full items-center gap-2.5 rounded-md px-2 text-left text-sm text-foreground transition-colors hover:bg-foreground/5",
FOCUS_RING,
)}
>
<Icon
aria-hidden="true"
className="size-4 shrink-0 text-muted-foreground transition-colors group-hover:text-foreground"
/>
<span className="min-w-0 flex-1 truncate">{children}</span>
{external && (
<LuArrowUpRight
aria-hidden="true"
className="size-3.5 shrink-0 text-muted-foreground"
/>
)}
</button>
</li>
);
}
function UpdateStatus({
channel,
check,
update,
onCheck,
}: {
channel: BuildChannel | null;
check: CheckState;
update: AppUpdateState;
onCheck: () => void;
}) {
const { t } = useTranslation();
const info = update.updateInfo;
const percent = update.updateProgress?.percentage;
let state: string;
let icon: ReactNode;
let text: string;
let action: ReactNode = null;
const spinner = (
<LuLoaderCircle
aria-hidden="true"
className="size-4 animate-spin text-muted-foreground motion-reduce:animate-none"
/>
);
const arrow = (
<LuCircleArrowUp aria-hidden="true" className="size-4 text-primary-text" />
);
if (channel === null) {
state = "loading";
icon = spinner;
text = t("about.update.checking");
} else if (channel === "development") {
state = "development";
icon = (
<LuInfo aria-hidden="true" className="size-4 text-muted-foreground" />
);
text = t("about.update.development");
} else if (info && update.updateReady) {
state = "ready";
icon = arrow;
text = t("about.update.ready", { version: info.new_version });
action = (
<Button
type="button"
size="sm"
onClick={() => void update.restartToUpdate()}
>
{t("about.update.restart")}
</Button>
);
} else if (info && update.isUpdating) {
state = "downloading";
icon = spinner;
text =
typeof percent === "number"
? t("about.update.downloadingPercent", {
version: info.new_version,
percent: Math.round(percent),
})
: t("about.update.downloading", { version: info.new_version });
} else if (info?.repo_update) {
state = "available";
icon = arrow;
text = t("about.update.packageManager", { version: info.new_version });
} else if (info) {
state = "available";
icon = arrow;
text = t("about.update.available", { version: info.new_version });
action = info.manual_update_required ? (
<Button
type="button"
size="sm"
onClick={() => void openUrl(info.release_page_url ?? info.download_url)}
>
{t("about.update.download")}
<LuArrowUpRight aria-hidden="true" />
</Button>
) : (
<Button
type="button"
size="sm"
onClick={() => void update.installAppUpdate(info)}
>
{t("about.update.update")}
</Button>
);
} else if (check === "failed") {
state = "failed";
icon = (
<LuCircleAlert
aria-hidden="true"
className="size-4 text-muted-foreground"
/>
);
text = t("about.update.failed");
action = (
<Button type="button" size="sm" variant="subtle" onClick={onCheck}>
<LuRefreshCw aria-hidden="true" />
{t("common.buttons.retry")}
</Button>
);
} else if (check === "done") {
state = "current";
icon = (
<LuCircleCheck aria-hidden="true" className="size-4 text-success-text" />
);
text = t("about.update.current");
action = (
<Button type="button" size="sm" variant="subtle" onClick={onCheck}>
<LuRefreshCw aria-hidden="true" />
{t("about.update.checkAgain")}
</Button>
);
} else {
state = "checking";
icon = spinner;
text = t("about.update.checking");
}
return (
<div
data-slot="about-update-status"
data-state={state}
className="flex min-h-12 items-center gap-3 rounded-lg bg-foreground/[0.035] py-2 pr-2 pl-3"
>
<span className="grid size-4 shrink-0 place-items-center">{icon}</span>
<p role="status" className="min-w-0 flex-1 text-sm text-foreground">
{text}
</p>
{action}
</div>
);
}
+748
View File
@@ -0,0 +1,748 @@
"use client";
import { motion, useReducedMotion } from "motion/react";
import {
type KeyboardEvent,
type PointerEvent,
useCallback,
useEffect,
useId,
useLayoutEffect,
useRef,
useState,
} from "react";
import { useTranslation } from "react-i18next";
import { LuRotateCw, LuX } from "react-icons/lu";
import { Button } from "@/components/ui/button";
import { useMotionAllowed } from "@/hooks/use-motion-allowed";
import {
addBite,
type Bite,
bakeDonut,
type Donut,
type Frame,
findBiteTarget,
type Grid,
isEaten,
KIND_CRUMB,
KIND_DOUGH,
KIND_EMPTY,
KIND_GLAZE,
KIND_SPRINKLE,
layoutGrid,
type Mat3,
materialAt,
remainingShare,
renderFrame,
SPRINKLE_COLORS,
START_ORIENTATION,
spin,
turn,
} from "@/lib/donut-lab";
import { MOTION_DURATION, MOTION_EASE_OUT } from "@/lib/motion";
import { playUiSound } from "@/lib/ui-sounds";
import { cn } from "@/lib/utils";
/** Radians per pixel of drag, and per arrow key press. */
const DRAG_TURN = 0.011;
const KEY_TURN = 0.26;
/** Radians per second the donut turns on its own axis when left alone. */
const IDLE_SPIN = 0.55;
/** How fast a flick slows down, per second. */
const FRICTION = 2.6;
const MAX_FLICK = 9;
const ASSEMBLY_MS = 700;
const CHOMP_MS = 140;
const GRAVITY = 1100;
const CRUMB_GLYPHS = ".,'`:;*";
const SCRAMBLE = "@%#*+=-:.";
const TARGET_COLS = 64;
type Assembly = {
start: number;
/** "burst": out of the logo. "rings": in from rings around the view. */
mode: "burst" | "rings";
x: number;
y: number;
};
interface Crumb {
x: number;
y: number;
vx: number;
vy: number;
char: string;
kind: number;
age: number;
life: number;
}
interface Palette {
foreground: string;
sprinkles: string[];
/** The background is light: dense characters are shadow. */
ink: boolean;
}
function resolvePalette(host: HTMLElement): Palette {
const probe = document.createElement("span");
probe.style.display = "none";
host.append(probe);
const read = (variable: string) => {
probe.style.color = `var(${variable})`;
return getComputedStyle(probe).color;
};
const foreground = read("--foreground");
const sprinkles = Array.from({ length: SPRINKLE_COLORS }, (_, i) =>
read(`--chart-${i + 1}`),
);
const background = read("--background");
probe.remove();
let ink = !document.documentElement.classList.contains("dark");
// Whatever syntax the theme uses, the canvas knows how bright it is.
const swatch = document.createElement("canvas").getContext("2d", {
willReadFrequently: true,
});
if (swatch) {
swatch.fillStyle = background;
swatch.fillRect(0, 0, 1, 1);
const [r, g, b] = swatch.getImageData(0, 0, 1, 1).data;
ink = 0.2126 * r + 0.7152 * g + 0.0722 * b > 140;
}
return { foreground, sprinkles, ink };
}
function easeOut(t: number) {
return 1 - (1 - t) ** 3;
}
/** A stable pseudo-random 0..1 for a cell, changing every `tick`. */
function flicker(index: number, tick: number) {
const n = Math.sin(index * 12.9898 + tick * 78.233) * 43758.5453;
return n - Math.floor(n);
}
const KIND_ALPHA: Record<number, number> = {
[KIND_DOUGH]: 0.82,
[KIND_GLAZE]: 1,
[KIND_CRUMB]: 0.68,
};
interface DonutLabProps {
/** The logo's center in client pixels: the donut grows out of it. */
origin: { x: number; y: number } | null;
onClose: () => void;
}
/**
* The About easter egg: a donut drawn in text, ray marched live. Drag to
* turn it, click or press Space to bite it, bake another when it is gone.
*/
export function DonutLab({ origin, onClose }: DonutLabProps) {
const { t } = useTranslation();
const reducedMotion = useReducedMotion() ?? false;
const motionAllowed = useMotionAllowed();
const descriptionId = useId();
const hostRef = useRef<HTMLDivElement>(null);
const canvasRef = useRef<HTMLCanvasElement>(null);
const donutRef = useRef<Donut | null>(null);
donutRef.current ??= bakeDonut();
const orientationRef = useRef<Mat3>(START_ORIENTATION);
const flickRef = useRef({ x: 0, y: 0 });
const idleRef = useRef(1);
const dragRef = useRef<{
id: number;
x: number;
y: number;
startX: number;
startY: number;
at: number;
moved: boolean;
} | null>(null);
const suppressClickRef = useRef(false);
const crumbsRef = useRef<Crumb[]>([]);
const chompRef = useRef<{ bite: Bite; start: number } | null>(null);
const assemblyRef = useRef<Assembly | null>(null);
const layoutRef = useRef<{
grid: Grid;
fontSize: number;
font: string;
dpr: number;
} | null>(null);
const frameRef = useRef<Frame | null>(null);
const paletteRef = useRef<Palette | null>(null);
const rafRef = useRef(0);
const lastTickRef = useRef(0);
const motionRef = useRef({ reducedMotion, motionAllowed });
motionRef.current = { reducedMotion, motionAllowed };
const [bites, setBites] = useState(0);
const [left, setLeft] = useState(100);
const [eaten, setEaten] = useState(false);
const [dragging, setDragging] = useState(false);
const draw = useCallback((now: number) => {
const canvas = canvasRef.current;
const layout = layoutRef.current;
const palette = paletteRef.current;
const context = canvas?.getContext("2d");
if (!canvas || !layout || !palette || !context) return;
const { grid, font, dpr } = layout;
const donut = donutRef.current;
if (!donut) return;
const frame = renderFrame(
donut,
orientationRef.current,
grid,
palette.ink,
frameRef.current ?? undefined,
);
frameRef.current = frame;
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, grid.width, grid.height);
context.font = font;
context.textBaseline = "middle";
context.textAlign = "left";
const colorOf = (kind: number) =>
kind >= KIND_SPRINKLE
? palette.sprinkles[kind - KIND_SPRINKLE]
: palette.foreground;
const alphaOf = (kind: number) => KIND_ALPHA[kind] ?? 1;
const assembly = assemblyRef.current;
const progress = assembly
? Math.min(1, (now - assembly.start) / ASSEMBLY_MS)
: 1;
if (assembly && progress < 1) {
// Every character flies to its place: out of the logo, or in from
// rings around the view, still scrambled until it lands.
const cx = grid.width / 2;
const cy = grid.height / 2;
const reach = Math.hypot(cx, cy);
const tick = Math.floor(now / 45);
for (
let kind = KIND_DOUGH;
kind < KIND_SPRINKLE + SPRINKLE_COLORS;
kind++
) {
context.fillStyle = colorOf(kind);
for (let i = 0; i < frame.kinds.length; i++) {
if (frame.kinds[i] !== kind) continue;
const col = i % grid.cols;
const row = (i - col) / grid.cols;
const fx = grid.offsetX + (col + 0.5) * grid.cellWidth;
const fy = grid.offsetY + (row + 0.5) * grid.cellHeight;
const distance = Math.hypot(fx - cx, fy - cy) / reach;
const delay =
(assembly.mode === "burst" ? distance : 1 - distance) * 0.35;
const local = easeOut(
Math.min(1, Math.max(0, (progress - delay) / 0.65)),
);
let x: number;
let y: number;
if (assembly.mode === "burst") {
const sx = assembly.x + (fx - assembly.x) * 0.15;
const sy = assembly.y + (fy - assembly.y) * 0.15;
x = sx + (fx - sx) * local;
y = sy + (fy - sy) * local;
} else {
const angle = Math.atan2(fy - cy, fx - cx) + 0.9 * (1 - local);
const radius =
Math.hypot(fx - cx, fy - cy) + reach * 0.6 * (1 - local);
x = cx + Math.cos(angle) * radius;
y = cy + Math.sin(angle) * radius;
}
const char =
local < 0.75
? SCRAMBLE[Math.floor(flicker(i, tick) * SCRAMBLE.length)]
: frame.chars[i];
context.globalAlpha = alphaOf(kind) * (0.55 + 0.45 * local);
context.fillText(char, x - grid.cellWidth / 2, y);
}
}
} else {
if (assembly) assemblyRef.current = null;
// One string per row and color: spaces keep the columns.
const rowChars = Array.from(
{ length: KIND_SPRINKLE + SPRINKLE_COLORS },
() => new Array<string>(grid.cols),
);
const used = new Array<boolean>(KIND_SPRINKLE + SPRINKLE_COLORS);
for (let row = 0; row < grid.rows; row++) {
used.fill(false);
for (const chars of rowChars) chars.fill(" ");
for (let col = 0; col < grid.cols; col++) {
const i = row * grid.cols + col;
const kind = frame.kinds[i];
if (kind === KIND_EMPTY) continue;
rowChars[kind][col] = frame.chars[i];
used[kind] = true;
}
const y = grid.offsetY + (row + 0.5) * grid.cellHeight;
for (let kind = KIND_DOUGH; kind < rowChars.length; kind++) {
if (!used[kind]) continue;
context.fillStyle = colorOf(kind);
context.globalAlpha = alphaOf(kind);
context.fillText(rowChars[kind].join(""), grid.offsetX, y);
}
}
}
for (const crumb of crumbsRef.current) {
context.fillStyle = colorOf(crumb.kind);
context.globalAlpha =
alphaOf(crumb.kind) * Math.min(1, (crumb.life - crumb.age) / 0.4);
context.fillText(crumb.char, crumb.x, crumb.y);
}
context.globalAlpha = 1;
}, []);
const needsFrames = useCallback(() => {
const flick = flickRef.current;
return (
!motionRef.current.reducedMotion ||
Math.abs(flick.x) + Math.abs(flick.y) > 0.01 ||
crumbsRef.current.length > 0 ||
chompRef.current !== null ||
assemblyRef.current !== null
);
}, []);
const tick = useCallback(
(now: number) => {
rafRef.current = 0;
const dt = Math.min(
0.05,
Math.max(0, (now - lastTickRef.current) / 1000),
);
lastTickRef.current = now;
const { reducedMotion: reduced } = motionRef.current;
if (!dragRef.current?.moved) {
const flick = flickRef.current;
let m = orientationRef.current;
if (Math.abs(flick.x) + Math.abs(flick.y) > 0.01) {
m = turn(m, flick.x * dt, flick.y * dt);
const decay = Math.exp(-FRICTION * dt);
flick.x *= decay;
flick.y *= decay;
} else {
flick.x = 0;
flick.y = 0;
}
if (!reduced) {
idleRef.current = Math.min(1, idleRef.current + dt * 0.8);
m = spin(m, IDLE_SPIN * idleRef.current * dt);
}
orientationRef.current = m;
}
const chomp = chompRef.current;
if (chomp) {
const p = Math.min(1, (now - chomp.start) / CHOMP_MS);
chomp.bite.grow = 0.35 + 0.65 * easeOut(p);
if (p >= 1) chompRef.current = null;
}
const layout = layoutRef.current;
if (layout && crumbsRef.current.length > 0) {
const floor = layout.grid.height - layout.grid.cellHeight * 0.6;
crumbsRef.current = crumbsRef.current.filter((crumb) => {
crumb.age += dt;
crumb.vy += GRAVITY * dt;
crumb.x += crumb.vx * dt;
crumb.y += crumb.vy * dt;
if (crumb.y > floor) {
crumb.y = floor;
crumb.vy = Math.abs(crumb.vy) < 60 ? 0 : -crumb.vy * 0.3;
crumb.vx *= 0.55;
}
return crumb.age < crumb.life;
});
}
draw(now);
if (document.visibilityState === "visible" && needsFrames()) {
rafRef.current = requestAnimationFrame(tick);
}
},
[draw, needsFrames],
);
/** Draws now, and keeps drawing while something moves. */
const wake = useCallback(() => {
const now = performance.now();
draw(now);
if (
!rafRef.current &&
document.visibilityState === "visible" &&
needsFrames()
) {
lastTickRef.current = now;
rafRef.current = requestAnimationFrame(tick);
}
}, [draw, needsFrames, tick]);
// Size the canvas to its box and lay the character grid over it.
useLayoutEffect(() => {
const canvas = canvasRef.current;
const host = hostRef.current;
if (!canvas || !host) return;
paletteRef.current = resolvePalette(host);
const family = getComputedStyle(canvas).fontFamily || "monospace";
const measure = canvas.getContext("2d");
if (measure) measure.font = `100px ${family}`;
let advance = (measure?.measureText("M").width ?? 60) / 100;
const resize = () => {
const width = canvas.clientWidth;
const height = canvas.clientHeight;
if (width === 0 || height === 0) return;
const dpr = Math.min(2, window.devicePixelRatio || 1);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
const { grid, fontSize } = layoutGrid(
width,
height,
advance,
TARGET_COLS,
);
layoutRef.current = {
grid,
fontSize,
font: `${fontSize}px ${family}`,
dpr,
};
frameRef.current = null;
wake();
};
resize();
const observer = new ResizeObserver(resize);
observer.observe(canvas);
// Measure again once the mono font is in, if it was not yet.
let alive = true;
void document.fonts?.load(`16px ${family}`).then(() => {
if (!alive || !measure) return;
measure.font = `100px ${family}`;
advance = measure.measureText("M").width / 100 || advance;
resize();
});
return () => {
alive = false;
observer.disconnect();
};
}, [wake]);
// The donut takes the keys, and grows out of the logo the first time.
// biome-ignore lint/correctness/useExhaustiveDependencies: once, on open
useLayoutEffect(() => {
const canvas = canvasRef.current;
canvas?.focus({ preventScroll: true });
if (!canvas || !origin || !motionRef.current.motionAllowed) return;
const rect = canvas.getBoundingClientRect();
assemblyRef.current = {
start: performance.now(),
mode: "burst",
x: origin.x - rect.left,
y: origin.y - rect.top,
};
wake();
}, []);
useEffect(() => {
const onVisibility = () => {
if (document.visibilityState === "visible") wake();
else if (rafRef.current) {
cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
}
};
document.addEventListener("visibilitychange", onVisibility);
return () => {
document.removeEventListener("visibilitychange", onVisibility);
if (rafRef.current) cancelAnimationFrame(rafRef.current);
rafRef.current = 0;
};
}, [wake]);
// Reduced motion stops the idle spin; a change of setting restarts frames.
useEffect(() => {
if (reducedMotion) {
flickRef.current = { x: 0, y: 0 };
crumbsRef.current = [];
}
wake();
}, [reducedMotion, wake]);
const takeBite = useCallback(
(x: number, y: number, reach: number) => {
const layout = layoutRef.current;
const donut = donutRef.current;
if (!layout || !donut || isEaten(donut)) return;
const target = findBiteTarget(
donut,
orientationRef.current,
layout.grid,
x,
y,
reach,
);
if (!target) return;
const { material, sprinkle } = materialAt(donut, target.point);
const animate = !motionRef.current.reducedMotion;
const bite = addBite(
donut,
target.point,
target.normal,
animate ? 0.35 : 1,
);
playUiSound("crunch");
if (animate) {
chompRef.current = { bite, start: performance.now() };
const kind =
material === "glaze"
? KIND_GLAZE
: material === "crumb"
? KIND_CRUMB
: KIND_DOUGH;
const count = 7 + Math.floor(Math.random() * 5);
for (let i = 0; i < count; i++) {
const fromSprinkle = material === "sprinkle" && i < 2;
crumbsRef.current.push({
x: target.x + (Math.random() - 0.5) * layout.grid.cellWidth * 2,
y: target.y,
vx: (Math.random() - 0.5) * 220,
vy: -80 - Math.random() * 220,
char: fromSprinkle
? "-"
: CRUMB_GLYPHS[Math.floor(Math.random() * CRUMB_GLYPHS.length)],
kind: fromSprinkle
? KIND_SPRINKLE + donut.sprinkles[sprinkle].color
: kind,
age: 0,
life: 1.1 + Math.random() * 0.8,
});
}
// The donut rocks a little under the bite.
flickRef.current.x += (Math.random() - 0.5) * 1.2;
flickRef.current.y += (Math.random() - 0.5) * 1.2;
}
const share = remainingShare(donut);
setBites(donut.bites.length);
setLeft(Math.round(share * 100));
setEaten(isEaten(donut, share));
wake();
},
[wake],
);
const biteNearCenter = useCallback(() => {
const layout = layoutRef.current;
if (!layout) return;
const { width, height } = layout.grid;
takeBite(width / 2, height / 2, Math.max(width, height));
}, [takeBite]);
const bakeAnother = useCallback(() => {
donutRef.current = bakeDonut();
chompRef.current = null;
setBites(0);
setLeft(100);
setEaten(false);
const layout = layoutRef.current;
if (layout && motionRef.current.motionAllowed) {
assemblyRef.current = {
start: performance.now(),
mode: "rings",
x: layout.grid.width / 2,
y: layout.grid.height / 2,
};
}
canvasRef.current?.focus();
wake();
}, [wake]);
const onPointerDown = (event: PointerEvent<HTMLCanvasElement>) => {
if (event.button !== 0 || dragRef.current) return;
try {
event.currentTarget.setPointerCapture(event.pointerId);
} catch {
// A synthetic pointer cannot be captured; the drag still works inside.
}
dragRef.current = {
id: event.pointerId,
x: event.clientX,
y: event.clientY,
startX: event.clientX,
startY: event.clientY,
at: event.timeStamp,
moved: false,
};
suppressClickRef.current = false;
flickRef.current = { x: 0, y: 0 };
};
const onPointerMove = (event: PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
if (
!drag.moved &&
Math.hypot(event.clientX - drag.startX, event.clientY - drag.startY) < 4
) {
return;
}
if (!drag.moved) {
drag.moved = true;
setDragging(true);
}
const dx = event.clientX - drag.x;
const dy = event.clientY - drag.y;
const dt = Math.max(0.008, (event.timeStamp - drag.at) / 1000);
drag.x = event.clientX;
drag.y = event.clientY;
drag.at = event.timeStamp;
orientationRef.current = turn(
orientationRef.current,
dy * DRAG_TURN,
dx * DRAG_TURN,
);
// The flick is the drag's recent speed, smoothed.
const flick = flickRef.current;
flick.x = flick.x * 0.6 + ((dy * DRAG_TURN) / dt) * 0.4;
flick.y = flick.y * 0.6 + ((dx * DRAG_TURN) / dt) * 0.4;
wake();
};
const endDrag = (event: PointerEvent<HTMLCanvasElement>) => {
const drag = dragRef.current;
if (!drag || drag.id !== event.pointerId) return;
dragRef.current = null;
if (!drag.moved) return;
suppressClickRef.current = true;
setDragging(false);
idleRef.current = 0;
const flick = flickRef.current;
// A drag that stopped before release does not fling.
const still = event.timeStamp - drag.at > 80;
if (motionRef.current.reducedMotion || still) {
flick.x = 0;
flick.y = 0;
} else {
flick.x = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, flick.x));
flick.y = Math.max(-MAX_FLICK, Math.min(MAX_FLICK, flick.y));
}
wake();
};
const onKeyDown = (event: KeyboardEvent<HTMLCanvasElement>) => {
const turns: Record<string, [number, number]> = {
ArrowUp: [-KEY_TURN, 0],
ArrowDown: [KEY_TURN, 0],
ArrowLeft: [0, -KEY_TURN],
ArrowRight: [0, KEY_TURN],
};
const step = turns[event.key];
if (step) {
event.preventDefault();
flickRef.current = { x: 0, y: 0 };
idleRef.current = 0;
orientationRef.current = turn(orientationRef.current, ...step);
wake();
return;
}
if (event.key === " " || event.key === "Enter") {
event.preventDefault();
if (!event.repeat) biteNearCenter();
}
};
const status =
bites === 0
? t("about.lab.hint")
: t("about.lab.status", { count: bites, left });
return (
<motion.div
ref={hostRef}
data-slot="donut-lab"
data-bites={bites}
data-state={eaten ? "eaten" : "whole"}
// Readable from the first frame: the entry only settles its scale.
initial={motionAllowed ? { scale: 0.985 } : false}
animate={{ scale: 1 }}
transition={{ duration: MOTION_DURATION.base, ease: MOTION_EASE_OUT }}
className="absolute inset-0 z-20 overflow-hidden rounded-[inherit] bg-background/95 bg-[radial-gradient(circle_at_50%_48%,color-mix(in_oklab,var(--foreground)_7%,transparent),transparent_62%)]"
>
<canvas
ref={canvasRef}
role="img"
tabIndex={0}
aria-label={t("about.lab.label", { left })}
aria-describedby={descriptionId}
onPointerDown={onPointerDown}
onPointerMove={onPointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onClick={(event) => {
if (suppressClickRef.current) {
suppressClickRef.current = false;
return;
}
const layout = layoutRef.current;
// A click with no pointer behind it came from a key or a script.
if (event.detail === 0 || !layout) {
biteNearCenter();
return;
}
const rect = event.currentTarget.getBoundingClientRect();
takeBite(
event.clientX - rect.left,
event.clientY - rect.top,
Math.min(layout.grid.width, layout.grid.height) * 0.22,
);
}}
onKeyDown={onKeyDown}
className={cn(
"absolute inset-0 size-full touch-none font-mono outline-none select-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-inset",
dragging ? "cursor-grabbing" : "cursor-grab",
)}
/>
<p id={descriptionId} className="sr-only">
{t("about.lab.description")}
</p>
<p className="sr-only" aria-live="polite">
{bites > 0 && (eaten ? t("about.lab.eaten") : status)}
</p>
<Button
type="button"
variant="subtle"
size="icon"
className="absolute top-3 right-3 size-8"
aria-label={t("about.lab.close")}
onClick={onClose}
>
<LuX aria-hidden="true" />
</Button>
<div className="pointer-events-none absolute inset-x-4 bottom-3 flex min-h-8 items-center justify-between gap-3">
<p
aria-hidden="true"
className="truncate font-mono text-[11px] text-muted-foreground tabular-nums"
>
{eaten ? t("about.lab.eaten") : status}
</p>
{eaten && (
<Button
type="button"
variant="soft"
size="sm"
className="pointer-events-auto"
onClick={bakeAnother}
>
<LuRotateCw aria-hidden="true" />
{t("about.lab.bakeAnother")}
</Button>
)}
</div>
</motion.div>
);
}
-102
View File
@@ -1,102 +0,0 @@
"use client";
import { motion } from "motion/react";
import { useCallback, useId, useState } from "react";
import { useTranslation } from "react-i18next";
import { Logo } from "@/components/icons/logo";
import { useMotionAllowed } from "@/hooks/use-motion-allowed";
import { MOTION_EASE_OUT } from "@/lib/motion";
/** A tiny snack drawer, unlocked with Shift + the About logo. */
export function DonutSnack() {
const { t } = useTranslation();
const still = !useMotionAllowed();
const maskId = useId();
const [bites, setBites] = useState(0);
const focusBite = useCallback((button: HTMLButtonElement | null) => {
button?.focus({ preventScroll: true });
}, []);
return (
<div
data-slot="donut-snack"
data-bites={bites}
className="flex flex-col items-center gap-2"
>
<button
type="button"
ref={focusBite}
data-slot="donut-snack-bite"
onClick={() => setBites((count) => (count + 1) % 4)}
className="group grid justify-items-center gap-1 rounded-md px-3 py-1 text-foreground outline-none focus-visible:ring-2 focus-visible:ring-ring"
>
<motion.svg
aria-hidden="true"
viewBox="0 0 100 100"
className="size-24"
initial={false}
animate={{
rotate: still ? 0 : bites === 1 ? -4 : bites === 2 ? 3 : 0,
}}
transition={{ duration: still ? 0 : 0.18, ease: MOTION_EASE_OUT }}
>
<defs>
<mask
id={maskId}
maskUnits="userSpaceOnUse"
x="0"
y="0"
width="100"
height="100"
>
<rect width="100" height="100" fill="white" />
{bites >= 1 && (
<g fill="black">
<circle cx="77" cy="18" r="17" />
<circle cx="88" cy="31" r="14" />
<circle cx="67" cy="24" r="12" />
</g>
)}
{bites >= 2 && (
<g fill="black">
<circle cx="16" cy="36" r="20" />
<circle cx="28" cy="25" r="16" />
<circle cx="27" cy="44" r="12" />
</g>
)}
</mask>
</defs>
{bites < 3 ? (
<g mask={`url(#${maskId})`}>
<Logo width="100" height="100" />
</g>
) : (
<g fill="currentColor">
<path d="m38 54 6-3 4 5-3 4-7-2Z" />
<path d="m57 60 5-2 3 4-5 3-4-1Z" />
<rect
x="51"
y="45"
width="3"
height="6"
rx="1.5"
transform="rotate(28 52.5 48)"
/>
<circle cx="34" cy="65" r="1.5" />
<circle cx="65" cy="48" r="1.5" />
</g>
)}
</motion.svg>
<span className="text-xs font-medium group-hover:text-muted-foreground">
{t(bites === 3 ? "about.snack.again" : "about.snack.takeBite")}
</span>
</button>
<p
role="status"
className="max-w-64 text-center text-xs text-muted-foreground"
>
{t(bites === 3 ? "about.snack.finished" : "about.snack.found")}
</p>
</div>
);
}
+12 -16
View File
@@ -46,23 +46,15 @@ export function useAppUpdateNotifications() {
}
}, [isClient, dismissedVersion]);
/** Checks now, even for a dismissed version. Throws when the check fails. */
const checkForAppUpdatesManual = useCallback(async () => {
if (!isClient) return;
try {
console.log("Triggering manual app update check...");
const update = await invoke<AppUpdateInfo | null>(
"check_for_app_updates_manual",
);
console.log("Manual check result:", update);
// Always show manual check results, even if previously dismissed
autoDownloadedVersion.current = null;
setUpdateInfo(update);
} catch (error) {
console.error("Failed to manually check for app updates:", error);
}
}, [isClient]);
const update = await invoke<AppUpdateInfo | null>(
"check_for_app_updates_manual",
);
autoDownloadedVersion.current = null;
setUpdateInfo(update);
return update;
}, []);
const handleAppUpdate = useCallback(
async (appUpdateInfo: AppUpdateInfo) => {
@@ -244,5 +236,9 @@ export function useAppUpdateNotifications() {
checkForAppUpdates,
checkForAppUpdatesManual,
dismissAppUpdate,
installAppUpdate: handleAppUpdate,
restartToUpdate: handleRestart,
};
}
export type AppUpdateState = ReturnType<typeof useAppUpdateNotifications>;
+50 -11
View File
@@ -3270,21 +3270,61 @@
"noOtherTargets": "Keine anderen Wayfern-Profile ausgewählt"
},
"about": {
"title": "Über",
"version": "Version {{version}}",
"portableBadge": "portabel",
"licenseNotice": "Open-Source-Anti-Detect-Browser.",
"website": "Website",
"github": "GitHub",
"licenses": "Lizenzen",
"searchLicenses": "Lizenzen suchen…",
"noMatchingLicenses": "Keine passenden Lizenzen.",
"openSource": "Quellcode von {{name}} anzeigen",
"snack": {
"found": "Sie haben die Snack-Schublade gefunden.",
"finished": "Nur Krümel. Keine Cookies.",
"takeBite": "Abbeißen",
"again": "Noch ein Donut"
"channel": {
"stable": "Stabil",
"nightly": "Nightly",
"development": "Entwicklung"
},
"portable": "Portabel",
"whatsNew": "Neu in dieser Version",
"update": {
"checking": "Suche nach Updates…",
"development": "Updates sind für Entwicklungs-Builds deaktiviert",
"current": "Auf dem neuesten Stand",
"checkAgain": "Erneut prüfen",
"failed": "Suche nach Updates fehlgeschlagen",
"available": "Version {{version}} ist verfügbar",
"packageManager": "Version {{version}} ist über Ihren Paketmanager verfügbar",
"update": "Aktualisieren",
"download": "Herunterladen",
"downloading": "Version {{version}} wird heruntergeladen…",
"downloadingPercent": "Version {{version}} wird heruntergeladen… {{percent}} %",
"ready": "Version {{version}} ist bereit zur Installation",
"restart": "Neu starten und aktualisieren"
},
"diagnostics": {
"title": "Diagnose",
"version": "Version",
"channel": "Kanal",
"system": "System",
"yes": "Ja",
"no": "Nein",
"notInstalled": "Nicht installiert",
"copy": "Details kopieren",
"copyFailed": "Details konnten nicht kopiert werden",
"openLogs": "Log-Ordner öffnen",
"openLogsFailed": "Log-Ordner konnte nicht geöffnet werden"
},
"help": {
"title": "Hilfe",
"reportBug": "Fehler melden"
},
"footer": "Freie Open-Source-Software.",
"sourceCode": "Quellcode",
"lab": {
"hint": "Ziehen zum Drehen. Klicken zum Abbeißen.",
"status_one": "{{count}} Bissen · {{left}} % übrig",
"status_other": "{{count}} Bissen · {{left}} % übrig",
"label": "Donut, {{left}} % übrig",
"description": "Ein 3D-Donut aus Textzeichen. Ziehen Sie ihn oder nutzen Sie die Pfeiltasten, um ihn zu drehen. Klicken Sie ihn an oder drücken Sie die Leertaste, um abzubeißen.",
"eaten": "Nur noch Krümel.",
"bakeAnother": "Noch einen backen",
"close": "Donut schließen"
}
},
"clearOnClose": {
@@ -3713,7 +3753,6 @@
"hint": "Wählen Sie ein Profil und ändern Sie dann sein Cookie oder seine Route.",
"changed": "{{name}} wurde geändert. Das andere Profil hat sein eigenes Cookie und seine eigene Route behalten.",
"resetDone": "Beide Beispiele wurden zurückgesetzt.",
"replay": "Profilisolierung erkunden",
"device": "Gerät",
"site": "Website"
},
+50 -11
View File
@@ -3270,21 +3270,61 @@
"noOtherTargets": "No other Wayfern profiles selected"
},
"about": {
"title": "About",
"version": "Version {{version}}",
"portableBadge": "portable",
"licenseNotice": "Open-source anti-detect browser.",
"website": "Website",
"github": "GitHub",
"licenses": "Licenses",
"searchLicenses": "Search licenses…",
"noMatchingLicenses": "No matching licenses.",
"openSource": "View {{name}} source code",
"snack": {
"found": "You found the snack drawer.",
"finished": "All crumbs. No cookies.",
"takeBite": "Take a bite",
"again": "One more donut"
"channel": {
"stable": "Stable",
"nightly": "Nightly",
"development": "Development"
},
"portable": "Portable",
"whatsNew": "What's new in this version",
"update": {
"checking": "Checking for updates…",
"development": "Updates are off for development builds",
"current": "Up to date",
"checkAgain": "Check again",
"failed": "Could not check for updates",
"available": "Version {{version}} is available",
"packageManager": "Version {{version}} is available from your package manager",
"update": "Update",
"download": "Download",
"downloading": "Downloading version {{version}}…",
"downloadingPercent": "Downloading version {{version}}… {{percent}}%",
"ready": "Version {{version}} is ready to install",
"restart": "Restart to update"
},
"diagnostics": {
"title": "Diagnostics",
"version": "Version",
"channel": "Channel",
"system": "System",
"yes": "Yes",
"no": "No",
"notInstalled": "Not installed",
"copy": "Copy details",
"copyFailed": "Could not copy the details",
"openLogs": "Open logs folder",
"openLogsFailed": "Could not open the logs folder"
},
"help": {
"title": "Help",
"reportBug": "Report a bug"
},
"footer": "Free and open-source software.",
"sourceCode": "Source code",
"lab": {
"hint": "Drag to turn it. Click to take a bite.",
"status_one": "{{count}} bite · {{left}}% left",
"status_other": "{{count}} bites · {{left}}% left",
"label": "Donut, {{left}}% left",
"description": "A 3D donut drawn with text characters. Drag it or use the arrow keys to turn it. Click it or press Space to take a bite.",
"eaten": "Only crumbs left.",
"bakeAnother": "Bake another",
"close": "Close the donut"
}
},
"clearOnClose": {
@@ -3713,7 +3753,6 @@
"hint": "Select a profile, then change its cookie or route.",
"changed": "{{name}} changed. The other profile kept its own cookie and route.",
"resetDone": "Both examples have been reset.",
"replay": "Explore profile isolation",
"device": "Device",
"site": "Site"
},
+50 -11
View File
@@ -3290,21 +3290,61 @@
"noOtherTargets": "No hay otros perfiles Wayfern seleccionados"
},
"about": {
"title": "Acerca de",
"version": "Versión {{version}}",
"portableBadge": "portable",
"licenseNotice": "Navegador anti-detección de código abierto.",
"website": "Sitio web",
"github": "GitHub",
"licenses": "Licencias",
"searchLicenses": "Buscar licencias…",
"noMatchingLicenses": "No hay licencias que coincidan.",
"openSource": "Ver el código fuente de {{name}}",
"snack": {
"found": "Encontraste el cajón de los dulces.",
"finished": "Solo migas. Nada de cookies.",
"takeBite": "Da un mordisco",
"again": "Otro donut"
"channel": {
"stable": "Estable",
"nightly": "Nightly",
"development": "Desarrollo"
},
"portable": "Portátil",
"whatsNew": "Novedades de esta versión",
"update": {
"checking": "Buscando actualizaciones…",
"development": "Las actualizaciones están desactivadas en las compilaciones de desarrollo",
"current": "Actualizado",
"checkAgain": "Volver a comprobar",
"failed": "No se pudo buscar actualizaciones",
"available": "La versión {{version}} está disponible",
"packageManager": "La versión {{version}} está disponible en tu gestor de paquetes",
"update": "Actualizar",
"download": "Descargar",
"downloading": "Descargando la versión {{version}}…",
"downloadingPercent": "Descargando la versión {{version}}… {{percent}} %",
"ready": "La versión {{version}} está lista para instalarse",
"restart": "Reiniciar para actualizar"
},
"diagnostics": {
"title": "Diagnóstico",
"version": "Versión",
"channel": "Canal",
"system": "Sistema",
"yes": "Sí",
"no": "No",
"notInstalled": "No instalado",
"copy": "Copiar detalles",
"copyFailed": "No se pudieron copiar los detalles",
"openLogs": "Abrir carpeta de registros",
"openLogsFailed": "No se pudo abrir la carpeta de registros"
},
"help": {
"title": "Ayuda",
"reportBug": "Informar de un error"
},
"footer": "Software libre y de código abierto.",
"sourceCode": "Código fuente",
"lab": {
"hint": "Arrástralo para girarlo. Haz clic para darle un mordisco.",
"status_one": "{{count}} mordisco · queda un {{left}} %",
"status_other": "{{count}} mordiscos · queda un {{left}} %",
"label": "Dónut, queda un {{left}} %",
"description": "Un dónut en 3D dibujado con caracteres de texto. Arrástralo o usa las flechas para girarlo. Haz clic en él o pulsa Espacio para darle un mordisco.",
"eaten": "Solo quedan migas.",
"bakeAnother": "Hornear otro",
"close": "Cerrar el dónut"
}
},
"clearOnClose": {
@@ -3754,7 +3794,6 @@
"hint": "Selecciona un perfil y cambia su cookie o ruta.",
"changed": "{{name}} cambió. El otro perfil conservó su propia cookie y ruta.",
"resetDone": "Se restablecieron ambos ejemplos.",
"replay": "Explorar el aislamiento de perfiles",
"device": "Dispositivo",
"site": "Sitio"
},
+50 -11
View File
@@ -3290,21 +3290,61 @@
"noOtherTargets": "Aucun autre profil Wayfern sélectionné"
},
"about": {
"title": "À propos",
"version": "Version {{version}}",
"portableBadge": "portable",
"licenseNotice": "Navigateur anti-détection open source.",
"website": "Site web",
"github": "GitHub",
"licenses": "Licences",
"searchLicenses": "Rechercher des licences…",
"noMatchingLicenses": "Aucune licence correspondante.",
"openSource": "Voir le code source de {{name}}",
"snack": {
"found": "Vous avez trouvé la réserve de gourmandises.",
"finished": "Des miettes, aucun cookie.",
"takeBite": "Prenez une bouchée",
"again": "Un autre donut"
"channel": {
"stable": "Stable",
"nightly": "Nightly",
"development": "Développement"
},
"portable": "Portable",
"whatsNew": "Nouveautés de cette version",
"update": {
"checking": "Recherche de mises à jour…",
"development": "Les mises à jour sont désactivées pour les versions de développement",
"current": "À jour",
"checkAgain": "Vérifier à nouveau",
"failed": "Impossible de rechercher les mises à jour",
"available": "La version {{version}} est disponible",
"packageManager": "La version {{version}} est disponible dans votre gestionnaire de paquets",
"update": "Mettre à jour",
"download": "Télécharger",
"downloading": "Téléchargement de la version {{version}}…",
"downloadingPercent": "Téléchargement de la version {{version}}… {{percent}} %",
"ready": "La version {{version}} est prête à être installée",
"restart": "Redémarrer pour mettre à jour"
},
"diagnostics": {
"title": "Diagnostic",
"version": "Version",
"channel": "Canal",
"system": "Système",
"yes": "Oui",
"no": "Non",
"notInstalled": "Non installé",
"copy": "Copier les détails",
"copyFailed": "Impossible de copier les détails",
"openLogs": "Ouvrir le dossier des journaux",
"openLogsFailed": "Impossible d'ouvrir le dossier des journaux"
},
"help": {
"title": "Aide",
"reportBug": "Signaler un bug"
},
"footer": "Logiciel libre et open source.",
"sourceCode": "Code source",
"lab": {
"hint": "Faites-le glisser pour le tourner. Cliquez pour croquer.",
"status_one": "{{count}} bouchée · il reste {{left}} %",
"status_other": "{{count}} bouchées · il reste {{left}} %",
"label": "Donut, il reste {{left}} %",
"description": "Un donut en 3D dessiné avec des caractères. Faites-le glisser ou utilisez les flèches pour le tourner. Cliquez dessus ou appuyez sur Espace pour croquer.",
"eaten": "Il ne reste que des miettes.",
"bakeAnother": "En cuire un autre",
"close": "Fermer le donut"
}
},
"clearOnClose": {
@@ -3754,7 +3794,6 @@
"hint": "Sélectionnez un profil, puis modifiez son cookie ou sa route.",
"changed": "{{name}} a changé. L’autre profil a conservé son propre cookie et sa route.",
"resetDone": "Les deux exemples ont été réinitialisés.",
"replay": "Découvrir l’isolation des profils",
"device": "Appareil",
"site": "Site"
},
+50 -11
View File
@@ -3270,21 +3270,61 @@
"noOtherTargets": "他に Wayfern プロファイルが選択されていません"
},
"about": {
"title": "このアプリについて",
"version": "バージョン {{version}}",
"portableBadge": "ポータブル",
"licenseNotice": "オープンソースのアンチディテクトブラウザです。",
"website": "ウェブサイト",
"github": "GitHub",
"licenses": "ライセンス",
"searchLicenses": "ライセンスを検索…",
"noMatchingLicenses": "一致するライセンスはありません。",
"openSource": "{{name}} のソースコードを表示",
"snack": {
"found": "おやつの引き出しを見つけました。",
"finished": "残ったのはパンくずだけ。Cookieはなし。",
"takeBite": "ひと口どうぞ",
"again": "もうひとつ"
"channel": {
"stable": "安定版",
"nightly": "Nightly",
"development": "開発版"
},
"portable": "ポータブル",
"whatsNew": "このバージョンの新機能",
"update": {
"checking": "アップデートを確認しています…",
"development": "開発版ではアップデートは無効です",
"current": "最新です",
"checkAgain": "再確認",
"failed": "アップデートを確認できませんでした",
"available": "バージョン {{version}} が利用可能です",
"packageManager": "バージョン {{version}} がパッケージマネージャーで利用可能です",
"update": "アップデート",
"download": "ダウンロード",
"downloading": "バージョン {{version}} をダウンロードしています…",
"downloadingPercent": "バージョン {{version}} をダウンロードしています… {{percent}}%",
"ready": "バージョン {{version}} をインストールする準備ができました",
"restart": "再起動してアップデート"
},
"diagnostics": {
"title": "診断",
"version": "バージョン",
"channel": "チャンネル",
"system": "システム",
"yes": "はい",
"no": "いいえ",
"notInstalled": "未インストール",
"copy": "詳細をコピー",
"copyFailed": "詳細をコピーできませんでした",
"openLogs": "ログフォルダーを開く",
"openLogsFailed": "ログフォルダーを開けませんでした"
},
"help": {
"title": "ヘルプ",
"reportBug": "不具合を報告"
},
"footer": "無料のオープンソースソフトウェアです。",
"sourceCode": "ソースコード",
"lab": {
"hint": "ドラッグで回転、クリックでひと口。",
"status_one": "{{count}} 口 · 残り {{left}}%",
"status_other": "{{count}} 口 · 残り {{left}}%",
"label": "ドーナツ、残り {{left}}%",
"description": "文字で描かれた 3D のドーナツです。ドラッグするか矢印キーで回転します。クリックするかスペースキーでひと口かじります。",
"eaten": "残ったのはかけらだけ。",
"bakeAnother": "もう一つ焼く",
"close": "ドーナツを閉じる"
}
},
"clearOnClose": {
@@ -3713,7 +3753,6 @@
"hint": "プロファイルを選択して、Cookieや経路を変更してください。",
"changed": "{{name}}を変更しました。もう一方のプロファイルのCookieと経路はそのままです。",
"resetDone": "両方のサンプルをリセットしました。",
"replay": "プロファイルの分離を試す",
"device": "デバイス",
"site": "サイト"
},
+50 -11
View File
@@ -3270,21 +3270,61 @@
"noOtherTargets": "선택된 다른 Wayfern 프로필이 없습니다"
},
"about": {
"title": "정보",
"version": "버전 {{version}}",
"portableBadge": "포터블",
"licenseNotice": "오픈 소스 안티 디텍트 브라우저입니다.",
"website": "웹사이트",
"github": "GitHub",
"licenses": "라이선스",
"searchLicenses": "라이선스 검색…",
"noMatchingLicenses": "일치하는 라이선스가 없습니다.",
"openSource": "{{name}} 소스 코드 보기",
"snack": {
"found": "간식 서랍을 찾았어요.",
"finished": "부스러기만 남았어요. 쿠키는 없어요.",
"takeBite": "한 입 먹기",
"again": "도넛 하나 더"
"channel": {
"stable": "안정판",
"nightly": "Nightly",
"development": "개발판"
},
"portable": "포터블",
"whatsNew": "이 버전의 새로운 기능",
"update": {
"checking": "업데이트 확인 중…",
"development": "개발 빌드에서는 업데이트가 꺼져 있습니다",
"current": "최신 버전입니다",
"checkAgain": "다시 확인",
"failed": "업데이트를 확인할 수 없습니다",
"available": "버전 {{version}}을(를) 사용할 수 있습니다",
"packageManager": "패키지 관리자에서 버전 {{version}}을(를) 사용할 수 있습니다",
"update": "업데이트",
"download": "다운로드",
"downloading": "버전 {{version}} 다운로드 중…",
"downloadingPercent": "버전 {{version}} 다운로드 중… {{percent}}%",
"ready": "버전 {{version}}을(를) 설치할 준비가 되었습니다",
"restart": "재시작하여 업데이트"
},
"diagnostics": {
"title": "진단",
"version": "버전",
"channel": "채널",
"system": "시스템",
"yes": "예",
"no": "아니요",
"notInstalled": "설치되지 않음",
"copy": "세부 정보 복사",
"copyFailed": "세부 정보를 복사할 수 없습니다",
"openLogs": "로그 폴더 열기",
"openLogsFailed": "로그 폴더를 열 수 없습니다"
},
"help": {
"title": "도움말",
"reportBug": "버그 신고"
},
"footer": "무료 오픈 소스 소프트웨어입니다.",
"sourceCode": "소스 코드",
"lab": {
"hint": "드래그하여 돌리고, 클릭하여 한 입 베어 무세요.",
"status_one": "{{count}}입 · {{left}}% 남음",
"status_other": "{{count}}입 · {{left}}% 남음",
"label": "도넛, {{left}}% 남음",
"description": "문자로 그린 3D 도넛입니다. 드래그하거나 화살표 키로 돌릴 수 있습니다. 클릭하거나 스페이스 키를 눌러 한 입 베어 무세요.",
"eaten": "부스러기만 남았습니다.",
"bakeAnother": "하나 더 굽기",
"close": "도넛 닫기"
}
},
"clearOnClose": {
@@ -3713,7 +3753,6 @@
"hint": "프로필을 선택한 다음 쿠키나 경로를 변경하세요.",
"changed": "{{name}} 프로필이 변경되었습니다. 다른 프로필은 자체 쿠키와 경로를 그대로 유지합니다.",
"resetDone": "두 예제를 모두 초기화했습니다.",
"replay": "프로필 격리 살펴보기",
"device": "기기",
"site": "사이트"
},
+50 -11
View File
@@ -3290,21 +3290,61 @@
"noOtherTargets": "Nenhum outro perfil Wayfern selecionado"
},
"about": {
"title": "Sobre",
"version": "Versão {{version}}",
"portableBadge": "portátil",
"licenseNotice": "Navegador anti-detecção de código aberto.",
"website": "Site",
"github": "GitHub",
"licenses": "Licenças",
"searchLicenses": "Pesquisar licenças…",
"noMatchingLicenses": "Nenhuma licença encontrada.",
"openSource": "Ver o código-fonte de {{name}}",
"snack": {
"found": "Você encontrou a gaveta de doces.",
"finished": "Só migalhas. Nenhum cookie.",
"takeBite": "Dê uma mordida",
"again": "Mais um donut"
"channel": {
"stable": "Estável",
"nightly": "Nightly",
"development": "Desenvolvimento"
},
"portable": "Portátil",
"whatsNew": "Novidades desta versão",
"update": {
"checking": "Procurando atualizações…",
"development": "As atualizações estão desativadas em versões de desenvolvimento",
"current": "Atualizado",
"checkAgain": "Verificar novamente",
"failed": "Não foi possível procurar atualizações",
"available": "A versão {{version}} está disponível",
"packageManager": "A versão {{version}} está disponível no seu gerenciador de pacotes",
"update": "Atualizar",
"download": "Baixar",
"downloading": "Baixando a versão {{version}}…",
"downloadingPercent": "Baixando a versão {{version}}… {{percent}}%",
"ready": "A versão {{version}} está pronta para instalar",
"restart": "Reiniciar para atualizar"
},
"diagnostics": {
"title": "Diagnóstico",
"version": "Versão",
"channel": "Canal",
"system": "Sistema",
"yes": "Sim",
"no": "Não",
"notInstalled": "Não instalado",
"copy": "Copiar detalhes",
"copyFailed": "Não foi possível copiar os detalhes",
"openLogs": "Abrir pasta de logs",
"openLogsFailed": "Não foi possível abrir a pasta de logs"
},
"help": {
"title": "Ajuda",
"reportBug": "Relatar um erro"
},
"footer": "Software livre e de código aberto.",
"sourceCode": "Código-fonte",
"lab": {
"hint": "Arraste para girar. Clique para dar uma mordida.",
"status_one": "{{count}} mordida · resta {{left}}%",
"status_other": "{{count}} mordidas · resta {{left}}%",
"label": "Donut, resta {{left}}%",
"description": "Um donut 3D desenhado com caracteres de texto. Arraste-o ou use as setas para girá-lo. Clique nele ou pressione Espaço para dar uma mordida.",
"eaten": "Só sobraram migalhas.",
"bakeAnother": "Assar outro",
"close": "Fechar o donut"
}
},
"clearOnClose": {
@@ -3754,7 +3794,6 @@
"hint": "Selecione um perfil e altere seu cookie ou rota.",
"changed": "{{name}} foi alterado. O outro perfil manteve seu próprio cookie e rota.",
"resetDone": "Os dois exemplos foram redefinidos.",
"replay": "Explorar o isolamento de perfis",
"device": "Dispositivo",
"site": "Site"
},
+52 -11
View File
@@ -3310,21 +3310,63 @@
"noOtherTargets": "Других выбранных Wayfern профилей нет"
},
"about": {
"title": "О приложении",
"version": "Версия {{version}}",
"portableBadge": "портативная",
"licenseNotice": "Антидетект-браузер с открытым исходным кодом.",
"website": "Веб-сайт",
"github": "GitHub",
"licenses": "Лицензии",
"searchLicenses": "Поиск лицензий…",
"noMatchingLicenses": "Совпадающих лицензий нет.",
"openSource": "Открыть исходный код {{name}}",
"snack": {
"found": "Вы нашли ящик со сладостями.",
"finished": "Только крошки. Никаких куки.",
"takeBite": "Откусить",
"again": "Ещё один пончик"
"channel": {
"stable": "Стабильная",
"nightly": "Nightly",
"development": "Разработка"
},
"portable": "Портативная",
"whatsNew": "Что нового в этой версии",
"update": {
"checking": "Проверка обновлений…",
"development": "Обновления отключены в сборках для разработки",
"current": "Установлена последняя версия",
"checkAgain": "Проверить снова",
"failed": "Не удалось проверить обновления",
"available": "Доступна версия {{version}}",
"packageManager": "Версия {{version}} доступна в вашем менеджере пакетов",
"update": "Обновить",
"download": "Скачать",
"downloading": "Загрузка версии {{version}}…",
"downloadingPercent": "Загрузка версии {{version}}… {{percent}}%",
"ready": "Версия {{version}} готова к установке",
"restart": "Перезапустить и обновить"
},
"diagnostics": {
"title": "Диагностика",
"version": "Версия",
"channel": "Канал",
"system": "Система",
"yes": "Да",
"no": "Нет",
"notInstalled": "Не установлен",
"copy": "Копировать сведения",
"copyFailed": "Не удалось скопировать сведения",
"openLogs": "Открыть папку журналов",
"openLogsFailed": "Не удалось открыть папку журналов"
},
"help": {
"title": "Помощь",
"reportBug": "Сообщить об ошибке"
},
"footer": "Свободное программное обеспечение с открытым исходным кодом.",
"sourceCode": "Исходный код",
"lab": {
"hint": "Перетащите, чтобы повернуть. Нажмите, чтобы откусить.",
"status_one": "{{count}} укус · осталось {{left}}%",
"status_other": "{{count}} укусов · осталось {{left}}%",
"label": "Пончик, осталось {{left}}%",
"description": "Трёхмерный пончик из текстовых символов. Перетащите его или используйте стрелки, чтобы повернуть. Нажмите на него или пробел, чтобы откусить.",
"eaten": "Остались одни крошки.",
"bakeAnother": "Испечь ещё один",
"close": "Закрыть пончик",
"status_few": "{{count}} укуса · осталось {{left}}%",
"status_many": "{{count}} укусов · осталось {{left}}%"
}
},
"clearOnClose": {
@@ -3795,7 +3837,6 @@
"hint": "Выберите профиль, затем измените его cookie или маршрут.",
"changed": "{{name}} изменён. Другой профиль сохранил собственные cookie и маршрут.",
"resetDone": "Оба примера сброшены.",
"replay": "Изучить изоляцию профилей",
"device": "Устройство",
"site": "Сайт"
},
+50 -11
View File
@@ -3270,21 +3270,61 @@
"noOtherTargets": "Başka Wayfern profili seçilmedi"
},
"about": {
"title": "Hakkında",
"version": "Sürüm {{version}}",
"portableBadge": "taşınabilir",
"licenseNotice": "Açık kaynaklı anti-detect tarayıcı.",
"website": "Web sitesi",
"github": "GitHub",
"licenses": "Lisanslar",
"searchLicenses": "Lisanslarda ara…",
"noMatchingLicenses": "Eşleşen lisans yok.",
"openSource": "{{name}} kaynak kodunu görüntüle",
"snack": {
"found": "Atıştırmalık çekmecesini buldunuz.",
"finished": "Sadece kırıntılar. Çerez yok.",
"takeBite": "Bir ısırık alın",
"again": "Bir donut daha"
"channel": {
"stable": "Kararlı",
"nightly": "Nightly",
"development": "Geliştirme"
},
"portable": "Taşınabilir",
"whatsNew": "Bu sürümdeki yenilikler",
"update": {
"checking": "Güncellemeler denetleniyor…",
"development": "Geliştirme sürümlerinde güncellemeler kapalıdır",
"current": "Güncel",
"checkAgain": "Yeniden denetle",
"failed": "Güncellemeler denetlenemedi",
"available": "{{version}} sürümü kullanılabilir",
"packageManager": "{{version}} sürümü paket yöneticinizde kullanılabilir",
"update": "Güncelle",
"download": "İndir",
"downloading": "{{version}} sürümü indiriliyor…",
"downloadingPercent": "{{version}} sürümü indiriliyor… %{{percent}}",
"ready": "{{version}} sürümü yüklenmeye hazır",
"restart": "Güncellemek için yeniden başlat"
},
"diagnostics": {
"title": "Tanılama",
"version": "Sürüm",
"channel": "Kanal",
"system": "Sistem",
"yes": "Evet",
"no": "Hayır",
"notInstalled": "Yüklü değil",
"copy": "Ayrıntıları kopyala",
"copyFailed": "Ayrıntılar kopyalanamadı",
"openLogs": "Günlük klasörünü aç",
"openLogsFailed": "Günlük klasörü açılamadı"
},
"help": {
"title": "Yardım",
"reportBug": "Hata bildir"
},
"footer": "Özgür ve açık kaynaklı yazılım.",
"sourceCode": "Kaynak kodu",
"lab": {
"hint": "Döndürmek için sürükleyin. Isırmak için tıklayın.",
"status_one": "{{count}} ısırık · %{{left}} kaldı",
"status_other": "{{count}} ısırık · %{{left}} kaldı",
"label": "Donut, %{{left}} kaldı",
"description": "Metin karakterleriyle çizilmiş 3B bir donut. Döndürmek için sürükleyin veya ok tuşlarını kullanın. Isırmak için tıklayın veya Boşluk tuşuna basın.",
"eaten": "Geriye yalnızca kırıntılar kaldı.",
"bakeAnother": "Bir tane daha pişir",
"close": "Donutu kapat"
}
},
"clearOnClose": {
@@ -3713,7 +3753,6 @@
"hint": "Bir profil seçin, ardından çerezini veya rotasını değiştirin.",
"changed": "{{name}} değişti. Diğer profil kendi çerezini ve rotasını korudu.",
"resetDone": "Her iki örnek sıfırlandı.",
"replay": "Profil yalıtımını keşfet",
"device": "Cihaz",
"site": "Site"
},
+50 -11
View File
@@ -3270,21 +3270,61 @@
"noOtherTargets": "Chưa chọn profile Wayfern nào khác"
},
"about": {
"title": "Giới thiệu",
"version": "Phiên bản {{version}}",
"portableBadge": "bản portable",
"licenseNotice": "Trình duyệt chống phát hiện mã nguồn mở.",
"website": "Trang web",
"github": "GitHub",
"licenses": "Giấy phép",
"searchLicenses": "Tìm kiếm giấy phép…",
"noMatchingLicenses": "Không có giấy phép phù hợp.",
"openSource": "Xem mã nguồn của {{name}}",
"snack": {
"found": "Bạn đã tìm thấy ngăn đồ ăn vặt.",
"finished": "Chỉ còn vụn bánh. Không có cookie.",
"takeBite": "Cắn một miếng",
"again": "Thêm một chiếc donut"
"channel": {
"stable": "Ổn định",
"nightly": "Nightly",
"development": "Phát triển"
},
"portable": "Di động",
"whatsNew": "Có gì mới trong phiên bản này",
"update": {
"checking": "Đang kiểm tra bản cập nhật…",
"development": "Bản cập nhật bị tắt với bản dựng phát triển",
"current": "Đã cập nhật",
"checkAgain": "Kiểm tra lại",
"failed": "Không thể kiểm tra bản cập nhật",
"available": "Đã có phiên bản {{version}}",
"packageManager": "Phiên bản {{version}} có trong trình quản lý gói của bạn",
"update": "Cập nhật",
"download": "Tải xuống",
"downloading": "Đang tải phiên bản {{version}}…",
"downloadingPercent": "Đang tải phiên bản {{version}}… {{percent}}%",
"ready": "Phiên bản {{version}} đã sẵn sàng để cài đặt",
"restart": "Khởi động lại để cập nhật"
},
"diagnostics": {
"title": "Chẩn đoán",
"version": "Phiên bản",
"channel": "Kênh",
"system": "Hệ thống",
"yes": "Có",
"no": "Không",
"notInstalled": "Chưa cài đặt",
"copy": "Sao chép chi tiết",
"copyFailed": "Không thể sao chép chi tiết",
"openLogs": "Mở thư mục nhật ký",
"openLogsFailed": "Không thể mở thư mục nhật ký"
},
"help": {
"title": "Trợ giúp",
"reportBug": "Báo lỗi"
},
"footer": "Phần mềm tự do và mã nguồn mở.",
"sourceCode": "Mã nguồn",
"lab": {
"hint": "Kéo để xoay. Nhấp để cắn một miếng.",
"status_one": "{{count}} miếng · còn {{left}}%",
"status_other": "{{count}} miếng · còn {{left}}%",
"label": "Bánh donut, còn {{left}}%",
"description": "Một chiếc bánh donut 3D vẽ bằng ký tự văn bản. Kéo hoặc dùng phím mũi tên để xoay. Nhấp vào hoặc nhấn phím Cách để cắn một miếng.",
"eaten": "Chỉ còn lại vụn bánh.",
"bakeAnother": "Nướng cái khác",
"close": "Đóng bánh donut"
}
},
"clearOnClose": {
@@ -3713,7 +3753,6 @@
"hint": "Chọn một hồ sơ rồi thay đổi cookie hoặc tuyến của hồ sơ đó.",
"changed": "{{name}} đã thay đổi. Hồ sơ còn lại vẫn giữ cookie và tuyến riêng.",
"resetDone": "Đã đặt lại cả hai ví dụ.",
"replay": "Khám phá tính tách biệt hồ sơ",
"device": "Thiết bị",
"site": "Trang web"
},
+50 -11
View File
@@ -3270,21 +3270,61 @@
"noOtherTargets": "未选择其他 Wayfern 配置文件"
},
"about": {
"title": "关于",
"version": "版本 {{version}}",
"portableBadge": "便携版",
"licenseNotice": "开源反检测浏览器。",
"website": "官网",
"github": "GitHub",
"licenses": "许可证",
"searchLicenses": "搜索许可证…",
"noMatchingLicenses": "没有匹配的许可证。",
"openSource": "查看 {{name}} 源代码",
"snack": {
"found": "你发现了零食抽屉。",
"finished": "只有碎屑,没有 Cookie。",
"takeBite": "咬一口",
"again": "再来一个甜甜圈"
"channel": {
"stable": "稳定版",
"nightly": "Nightly",
"development": "开发版"
},
"portable": "便携版",
"whatsNew": "此版本的新功能",
"update": {
"checking": "正在检查更新…",
"development": "开发版已关闭更新",
"current": "已是最新版本",
"checkAgain": "再次检查",
"failed": "无法检查更新",
"available": "版本 {{version}} 可用",
"packageManager": "版本 {{version}} 可通过包管理器获取",
"update": "更新",
"download": "下载",
"downloading": "正在下载版本 {{version}}…",
"downloadingPercent": "正在下载版本 {{version}}… {{percent}}%",
"ready": "版本 {{version}} 已可安装",
"restart": "重启以更新"
},
"diagnostics": {
"title": "诊断",
"version": "版本",
"channel": "渠道",
"system": "系统",
"yes": "是",
"no": "否",
"notInstalled": "未安装",
"copy": "复制详细信息",
"copyFailed": "无法复制详细信息",
"openLogs": "打开日志文件夹",
"openLogsFailed": "无法打开日志文件夹"
},
"help": {
"title": "帮助",
"reportBug": "报告问题"
},
"footer": "免费的开源软件。",
"sourceCode": "源代码",
"lab": {
"hint": "拖动可旋转,点击可咬一口。",
"status_one": "{{count}} 口 · 剩余 {{left}}%",
"status_other": "{{count}} 口 · 剩余 {{left}}%",
"label": "甜甜圈,剩余 {{left}}%",
"description": "一个用文本字符绘制的 3D 甜甜圈。拖动或使用方向键旋转,点击或按空格键咬一口。",
"eaten": "只剩下碎屑了。",
"bakeAnother": "再烤一个",
"close": "关闭甜甜圈"
}
},
"clearOnClose": {
@@ -3713,7 +3753,6 @@
"hint": "选择一个配置文件,然后更改其 Cookie 或路由。",
"changed": "{{name}} 已更改。另一个配置文件保留了自己的 Cookie 和路由。",
"resetDone": "已重置两个示例。",
"replay": "探索配置文件隔离",
"device": "设备",
"site": "网站"
},
+218
View File
@@ -0,0 +1,218 @@
import assert from "node:assert/strict";
import test from "node:test";
import {
addBite,
bakeDonut,
CRUMB_RAMP,
castRay,
DOUGH_RAMP,
donutDistance,
findBiteTarget,
GLAZE_RAMP,
identity,
isEaten,
KIND_DOUGH,
KIND_EMPTY,
KIND_GLAZE,
KIND_SPRINKLE,
layoutGrid,
MAX_BITES,
materialAt,
normalAt,
rampFor,
remainingShare,
renderFrame,
START_ORIENTATION,
seededRandom,
shade,
spin,
sprinkleGlyph,
surfaceAt,
turn,
} from "./donut-lab.ts";
const VIEW = { width: 600, height: 340 };
function unit(v) {
const length = Math.hypot(...v);
return v.map((x) => x / length);
}
test("a fresh donut is whole and bakes the same way every time", () => {
const a = bakeDonut();
const b = bakeDonut();
assert.equal(remainingShare(a), 1);
assert.ok(a.samples.length / 3 > 1000, "enough points to weigh it");
assert.ok(a.sprinkles.length > 10);
assert.deepEqual(a.sprinkles, b.sprinkles);
assert.equal(isEaten(a), false);
});
test("a ray through the hole misses, and a ray onto the ring hits the glaze", () => {
const donut = bakeDonut();
assert.equal(castRay(donut, [0, 3, 0], [0, -1, 0]), null);
assert.equal(castRay(donut, [0, 0, 3], [0, 0, -1])?.point[2] > 1, true);
assert.equal(castRay(donut, [3, 3, 3], unit([1, 1, 1])), null);
const top = castRay(donut, [1, 3, 0], [0, -1, 0]);
assert.ok(top, "the top of the ring");
assert.ok(Math.abs(donutDistance(donut, ...top.point)) < 0.05);
assert.equal(materialAt(donut, top.point).material !== "dough", true);
const bottom = castRay(donut, [1, -3, 0], [0, 1, 0]);
assert.ok(bottom);
assert.equal(materialAt(donut, bottom.point).material, "dough");
// The view opens on the hole: the middle of the canvas shows no donut.
assert.equal(
surfaceAt(donut, START_ORIENTATION, VIEW, VIEW.width / 2, VIEW.height / 2),
null,
);
});
test("bites carve the field and reduce the mass", () => {
const donut = bakeDonut();
const hit = castRay(donut, [1, 3, 0], [0, -1, 0]);
const normal = normalAt(donut, hit.point);
assert.ok(normal[1] > 0.5, "the top faces up");
addBite(donut, hit.point, normal);
const afterOne = remainingShare(donut);
assert.ok(afterOne < 1 && afterOne > 0.8, `one bite leaves ${afterOne}`);
assert.ok(donutDistance(donut, ...hit.point) > 0, "the bitten point is gone");
// The wall of the bite shows the inside.
const wall = castRay(donut, [1, 3, 0], [0, -1, 0]);
assert.ok(wall, "the ray now lands deeper, inside the bite");
assert.ok(wall.point[1] < hit.point[1]);
assert.equal(materialAt(donut, wall.point).material, "crumb");
const other = castRay(donut, [-1, 3, 0], [0, -1, 0]);
addBite(donut, other.point, normalAt(donut, other.point));
assert.ok(remainingShare(donut) < afterOne);
});
test("a growing bite carves less and does not count until it is whole", () => {
const donut = bakeDonut();
const hit = castRay(donut, [0, 0, 3], [0, 0, -1]);
const bite = addBite(donut, hit.point, normalAt(donut, hit.point), 0);
assert.ok(donutDistance(donut, ...hit.point) < 0.02);
bite.grow = 1;
assert.ok(donutDistance(donut, ...hit.point) > 0.3);
});
test("the donut is eaten after enough bites, and never takes more than the cap", () => {
const donut = bakeDonut();
const random = seededRandom(11);
let m = START_ORIENTATION;
for (let tries = 0; tries < 400 && !isEaten(donut); tries++) {
m = turn(m, (random() - 0.5) * 2, (random() - 0.5) * 4);
const target = findBiteTarget(
donut,
m,
VIEW,
random() * VIEW.width,
random() * VIEW.height,
40,
);
if (target) addBite(donut, target.point, target.normal);
}
assert.equal(isEaten(donut), true);
assert.ok(donut.bites.length <= MAX_BITES);
assert.ok(donut.bites.length >= 6, "a donut is more than a few bites");
assert.ok(remainingShare(donut) < 0.5);
const capped = bakeDonut();
for (let i = 0; i < MAX_BITES; i++) {
capped.bites.push({ center: [9, 9, 9], radius: 0.1, grow: 1 });
}
assert.equal(remainingShare(capped), 1);
assert.equal(isEaten(capped), true, "the cap ends a donut that will not go");
});
test("a press in the hole bites the nearest rim, and a press far away bites nothing", () => {
const donut = bakeDonut();
const cx = VIEW.width / 2;
const cy = VIEW.height / 2;
assert.equal(findBiteTarget(donut, START_ORIENTATION, VIEW, cx, cy, 0), null);
const rim = findBiteTarget(donut, START_ORIENTATION, VIEW, cx, cy, 120);
assert.ok(rim);
assert.ok(Math.hypot(rim.x - cx, rim.y - cy) <= 120);
assert.equal(findBiteTarget(donut, START_ORIENTATION, VIEW, 2, 2, 20), null);
});
test("shading stays in the ramp of the material and never blanks a hit", () => {
const random = seededRandom(5);
assert.equal(rampFor("dough"), DOUGH_RAMP);
assert.equal(rampFor("glaze"), GLAZE_RAMP);
assert.equal(rampFor("crumb"), CRUMB_RAMP);
for (let i = 0; i < 2000; i++) {
const normal = unit([random() - 0.5, random() - 0.5, random() - 0.5]);
const toEye = unit([random() - 0.5, random() - 0.5, random() + 0.2]);
for (const material of ["dough", "glaze", "crumb"]) {
for (const ink of [false, true]) {
const char = shade(normal, toEye, material, ink, random() * 2 - 1);
assert.ok(rampFor(material).includes(char), `${material} ${char}`);
assert.notEqual(char, " ");
}
}
}
// Light reads the right way round on both backgrounds.
const lit = unit([-0.45, 0.7, 0.55]);
const away = unit([0.45, -0.7, 0.55]);
const eye = [0, 0, 1];
const level = (char) => DOUGH_RAMP.indexOf(char);
assert.ok(
level(shade(lit, eye, "dough", false)) >
level(shade(away, eye, "dough", false)),
"on dark, light is dense",
);
assert.ok(
level(shade(lit, eye, "dough", true)) <
level(shade(away, eye, "dough", true)),
"on light, shadow is dense",
);
});
test("sprinkles draw along their length", () => {
assert.equal(sprinkleGlyph([1, 0, 0]), "-");
assert.equal(sprinkleGlyph([0, 1, 0]), "|");
assert.equal(sprinkleGlyph(unit([1, 1, 0])), "/");
assert.equal(sprinkleGlyph(unit([-1, 1, 0])), "\\");
assert.equal(sprinkleGlyph([0, 0, 1]), "o");
});
test("turns and spins stay rotations", () => {
let m = identity();
for (let i = 0; i < 500; i++) {
m = spin(turn(m, 0.07, -0.05), 0.03);
}
const rows = [m.slice(0, 3), m.slice(3, 6), m.slice(6, 9)];
for (const [i, row] of rows.entries()) {
assert.ok(Math.abs(Math.hypot(...row) - 1) < 1e-9);
for (const other of rows.slice(i + 1)) {
const dot = row[0] * other[0] + row[1] * other[1] + row[2] * other[2];
assert.ok(Math.abs(dot) < 1e-9);
}
}
});
test("a frame shows glaze, dough and sprinkles around an empty hole", () => {
const donut = bakeDonut();
const { grid, fontSize } = layoutGrid(VIEW.width, VIEW.height);
assert.equal(grid.cols, 64);
assert.ok(grid.rows >= 20 && grid.rows <= 40);
assert.ok(fontSize > 8);
const frame = renderFrame(donut, START_ORIENTATION, grid, false);
const count = (predicate) => frame.kinds.filter(predicate).length;
assert.ok(count((kind) => kind === KIND_GLAZE) > 100);
assert.ok(count((kind) => kind === KIND_DOUGH) > 10);
assert.ok(count((kind) => kind >= KIND_SPRINKLE) > 10);
const middle =
Math.floor(grid.rows / 2) * grid.cols + Math.floor(grid.cols / 2);
assert.equal(frame.kinds[middle], KIND_EMPTY);
assert.equal(frame.chars[middle], " ");
for (const [i, char] of frame.chars.entries()) {
assert.equal(char === " ", frame.kinds[i] === KIND_EMPTY);
}
});
+748
View File
@@ -0,0 +1,748 @@
/**
* The donut of the About easter egg: a signed distance field, a ray marcher,
* bites carved out of the field, a mass estimate, and text shading. Pure math,
* so it runs in node tests; the canvas lives in `donut-lab.tsx`.
*
* Object space: the ring lies in the XZ plane around the Y axis, and +Y is the
* glazed top. View space: +X right, +Y up, the camera on +Z looking at the
* origin. A row-major rotation matrix turns object space into view space.
*/
export type Vec3 = [number, number, number];
export type Mat3 = [
number,
number,
number,
number,
number,
number,
number,
number,
number,
];
export const MAJOR_RADIUS = 1;
export const MINOR_RADIUS = 0.45;
export const GLAZE_THICKNESS = 0.06;
/** Every point of the donut, sprinkles included, lies inside this sphere. */
export const BOUNDS_RADIUS = 1.62;
export const MAX_STEPS = 28;
const SLAB_BOTTOM = -MINOR_RADIUS - 0.02;
const SLAB_TOP = MINOR_RADIUS + GLAZE_THICKNESS + 0.08;
const HIT_DISTANCE = 0.01;
const GRAZE_DISTANCE = 0.05;
export const BITE_RADIUS = 0.8;
/** How far outside the surface a bite's sphere sits: a scoop, not a crater. */
const BITE_OFFSET = 0.1;
const BITE_SMOOTHING = 0.06;
/** The donut is gone when this share of it is eaten, or after this many bites. */
export const EATEN_SHARE = 0.85;
export const MAX_BITES = 30;
const CAMERA_DISTANCE = 5;
/** Half the side of the view, in object units, at the smaller canvas side. */
const FRAME_RADIUS = 1.72;
export const DOUGH_RAMP = " .:-=+*#%@";
export const GLAZE_RAMP = " .,~;oO08@";
export const CRUMB_RAMP = " .,:;io0&@";
export const SPRINKLE_COLORS = 5;
export type Material = "dough" | "glaze" | "crumb" | "sprinkle";
export const KIND_EMPTY = 0;
export const KIND_DOUGH = 1;
export const KIND_GLAZE = 2;
export const KIND_CRUMB = 3;
/** Sprinkles are KIND_SPRINKLE + their color index (0 to 4). */
export const KIND_SPRINKLE = 4;
export interface Sprinkle {
a: Vec3;
/** b - a, the capsule's segment. */
ab: Vec3;
abLengthSquared: number;
axis: Vec3;
color: number;
}
export interface Bite {
center: Vec3;
radius: number;
/** 0 to 1 while the bite is drawn in; the mass estimate ignores it. */
grow: number;
}
export interface Donut {
sprinkles: Sprinkle[];
/** Sprinkle indices by angle around the ring, so a lookup checks a few. */
buckets: number[][];
bites: Bite[];
/** Points inside the fresh donut, xyz packed, for the mass estimate. */
samples: Float64Array;
}
const SPRINKLE_COUNT = 32;
const SPRINKLE_HALF_LENGTH = 0.1;
const SPRINKLE_RADIUS = 0.035;
/** A hit this close to a sprinkle takes its color: sprinkles stay visible at text resolution. */
const SPRINKLE_HALO = 0.035;
const BUCKETS = 24;
const MASS_SAMPLES = 6000;
const TAU = Math.PI * 2;
/** A small seeded generator, so a donut always bakes the same way. */
export function seededRandom(seed: number) {
let state = seed >>> 0;
return () => {
state = (state + 0x6d2b79f5) >>> 0;
let t = state;
t = Math.imul(t ^ (t >>> 15), t | 1);
t ^= t + Math.imul(t ^ (t >>> 7), t | 61);
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
/** The height on the tube above which the glaze covers it: wavy, as if poured. */
export function glazeEdge(theta: number) {
return (
0.18 + 0.06 * Math.sin(5 * theta + 0.6) + 0.03 * Math.sin(11 * theta + 2.1)
);
}
function bucketOf(theta: number) {
const turn = (((theta % TAU) + TAU) % TAU) / TAU;
return Math.min(BUCKETS - 1, Math.floor(turn * BUCKETS));
}
function bakeSprinkles(random: () => number) {
const sprinkles: Sprinkle[] = [];
const buckets: number[][] = Array.from({ length: BUCKETS }, () => []);
const surface = MINOR_RADIUS + GLAZE_THICKNESS - 0.01;
let guard = 0;
while (sprinkles.length < SPRINKLE_COUNT && guard++ < 10_000) {
const theta = random() * TAU;
const phi = Math.PI * (0.1 + random() * 0.8);
const y = surface * Math.sin(phi);
if (y < glazeEdge(theta) + 0.08) continue;
const rho = MAJOR_RADIUS + surface * Math.cos(phi);
const cos = Math.cos(theta);
const sin = Math.sin(theta);
const center: Vec3 = [rho * cos, y, rho * sin];
// Two tangents of the surface: along the ring, and around the tube.
const along: Vec3 = [-sin, 0, cos];
const around: Vec3 = [
-Math.sin(phi) * cos,
Math.cos(phi),
-Math.sin(phi) * sin,
];
const psi = random() * Math.PI;
const axis: Vec3 = [
Math.cos(psi) * along[0] + Math.sin(psi) * around[0],
Math.cos(psi) * along[1] + Math.sin(psi) * around[1],
Math.cos(psi) * along[2] + Math.sin(psi) * around[2],
];
const a: Vec3 = [
center[0] - axis[0] * SPRINKLE_HALF_LENGTH,
center[1] - axis[1] * SPRINKLE_HALF_LENGTH,
center[2] - axis[2] * SPRINKLE_HALF_LENGTH,
];
const ab: Vec3 = [
axis[0] * 2 * SPRINKLE_HALF_LENGTH,
axis[1] * 2 * SPRINKLE_HALF_LENGTH,
axis[2] * 2 * SPRINKLE_HALF_LENGTH,
];
const index = sprinkles.length;
sprinkles.push({
a,
ab,
abLengthSquared: (2 * SPRINKLE_HALF_LENGTH) ** 2,
axis,
color: index % SPRINKLE_COLORS,
});
// Every bucket the sprinkle (and the band where it matters) reaches.
const reach =
(SPRINKLE_HALF_LENGTH + SPRINKLE_RADIUS + 0.3) / Math.max(rho, 0.3);
const steps = Math.ceil((reach / TAU) * BUCKETS) + 1;
const home = bucketOf(theta);
for (let step = -steps; step <= steps; step++) {
const bucket = (home + step + BUCKETS) % BUCKETS;
if (!buckets[bucket].includes(index)) buckets[bucket].push(index);
}
}
return { sprinkles, buckets };
}
/** Distance to the dough and to the glaze, ignoring bites and sprinkles. */
function bodyParts(x: number, y: number, z: number) {
const rho = Math.sqrt(x * x + z * z);
const q = rho - MAJOR_RADIUS;
const tube = Math.sqrt(q * q + y * y);
const dough = tube - MINOR_RADIUS;
const glaze = Math.max(
tube - MINOR_RADIUS - GLAZE_THICKNESS,
glazeEdge(Math.atan2(z, x)) - y,
);
return { dough, glaze };
}
function sprinkleDistance(
donut: Donut,
x: number,
y: number,
z: number,
theta: number,
) {
let best = Number.POSITIVE_INFINITY;
let index = -1;
for (const i of donut.buckets[bucketOf(theta)]) {
const s = donut.sprinkles[i];
const px = x - s.a[0];
const py = y - s.a[1];
const pz = z - s.a[2];
let h = (px * s.ab[0] + py * s.ab[1] + pz * s.ab[2]) / s.abLengthSquared;
h = h < 0 ? 0 : h > 1 ? 1 : h;
const dx = px - s.ab[0] * h;
const dy = py - s.ab[1] * h;
const dz = pz - s.ab[2] * h;
const d = Math.sqrt(dx * dx + dy * dy + dz * dz) - SPRINKLE_RADIUS;
if (d < best) {
best = d;
index = i;
}
}
return { distance: best, index };
}
function baseDistance(donut: Donut, x: number, y: number, z: number) {
const rho = Math.sqrt(x * x + z * z);
const q = rho - MAJOR_RADIUS;
const tube = Math.sqrt(q * q + y * y);
const dough = tube - MINOR_RADIUS;
const theta = Math.atan2(z, x);
const glaze = Math.max(
tube - MINOR_RADIUS - GLAZE_THICKNESS,
glazeEdge(theta) - y,
);
let d = dough < glaze ? dough : glaze;
if (glaze < 0.25) {
const sprinkle = sprinkleDistance(donut, x, y, z, theta).distance;
if (sprinkle < d) d = sprinkle;
}
return d;
}
/** `d` with a sphere carved out of it, the cut edge slightly rounded. */
export function smoothSubtract(sphere: number, d: number, k: number) {
const h = Math.min(1, Math.max(0, 0.5 - (0.5 * (d + sphere)) / k));
return d + (-sphere - d) * h + k * h * (1 - h);
}
/** The signed distance from a point to the donut as it is now, bites included. */
export function donutDistance(donut: Donut, x: number, y: number, z: number) {
let d = baseDistance(donut, x, y, z);
for (const bite of donut.bites) {
const radius = bite.radius * bite.grow;
if (radius <= 0) continue;
const bx = x - bite.center[0];
const by = y - bite.center[1];
const bz = z - bite.center[2];
const sphere = Math.sqrt(bx * bx + by * by + bz * bz) - radius;
// Past the smoothing band a bite leaves the distance as it is.
if (sphere + d >= BITE_SMOOTHING) continue;
d = smoothSubtract(sphere, d, BITE_SMOOTHING);
}
return d;
}
function bakeSamples(random: () => number) {
const inside: number[] = [];
for (let i = 0; i < MASS_SAMPLES; i++) {
const x = (random() * 2 - 1) * 1.52;
const y = -0.46 + random() * 0.98;
const z = (random() * 2 - 1) * 1.52;
const { dough, glaze } = bodyParts(x, y, z);
if (Math.min(dough, glaze) <= 0) inside.push(x, y, z);
}
return Float64Array.from(inside);
}
/** A fresh donut. The same seed always bakes the same sprinkles. */
export function bakeDonut(seed = 7): Donut {
const random = seededRandom(seed);
const { sprinkles, buckets } = bakeSprinkles(random);
return { sprinkles, buckets, bites: [], samples: bakeSamples(random) };
}
/** The share of the fresh donut still there, 0 to 1, estimated from sample points. */
export function remainingShare(donut: Donut) {
const { samples, bites } = donut;
const total = samples.length / 3;
if (total === 0) return 0;
let left = 0;
for (let i = 0; i < samples.length; i += 3) {
let eaten = false;
for (const bite of bites) {
const dx = samples[i] - bite.center[0];
const dy = samples[i + 1] - bite.center[1];
const dz = samples[i + 2] - bite.center[2];
if (dx * dx + dy * dy + dz * dz < bite.radius * bite.radius) {
eaten = true;
break;
}
}
if (!eaten) left++;
}
return left / total;
}
export function isEaten(donut: Donut, remaining = remainingShare(donut)) {
return donut.bites.length >= MAX_BITES || remaining <= 1 - EATEN_SHARE;
}
/**
* Carves a bite at a surface point. The sphere sits a little outside the
* surface, along the normal, so the bite is a scoop as wide as a mouth.
*/
export function addBite(
donut: Donut,
point: Vec3,
normal: Vec3,
grow = 1,
): Bite {
const bite: Bite = {
center: [
point[0] + normal[0] * BITE_OFFSET,
point[1] + normal[1] * BITE_OFFSET,
point[2] + normal[2] * BITE_OFFSET,
],
radius: BITE_RADIUS,
grow,
};
donut.bites.push(bite);
return bite;
}
export interface RayHit {
t: number;
point: Vec3;
}
/** Marches a ray (object space, `dir` of length 1) to the donut, or returns null. */
export function castRay(
donut: Donut,
origin: Vec3,
dir: Vec3,
maxSteps = MAX_STEPS,
): RayHit | null {
const [ox, oy, oz] = origin;
const [dx, dy, dz] = dir;
// Rays that miss the bounding sphere cannot hit the donut.
const b = ox * dx + oy * dy + oz * dz;
const c = ox * ox + oy * oy + oz * oz - BOUNDS_RADIUS * BOUNDS_RADIUS;
const disc = b * b - c;
if (disc <= 0) return null;
const root = Math.sqrt(disc);
let end = -b + root;
let t = Math.max(0, -b - root);
// The donut is also a flat slab: march only the stretch inside it.
if (Math.abs(dy) > 1e-6) {
const t0 = (SLAB_BOTTOM - oy) / dy;
const t1 = (SLAB_TOP - oy) / dy;
t = Math.max(t, Math.min(t0, t1));
end = Math.min(end, Math.max(t0, t1));
} else if (oy < SLAB_BOTTOM || oy > SLAB_TOP) {
return null;
}
if (t > end) return null;
let d = Number.POSITIVE_INFINITY;
for (let step = 0; step < maxSteps; step++) {
d = donutDistance(donut, ox + dx * t, oy + dy * t, oz + dz * t);
if (d < HIT_DISTANCE) break;
t += d * 0.9;
if (t > end) return null;
}
// A ray that ran out of steps grazing the surface still counts: at text
// resolution a cell is wider than the gap.
if (d > GRAZE_DISTANCE) return null;
return { t, point: [ox + dx * t, oy + dy * t, oz + dz * t] };
}
/** The outward surface normal at a point, from the field's gradient. */
export function normalAt(donut: Donut, p: Vec3): Vec3 {
const e = 0.004;
const [x, y, z] = p;
const a = donutDistance(donut, x + e, y - e, z - e);
const b = donutDistance(donut, x - e, y - e, z + e);
const c = donutDistance(donut, x - e, y + e, z - e);
const d = donutDistance(donut, x + e, y + e, z + e);
const nx = a - b - c + d;
const ny = -a - b + c + d;
const nz = -a + b - c + d;
const length = Math.sqrt(nx * nx + ny * ny + nz * nz) || 1;
return [nx / length, ny / length, nz / length];
}
/** What a surface point is made of, and its sprinkle when it is one. */
export function materialAt(
donut: Donut,
p: Vec3,
): { material: Material; sprinkle: number } {
const [x, y, z] = p;
const { dough, glaze } = bodyParts(x, y, z);
const sprinkle = sprinkleDistance(donut, x, y, z, Math.atan2(z, x));
const onBite = donut.bites.some((bite) => {
const dx = x - bite.center[0];
const dy = y - bite.center[1];
const dz = z - bite.center[2];
return Math.sqrt(dx * dx + dy * dy + dz * dz) < bite.radius + 0.03;
});
if (onBite) {
// The wall of a bite: the cut shows what is inside.
if (sprinkle.distance < 0) {
return { material: "sprinkle", sprinkle: sprinkle.index };
}
if (dough < -0.012) return { material: "crumb", sprinkle: -1 };
if (glaze < 0 && dough > 0) return { material: "glaze", sprinkle: -1 };
return { material: "dough", sprinkle: -1 };
}
if (sprinkle.distance < Math.min(dough, glaze) + SPRINKLE_HALO) {
return { material: "sprinkle", sprinkle: sprinkle.index };
}
return { material: glaze < dough ? "glaze" : "dough", sprinkle: -1 };
}
export function rampFor(material: Material) {
return material === "glaze"
? GLAZE_RAMP
: material === "crumb"
? CRUMB_RAMP
: DOUGH_RAMP;
}
/** The light, in view space: from the upper left, in front. */
export const LIGHT: Vec3 = (() => {
const v: Vec3 = [-0.45, 0.7, 0.55];
const length = Math.hypot(...v);
return [v[0] / length, v[1] / length, v[2] / length];
})();
/**
* The character for a surface point, from its view-space normal and the
* direction to the eye. Lambert light, a rim term and, on the glaze, a
* highlight. On a dark background dense characters are light. On a light
* background (`ink`) they are shadow, the rim draws an outline, and the
* highlight is bare paper. A hit is never blank, so the silhouette holds.
* `grain` (-1 to 1) roughens the crumb.
*/
export function shade(
normal: Vec3,
toEye: Vec3,
material: Material,
ink: boolean,
grain = 0,
): string {
const ramp = rampFor(material);
const [nx, ny, nz] = normal;
const diffuse = Math.max(0, nx * LIGHT[0] + ny * LIGHT[1] + nz * LIGHT[2]);
const facing = Math.max(0, nx * toEye[0] + ny * toEye[1] + nz * toEye[2]);
const rim = (1 - facing) ** 2;
let highlight = 0;
if (material === "glaze") {
const hx = LIGHT[0] + toEye[0];
const hy = LIGHT[1] + toEye[1];
const hz = LIGHT[2] + toEye[2];
const length = Math.sqrt(hx * hx + hy * hy + hz * hz) || 1;
highlight = Math.max(0, (nx * hx + ny * hy + nz * hz) / length) ** 24;
}
const texture = material === "crumb" ? grain * 0.18 : 0;
const value = ink
? 0.12 + 0.7 * (1 - diffuse) + 0.35 * rim - 0.7 * highlight + texture
: 0.1 + 0.75 * diffuse + 0.35 * rim + 0.6 * highlight + texture;
const clamped = Math.min(1, Math.max(0, value));
const level = 1 + Math.round(clamped * (ramp.length - 2));
return ramp[level];
}
/** The glyph of a sprinkle, drawn along its length as it looks from the eye. */
export function sprinkleGlyph(axisView: Vec3) {
const [x, y] = axisView;
if (Math.hypot(x, y) < 0.35) return "o";
let angle = Math.atan2(y, x);
if (angle < 0) angle += Math.PI;
const eighth = Math.PI / 8;
if (angle < eighth || angle >= 7 * eighth) return "-";
if (angle < 3 * eighth) return "/";
if (angle < 5 * eighth) return "|";
return "\\";
}
export function identity(): Mat3 {
return [1, 0, 0, 0, 1, 0, 0, 0, 1];
}
export function multiply(a: Mat3, b: Mat3): Mat3 {
const out = new Array(9).fill(0) as Mat3;
for (let row = 0; row < 3; row++) {
for (let col = 0; col < 3; col++) {
out[row * 3 + col] =
a[row * 3] * b[col] +
a[row * 3 + 1] * b[3 + col] +
a[row * 3 + 2] * b[6 + col];
}
}
return out;
}
export function rotationX(angle: number): Mat3 {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [1, 0, 0, 0, c, -s, 0, s, c];
}
export function rotationY(angle: number): Mat3 {
const c = Math.cos(angle);
const s = Math.sin(angle);
return [c, 0, s, 0, 1, 0, -s, 0, c];
}
/** Keeps a matrix a rotation after many small turns. */
export function orthonormalize(m: Mat3): Mat3 {
const x: Vec3 = [m[0], m[1], m[2]];
let length = Math.hypot(...x) || 1;
x[0] /= length;
x[1] /= length;
x[2] /= length;
const yDotX = m[3] * x[0] + m[4] * x[1] + m[5] * x[2];
const y: Vec3 = [
m[3] - yDotX * x[0],
m[4] - yDotX * x[1],
m[5] - yDotX * x[2],
];
length = Math.hypot(...y) || 1;
y[0] /= length;
y[1] /= length;
y[2] /= length;
const z: Vec3 = [
x[1] * y[2] - x[2] * y[1],
x[2] * y[0] - x[0] * y[2],
x[0] * y[1] - x[1] * y[0],
];
return [...x, ...y, ...z] as Mat3;
}
/** Turns the donut about the screen's axes: a drag, or an arrow key. */
export function turn(m: Mat3, aroundX: number, aroundY: number): Mat3 {
return orthonormalize(
multiply(multiply(rotationY(aroundY), rotationX(aroundX)), m),
);
}
/** Spins the donut about its own axis, as a record turns. */
export function spin(m: Mat3, angle: number): Mat3 {
return orthonormalize(multiply(m, rotationY(angle)));
}
/** Top tilted toward the eye, so the glaze and the hole both show. */
export const START_ORIENTATION: Mat3 = turn(identity(), 0.75, 0);
export function toView(m: Mat3, v: Vec3): Vec3 {
return [
m[0] * v[0] + m[1] * v[1] + m[2] * v[2],
m[3] * v[0] + m[4] * v[1] + m[5] * v[2],
m[6] * v[0] + m[7] * v[1] + m[8] * v[2],
];
}
export function toObject(m: Mat3, v: Vec3): Vec3 {
return [
m[0] * v[0] + m[3] * v[1] + m[6] * v[2],
m[1] * v[0] + m[4] * v[1] + m[7] * v[2],
m[2] * v[0] + m[5] * v[1] + m[8] * v[2],
];
}
/** A canvas, in CSS pixels. */
export interface View {
width: number;
height: number;
}
/** The ray through a canvas point, in view space. */
export function viewRay(view: View, px: number, py: number) {
const scale =
(2 * FRAME_RADIUS) / Math.max(1, Math.min(view.width, view.height));
const x = (px - view.width / 2) * scale;
const y = (view.height / 2 - py) * scale;
const length = Math.sqrt(x * x + y * y + CAMERA_DISTANCE * CAMERA_DISTANCE);
return {
origin: [0, 0, CAMERA_DISTANCE] as Vec3,
dir: [x / length, y / length, -CAMERA_DISTANCE / length] as Vec3,
};
}
/** Where an object-space point lands on the canvas. */
export function project(view: View, m: Mat3, p: Vec3) {
const [x, y, z] = toView(m, p);
const scale =
(2 * FRAME_RADIUS) / Math.max(1, Math.min(view.width, view.height));
const perspective = CAMERA_DISTANCE / (CAMERA_DISTANCE - z);
return {
x: view.width / 2 + (x * perspective) / scale,
y: view.height / 2 - (y * perspective) / scale,
};
}
export interface SurfacePoint {
point: Vec3;
normal: Vec3;
/** In view space, to shade it. */
viewNormal: Vec3;
toEye: Vec3;
}
/** The donut's surface under a canvas point, or null where the ray misses. */
export function surfaceAt(
donut: Donut,
m: Mat3,
view: View,
px: number,
py: number,
): SurfacePoint | null {
const ray = viewRay(view, px, py);
const hit = castRay(donut, toObject(m, ray.origin), toObject(m, ray.dir));
if (!hit) return null;
const normal = normalAt(donut, hit.point);
return {
point: hit.point,
normal,
viewNormal: toView(m, normal),
toEye: [-ray.dir[0], -ray.dir[1], -ray.dir[2]],
};
}
/**
* The surface point to bite for a press at a canvas point: under it, or the
* nearest one within `reach` pixels, so a press in the hole bites its rim.
*/
export function findBiteTarget(
donut: Donut,
m: Mat3,
view: View,
px: number,
py: number,
reach: number,
step = 6,
): (SurfacePoint & { x: number; y: number }) | null {
const direct = surfaceAt(donut, m, view, px, py);
if (direct) return { ...direct, x: px, y: py };
for (let radius = step; radius <= reach; radius += step) {
const count = Math.max(8, Math.ceil((TAU * radius) / step));
for (let i = 0; i < count; i++) {
const angle = (i / count) * TAU;
const x = px + Math.cos(angle) * radius;
const y = py + Math.sin(angle) * radius;
if (x < 0 || y < 0 || x > view.width || y > view.height) continue;
const surface = surfaceAt(donut, m, view, x, y);
if (surface) return { ...surface, x, y };
}
}
return null;
}
/** The character grid laid over a canvas. */
export interface Grid extends View {
cols: number;
rows: number;
cellWidth: number;
cellHeight: number;
offsetX: number;
offsetY: number;
}
export interface Frame {
chars: string[];
kinds: Uint8Array;
}
/**
* Lays a grid of about `targetCols` columns over a canvas. `advance` is the
* width of one character per pixel of font size; rows are a little shorter
* than the font, so the grid is denser than plain text.
*/
export function layoutGrid(
width: number,
height: number,
advance = 0.6,
targetCols = 64,
) {
const cols = Math.max(24, Math.min(96, targetCols));
const cellWidth = width / cols;
const fontSize = cellWidth / advance;
const cellHeight = fontSize * 0.92;
const rows = Math.max(8, Math.floor(height / cellHeight));
const grid: Grid = {
width,
height,
cols,
rows,
cellWidth,
cellHeight,
offsetX: 0,
offsetY: (height - rows * cellHeight) / 2,
};
return { grid, fontSize };
}
function hash3(x: number, y: number, z: number) {
const n = Math.sin(x * 127.1 + y * 311.7 + z * 74.7) * 43758.5453;
return (n - Math.floor(n)) * 2 - 1;
}
/** Ray marches every cell of the grid into a frame of characters and kinds. */
export function renderFrame(
donut: Donut,
m: Mat3,
grid: Grid,
ink: boolean,
frame: Frame = {
chars: new Array(grid.cols * grid.rows).fill(" "),
kinds: new Uint8Array(grid.cols * grid.rows),
},
): Frame {
for (let row = 0; row < grid.rows; row++) {
const py = grid.offsetY + (row + 0.5) * grid.cellHeight;
for (let col = 0; col < grid.cols; col++) {
const index = row * grid.cols + col;
const px = grid.offsetX + (col + 0.5) * grid.cellWidth;
const surface = surfaceAt(donut, m, grid, px, py);
if (!surface) {
frame.chars[index] = " ";
frame.kinds[index] = KIND_EMPTY;
continue;
}
const { material, sprinkle } = materialAt(donut, surface.point);
if (material === "sprinkle") {
const { axis, color } = donut.sprinkles[sprinkle];
frame.chars[index] = sprinkleGlyph(toView(m, axis));
frame.kinds[index] = KIND_SPRINKLE + color;
continue;
}
const [x, y, z] = surface.point;
const grain = material === "crumb" ? hash3(x * 9, y * 9, z * 9) : 0;
frame.chars[index] = shade(
surface.viewNormal,
surface.toEye,
material,
ink,
grain,
);
frame.kinds[index] =
material === "glaze"
? KIND_GLAZE
: material === "crumb"
? KIND_CRUMB
: KIND_DOUGH;
}
}
return frame;
}
+12 -2
View File
@@ -27,7 +27,8 @@ export type UiSound =
| "stop"
| "synced"
| "download"
| "attention";
| "attention"
| "crunch";
export type SoundCategory = "interface" | "feedback" | "events";
@@ -50,6 +51,7 @@ export const SOUND_CATEGORY: Record<UiSound, SoundCategory> = {
synced: "events",
download: "events",
attention: "events",
crunch: "interface",
};
export const UI_SOUNDS = Object.keys(SOUND_CATEGORY) as UiSound[];
@@ -142,7 +144,8 @@ interface Phrase {
* The phrases. Each sound has its own contour, so it is recognized without
* looking: a press is one falling tick, a result goes up (success) or down
* (error) a short way, a launch climbs four notes and a stop comes back down,
* a warning repeats one unresolved note, and a copy repeats one high note.
* a warning repeats one unresolved note, a copy repeats one high note, and a
* crunch is a low thud under a burst of noise.
*/
const PHRASES: Record<UiSound, Phrase> = {
tap: {
@@ -252,6 +255,12 @@ const PHRASES: Record<UiSound, Phrase> = {
{ step: 9, at: 0.11, length: 0.26, level: 0.55 },
],
},
crunch: {
notes: [{ step: -4, at: 0, length: 0.05, level: 0.6, slide: -4 }],
touch: 1,
air: { length: 0.09, level: 0.9, frequency: 2600 },
jitter: 0.06,
},
};
interface Pack {
@@ -492,6 +501,7 @@ export const SOUND_GAP_MS: Record<UiSound, number> = {
synced: 2000,
download: 2000,
attention: 3000,
crunch: 80,
};
/** What the sound picker needs to know about the control that was used. */