mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
feat: new About and egg
This commit is contained in:
1 parent
d8e05cd250
commit
6a9b433b6c
22 files changed
+3080
-549
No files matched your search
+135
-62
@@ -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");
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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": "网站"
|
||||
},
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
@@ -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
@@ -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. */
|
||||
|
||||
Reference in new issue
Block a user