feat(i18n): localize Settings > General > Notifications

Settings > General > Notifications held all of its copy as literals, so the page
stayed English under a locale switch. Every string now resolves through t() /
<Trans> at render, and the page is on the i18nGuardFiles allowlist so it cannot
drift back.

Event titles, sound preset names and the permission button label resolve from
maps of catalog keys rather than module-scope t(), so they follow a locale
switch. The Apprise install notice is a single <Trans> message so the link stays
inside the sentence. SCREENS in the pseudo-locale oracle gains a per-screen
allow list for text the frontend must not translate (backend-owned provider
names).
This commit is contained in:
João Salavisa
2026-08-02 01:49:44 +02:00
committed by GitHub
parent de103b113f
commit e4c4efec18
13 changed files with 503 additions and 87 deletions
@@ -0,0 +1,96 @@
import { cleanup, fireEvent, render, screen } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import {
NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED,
NOTIFICATION_EVENT_SESSION_TURN_FINISHED,
} from "@/lib/notifications/events";
import type { NotificationProvider } from "@/lib/types/http";
import { NotificationEventsTable } from "./notification-events-table";
function provider(overrides: Partial<NotificationProvider> = {}): NotificationProvider {
return {
id: "provider-1",
name: "Desktop Notifications",
type: "local",
config: {},
enabled: true,
events: [],
created_at: "",
updated_at: "",
...overrides,
};
}
function renderTable(tableEvents: string[], providers = [provider()], onTestProvider = vi.fn()) {
return render(
<NotificationEventsTable
tableProviders={providers}
baselineProviders={providers}
tableEvents={tableEvents}
onToggleEvent={vi.fn()}
onTestProvider={onTestProvider}
/>,
);
}
afterEach(cleanup);
describe("NotificationEventsTable", () => {
it("renders the translated title and description for a known event type", () => {
renderTable([NOTIFICATION_EVENT_SESSION_TURN_FINISHED]);
// Once in the mobile list and once in the desktop table; both are always
// mounted and hidden by CSS, so each string appears twice.
expect(screen.getAllByText("Agent turn finished")).toHaveLength(2);
expect(screen.getAllByText("Notify after each completed agent turn.")).toHaveLength(2);
});
it("falls back to the raw event type for an event the catalog does not know", () => {
renderTable(["session.some_future_event"]);
expect(screen.getAllByText("session.some_future_event")).toHaveLength(2);
expect(screen.getAllByText("Notify when this event occurs.")).toHaveLength(2);
});
it("names each checkbox with the event title and the provider name", () => {
renderTable([NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]);
expect(
screen.getAllByRole("checkbox", {
name: "Agent needs an answer for Desktop Notifications",
}),
).toHaveLength(2);
});
it("names and wires the test control for a remote provider", async () => {
const onTestProvider = vi.fn().mockResolvedValue(undefined);
const remote = provider({ id: "apprise-1", name: "Ops channel", type: "apprise" });
renderTable([NOTIFICATION_EVENT_SESSION_TURN_FINISHED], [remote], onTestProvider);
const buttons = screen.getAllByRole("button", {
name: "Send test notification for Ops channel",
});
expect(buttons).toHaveLength(2);
// Radix Tooltip does not open from synthetic pointer events in this
// environment; focus is the reliable path (apps/web/AGENTS.md).
fireEvent.focus(buttons[0]);
expect((await screen.findByRole("tooltip")).textContent).toBe("Send test notification");
fireEvent.click(buttons[0]);
expect(onTestProvider).toHaveBeenCalledWith("apprise-1");
});
it("omits the test control for the local provider, which cannot be tested remotely", () => {
renderTable([NOTIFICATION_EVENT_SESSION_TURN_FINISHED]);
expect(screen.queryByRole("button", { name: /Send test notification/ })).toBeNull();
});
it("reports an empty provider list instead of an empty table", () => {
renderTable([NOTIFICATION_EVENT_SESSION_TURN_FINISHED], []);
expect(screen.getByText("No providers configured yet.")).toBeTruthy();
expect(screen.queryByTestId("notification-events-desktop-table")).toBeNull();
});
});
@@ -1,10 +1,16 @@
"use client";
import { IconBell } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Checkbox } from "@kandev/ui/checkbox";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
import { EVENT_LABELS } from "@/lib/notifications/events";
import {
NOTIFICATION_EVENT_OFFICE_INBOX_ITEM,
NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED,
NOTIFICATION_EVENT_SESSION_TURN_FINISHED,
NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE,
} from "@/lib/notifications/events";
import type { NotificationProvider } from "@/lib/types/http";
type Props = {
@@ -15,36 +21,76 @@ type Props = {
onTestProvider: (providerId: string) => Promise<void>;
};
function eventMeta(eventType: string) {
return (
EVENT_LABELS[eventType] ?? {
title: eventType,
description: "Notify when this event occurs.",
}
);
/**
* Catalog keys for each known event type — keys, not resolved copy. The event
* type itself is a wire sentinel and is never translated; only the row's title
* and description are. Resolving these at module scope would pin the copy to the
* boot locale (docs/i18n.md), so `useEventMeta` resolves them at render.
*
* An event type the backend introduces before this map knows it falls back to
* the raw type plus a generic description, exactly as it did before i18n.
*/
const EVENT_MESSAGE_KEYS: Record<string, { title: string; description: string }> = {
[NOTIFICATION_EVENT_SESSION_TURN_FINISHED]: {
title: "settings:notificationEventTurnFinished",
description: "settings:notificationEventTurnFinishedDescription",
},
[NOTIFICATION_EVENT_SESSION_CLARIFICATION_REQUESTED]: {
title: "settings:notificationEventClarificationRequested",
description: "settings:notificationEventClarificationRequestedDescription",
},
[NOTIFICATION_EVENT_SYSTEM_UPDATE_AVAILABLE]: {
title: "settings:notificationEventUpdateAvailable",
description: "settings:notificationEventUpdateAvailableDescription",
},
[NOTIFICATION_EVENT_OFFICE_INBOX_ITEM]: {
title: "settings:notificationEventOfficeInboxItem",
description: "settings:notificationEventOfficeInboxItemDescription",
},
};
function useEventMeta() {
const { t } = useTranslation();
return (eventType: string) => {
const keys = EVENT_MESSAGE_KEYS[eventType];
return {
title: keys ? t(keys.title) : eventType,
description: keys ? t(keys.description) : t("settings:notifyWhenThisEventOccurs"),
};
};
}
/**
* `eventTitle` comes from the row that owns it rather than a second
* `useEventMeta()` lookup, so the accessible name can never drift from the title
* the user reads next to the checkbox.
*/
function EventCheckbox({
provider,
baselineProviders,
eventType,
eventTitle,
onToggleEvent,
mobile = false,
}: {
provider: NotificationProvider;
baselineProviders: NotificationProvider[];
eventType: string;
eventTitle: string;
onToggleEvent: Props["onToggleEvent"];
mobile?: boolean;
}) {
const meta = eventMeta(eventType);
const { t } = useTranslation();
const checked = (provider.events ?? []).includes(eventType);
const baselineChecked = (
baselineProviders.find((candidate) => candidate.id === provider.id)?.events ?? []
).includes(eventType);
const checkbox = (
<Checkbox
aria-label={`${meta.title} for ${provider.name}`}
aria-label={t("settings:notificationEventToggle", {
event: eventTitle,
name: provider.name,
})}
checked={checked}
data-settings-dirty={checked !== baselineChecked}
onCheckedChange={() => onToggleEvent(provider, eventType)}
@@ -69,6 +115,7 @@ function TestProviderButton({
provider: NotificationProvider;
mobile?: boolean;
}) {
const { t } = useTranslation();
if (provider.type === "local") return null;
return (
<TooltipProvider>
@@ -78,13 +125,13 @@ function TestProviderButton({
variant="ghost"
size="icon"
className={mobile ? "h-11 w-11 shrink-0 cursor-pointer" : "h-6 w-6 cursor-pointer"}
aria-label={`Send test notification for ${provider.name}`}
aria-label={t("settings:sendTestNotificationFor", { name: provider.name })}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-3.5 w-3.5" />
</Button>
</TooltipTrigger>
<TooltipContent>Send test notification</TooltipContent>
<TooltipContent>{t("settings:sendTestNotification")}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
@@ -97,6 +144,7 @@ function MobileEventList({
onToggleEvent,
onTestProvider,
}: Props) {
const eventMeta = useEventMeta();
return (
<div className="space-y-4 md:hidden" data-testid="notification-events-mobile-list">
{tableEvents.map((eventType) => {
@@ -121,6 +169,7 @@ function MobileEventList({
provider={provider}
baselineProviders={baselineProviders}
eventType={eventType}
eventTitle={meta.title}
onToggleEvent={onToggleEvent}
mobile
/>
@@ -141,6 +190,8 @@ function DesktopEventTable({
onToggleEvent,
onTestProvider,
}: Props) {
const { t } = useTranslation();
const eventMeta = useEventMeta();
return (
<div
className="hidden overflow-auto rounded-lg border border-muted md:block"
@@ -149,7 +200,7 @@ function DesktopEventTable({
<table className="min-w-full text-sm">
<thead className="bg-muted/40">
<tr>
<th className="px-4 py-3 text-left font-medium">Notification type</th>
<th className="px-4 py-3 text-left font-medium">{t("settings:notificationType")}</th>
{tableProviders.map((provider) => (
<th key={provider.id} className="px-4 py-3 text-center font-medium">
<div className="flex items-center justify-center gap-1.5">
@@ -176,6 +227,7 @@ function DesktopEventTable({
provider={provider}
baselineProviders={baselineProviders}
eventType={eventType}
eventTitle={meta.title}
onToggleEvent={onToggleEvent}
/>
</div>
@@ -191,8 +243,11 @@ function DesktopEventTable({
}
export function NotificationEventsTable(props: Props) {
const { t } = useTranslation();
if (props.tableProviders.length === 0) {
return <p className="text-sm text-muted-foreground">No providers configured yet.</p>;
return (
<p className="text-sm text-muted-foreground">{t("settings:noProvidersConfiguredYet")}</p>
);
}
return (
@@ -1,4 +1,4 @@
import { render, renderHook, screen, waitFor } from "@testing-library/react";
import { cleanup, render, renderHook, screen, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
DesktopNotificationsSection,
@@ -23,6 +23,7 @@ beforeEach(() => {
});
afterEach(() => {
cleanup();
Object.defineProperty(globalThis, "Notification", {
configurable: true,
value: originalNotification,
@@ -87,4 +88,21 @@ describe("DesktopNotificationsSection", () => {
expect(screen.getByRole("button", { name: "Refresh notification permission" })).toBeTruthy();
});
it.each([
["default" as const, "Enable"],
["granted" as const, "Enabled"],
["error" as const, "Retry"],
])("labels the permission button %s as %s", (permission, label) => {
render(
<DesktopNotificationsSection
notificationPermission={permission}
onRequestPermission={vi.fn()}
onRefreshPermission={vi.fn()}
onTestNotification={vi.fn()}
/>,
);
expect(screen.getByRole("button", { name: label })).toBeTruthy();
});
});
@@ -1,5 +1,6 @@
import { useCallback, useEffect, useState } from "react";
import { IconBell, IconRefresh } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@kandev/ui/hover-card";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@kandev/ui/tooltip";
@@ -22,10 +23,15 @@ type DesktopNotificationsSectionProps = {
onTestNotification: () => void;
};
function permissionActionLabel(permission: NotificationPermissionState) {
if (permission === "granted") return "Enabled";
if (permission === "error") return "Retry";
return "Enable";
/**
* Returns a catalog key rather than copy: `permission` is a sentinel compared
* with `===` and this helper runs outside a component, so resolving here would
* either freeze the copy at the boot locale or need a `t` threaded through.
*/
function permissionActionLabelKey(permission: NotificationPermissionState) {
if (permission === "granted") return "settings:notificationPermissionEnabled";
if (permission === "error") return "settings:notificationPermissionRetry";
return "settings:notificationPermissionEnable";
}
export function DesktopNotificationsSection({
@@ -34,18 +40,19 @@ export function DesktopNotificationsSection({
onRefreshPermission,
onTestNotification,
}: DesktopNotificationsSectionProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-base font-medium">Desktop Notifications</div>
<div className="text-base font-medium">{t("settings:desktopNotifications")}</div>
<p className="text-sm text-muted-foreground">
Notify this device when a selected agent turn, question, or Office event occurs.
{t("settings:desktopNotificationsDescription")}
</p>
</div>
<div className="flex items-center gap-2">
<Button
title="Enable desktop notifications"
title={t("settings:enableDesktopNotifications")}
variant="default"
size="sm"
onClick={() => void onRequestPermission()}
@@ -58,7 +65,7 @@ export function DesktopNotificationsSection({
: "cursor-pointer"
}
>
{permissionActionLabel(notificationPermission)}
{t(permissionActionLabelKey(notificationPermission))}
</Button>
<TooltipProvider>
<Tooltip>
@@ -66,20 +73,20 @@ export function DesktopNotificationsSection({
<Button
variant="ghost"
size="icon"
aria-label="Refresh notification permission"
aria-label={t("settings:refreshNotificationPermission")}
className="cursor-pointer"
onClick={() => void onRefreshPermission()}
>
<IconRefresh className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Refresh permission status</TooltipContent>
<TooltipContent>{t("settings:refreshPermissionStatus")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<HoverCard>
<HoverCardTrigger asChild>
<Button
title="Send test notification"
title={t("settings:sendTestNotification")}
variant="outline"
className="cursor-pointer"
size="icon"
@@ -91,7 +98,7 @@ export function DesktopNotificationsSection({
</Button>
</HoverCardTrigger>
<HoverCardContent side="top" className="text-sm">
If you do not see notifications, check your OS settings and allow this browser.
{t("settings:notificationsNotShowingHint")}
</HoverCardContent>
</HoverCard>
</div>
@@ -100,20 +107,15 @@ export function DesktopNotificationsSection({
{notificationPermission === "denied" && (
<p className="text-sm text-amber-600">
{nativeNotifications.isAvailable()
? "Notifications are blocked in your OS app notification settings. Enable them there, then click Refresh."
: "Notifications are blocked in your browser. Enable them in site settings, then click Refresh."}
? t("settings:notificationsBlockedInOs")
: t("settings:notificationsBlockedInBrowser")}
</p>
)}
{notificationPermission === "unsupported" && (
<p className="text-sm text-amber-600">
This browser does not support desktop notifications.
</p>
<p className="text-sm text-amber-600">{t("settings:notificationsUnsupported")}</p>
)}
{notificationPermission === "error" && (
<p className="text-sm text-amber-600">
Kandev could not check notification permission. Try again, then check your browser or OS
notification settings.
</p>
<p className="text-sm text-amber-600">{t("settings:notificationPermissionCheckFailed")}</p>
)}
</div>
);
@@ -58,6 +58,19 @@ describe("NotificationSoundSection", () => {
await waitFor(() => expect(toggle.getAttribute(DIRTY_ATTRIBUTE)).toBe("false"));
});
it("shows the selected preset under its translated name", async () => {
setSoundPreferences({ enabled: true, presetId: "ding" });
render(
<SettingsSaveProvider>
<NotificationSoundSection />
</SettingsSaveProvider>,
);
// The preset id is the persisted sentinel; only the name is translated.
const trigger = await screen.findByRole("combobox", { name: "Notification sound" });
expect(trigger.textContent).toContain("Ding");
});
it("reports dirtiness so the notifications parent card can highlight", async () => {
const onDirtyChange = vi.fn();
render(
@@ -2,6 +2,7 @@
import { useEffect, useState } from "react";
import { IconVolume } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select";
import { Switch } from "@kandev/ui/switch";
@@ -13,14 +14,28 @@ import {
setSoundPreferences,
SOUND_PRESETS,
type SoundPreferences,
type SoundPresetId,
} from "@/lib/notifications/sound";
import { useSettingsSaveContributor } from "./settings-save-provider";
/**
* Catalog keys for the preset names, keyed by the preset id — the id is the
* persisted sentinel and must never be translated. Keys, not `t()` calls: a
* module-scope `t` would freeze the names at the boot locale (docs/i18n.md).
*/
const SOUND_PRESET_LABEL_KEYS: Record<SoundPresetId, string> = {
plim: "settings:soundPresetPlim",
chime: "settings:soundPresetChime",
ding: "settings:soundPresetDing",
pop: "settings:soundPresetPop",
};
export function NotificationSoundSection({
onDirtyChange,
}: {
onDirtyChange?: (isDirty: boolean) => void;
}) {
const { t } = useTranslation();
const [saved, setSaved] = useState<SoundPreferences>(getSoundPreferences);
const [prefs, setPrefs] = useState<SoundPreferences>(saved);
const revision = JSON.stringify(prefs);
@@ -48,17 +63,16 @@ export function NotificationSoundSection({
>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-base font-medium">Notification Sound</div>
<div className="text-base font-medium">{t("settings:notificationSound")}</div>
<p className="text-sm text-muted-foreground">
Play a sound on this device when a selected agent turn, question, or Office event
occurs.
{t("settings:notificationSoundDescription")}
</p>
</div>
<Switch
checked={prefs.enabled}
data-settings-dirty={prefs.enabled !== saved.enabled}
onCheckedChange={(enabled) => setPrefs({ ...prefs, enabled })}
aria-label="Enable notification sound"
aria-label={t("settings:enableNotificationSound")}
className="cursor-pointer"
/>
</div>
@@ -74,7 +88,7 @@ export function NotificationSoundSection({
>
<SelectTrigger
className="w-44 cursor-pointer"
aria-label="Notification sound"
aria-label={t("settings:notificationSoundSelect")}
data-settings-dirty={prefs.presetId !== saved.presetId}
>
<SelectValue />
@@ -82,7 +96,7 @@ export function NotificationSoundSection({
<SelectContent>
{SOUND_PRESETS.map((preset) => (
<SelectItem key={preset.id} value={preset.id} className="cursor-pointer">
{preset.label}
{t(SOUND_PRESET_LABEL_KEYS[preset.id])}
</SelectItem>
))}
</SelectContent>
@@ -94,13 +108,13 @@ export function NotificationSoundSection({
variant="outline"
size="icon"
className="cursor-pointer"
aria-label="Preview sound"
aria-label={t("settings:previewSound")}
onClick={() => playSoundPreset(prefs.presetId)}
>
<IconVolume className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Preview sound</TooltipContent>
<TooltipContent>{t("settings:previewSound")}</TooltipContent>
</Tooltip>
</TooltipProvider>
</div>
@@ -8,6 +8,7 @@ import {
updateNotificationProvider,
} from "@/lib/api";
import { useRequest } from "@/lib/http/use-request";
import { t } from "@/lib/i18n";
import { DEFAULT_NOTIFICATION_EVENTS } from "@/lib/notifications/events";
import { nativeNotifications } from "@/lib/desktop/native-notification-client";
import { useNotificationProviders } from "@/hooks/domains/settings/use-notification-providers";
@@ -273,7 +274,7 @@ export function useSaveRequest(state: NotificationsState) {
? { name: appriseName, urls: parseAppriseUrls(appriseUrls) }
: null;
if (createDraft && createDraft.urls.length === 0) {
throw new Error("At least one Apprise service URL is required.");
throw new Error(t("settings:appriseUrlRequired"));
}
const updates: Array<Promise<NotificationProvider>> = [];
for (const provider of providers) {
@@ -511,8 +512,8 @@ export function useNotificationsActions(
bumpPermission();
}
if (permission !== "granted") return;
new Notification("Test notification", {
body: "If you can read this, browser notifications are working.",
new Notification(t("settings:testNotificationTitle"), {
body: t("settings:testNotificationBody"),
});
};
@@ -0,0 +1,70 @@
import { cleanup, render } from "@testing-library/react";
import { afterEach, describe, expect, it, vi } from "vitest";
import type { NotificationProvider } from "@/lib/types/http";
import { NotificationsSettings } from "./notifications-settings";
import { SettingsSaveProvider } from "./settings-save-provider";
const mocks = vi.hoisted(() => ({
createNotificationProvider: vi.fn(),
deleteNotificationProvider: vi.fn(),
testNotificationProvider: vi.fn(),
updateNotificationProvider: vi.fn(),
setNotificationProviders: vi.fn(),
}));
vi.mock("@/lib/api", () => ({
createNotificationProvider: mocks.createNotificationProvider,
deleteNotificationProvider: mocks.deleteNotificationProvider,
testNotificationProvider: mocks.testNotificationProvider,
updateNotificationProvider: mocks.updateNotificationProvider,
}));
// Identities must be stable: the hydration effect in `useNotificationsState`
// compares them by reference, so a fresh array per render re-hydrates forever.
const PROVIDERS: NotificationProvider[] = [];
const EVENTS = ["session.turn_finished"];
vi.mock("@/hooks/domains/settings/use-notification-providers", () => ({
useNotificationProviders: () => ({
providers: PROVIDERS,
events: EVENTS,
appriseAvailable: false,
loaded: true,
}),
}));
vi.mock("@/components/state-provider", () => ({
useAppStore: (selector: (state: Record<string, unknown>) => unknown) =>
selector({ setNotificationProviders: mocks.setNotificationProviders }),
}));
vi.mock("@/lib/desktop/native-notification-client", () => ({
nativeNotifications: {
isAvailable: () => false,
permission: { get: vi.fn(), request: vi.fn() },
},
}));
afterEach(cleanup);
describe("NotificationsSettings", () => {
it("renders the Apprise install notice as one sentence around its link", () => {
// <Trans> addresses the anchor by child index, and a drifted index renders
// duplicated fragments with an empty tag rather than failing (docs/i18n.md).
const { container } = render(
<SettingsSaveProvider>
<NotificationsSettings />
</SettingsSaveProvider>,
);
const anchor = container.querySelector("a");
expect(anchor?.textContent).toBe("View installation instructions");
expect(anchor?.getAttribute("href")).toBe(
"https://github.com/caronc/apprise?tab=readme-ov-file#installation",
);
expect(anchor?.parentElement?.textContent).toBe(
"Apprise is not installed yet. You can add it later to enable remote notifications. " +
"View installation instructions.",
);
});
});
@@ -2,6 +2,7 @@
import { useMemo, useState, type FormEvent } from "react";
import { IconBell } from "@tabler/icons-react";
import { Trans, useTranslation } from "react-i18next";
import { Button } from "@kandev/ui/button";
import { Input } from "@kandev/ui/input";
import { Separator } from "@kandev/ui/separator";
@@ -36,6 +37,7 @@ function AppriseProviderCardActions({
onDeleteProvider: (providerId: string) => void;
onTestProvider: (providerId: string) => Promise<void>;
}) {
const { t } = useTranslation();
return (
<div className="flex items-center gap-2">
<TooltipProvider>
@@ -45,13 +47,13 @@ function AppriseProviderCardActions({
variant="outline"
size="icon"
className="h-8 w-8 cursor-pointer"
aria-label={`Send test notification for ${provider.name}`}
aria-label={t("settings:sendTestNotificationFor", { name: provider.name })}
onClick={() => void onTestProvider(provider.id)}
>
<IconBell className="h-4 w-4" />
</Button>
</TooltipTrigger>
<TooltipContent>Send test notification</TooltipContent>
<TooltipContent>{t("settings:sendTestNotification")}</TooltipContent>
</Tooltip>
</TooltipProvider>
<Button
@@ -60,7 +62,7 @@ function AppriseProviderCardActions({
className="cursor-pointer"
onClick={() => onOpenForm("edit", provider)}
>
Edit
{t("settings:edit")}
</Button>
<Button
variant="outline"
@@ -68,7 +70,7 @@ function AppriseProviderCardActions({
className="cursor-pointer"
onClick={() => onDeleteProvider(provider.id)}
>
Remove
{t("settings:remove")}
</Button>
</div>
);
@@ -148,6 +150,7 @@ function AppriseProviderList({
<div className="flex items-center justify-between gap-4">
<div className="space-y-1 flex-1">
<div className="font-medium">{provider.name}</div>
{/* Product name — never translated (docs/i18n.md "Do not translate"). */}
<div className="text-xs text-muted-foreground">Apprise</div>
</div>
<AppriseProviderCardActions
@@ -206,30 +209,35 @@ function ExternalProvidersSection({
onTestProvider,
onTextareaInput,
}: ExternalProvidersSectionProps) {
const { t } = useTranslation();
return (
<div className="space-y-4">
<div>
<div className="text-base font-medium">External Providers</div>
<div className="text-base font-medium">{t("settings:externalProviders")}</div>
<p className="text-sm text-muted-foreground">
Configure external providers for remote notifications.
{t("settings:externalProvidersDescription")}
</p>
</div>
{!appriseAvailable && (
<p className="text-sm text-muted-foreground">
Apprise is not installed yet. You can add it later to enable remote notifications.{" "}
<a
className="underline"
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
target="_blank"
rel="noreferrer"
>
View installation instructions
</a>
.
{/* The link is part of the sentence, so the whole notice is one
message and the <2> tag addresses the anchor by child index. */}
<Trans i18nKey="settings:appriseNotInstalled">
Apprise is not installed yet. You can add it later to enable remote notifications.{" "}
<a
className="underline"
href="https://github.com/caronc/apprise?tab=readme-ov-file#installation"
target="_blank"
rel="noreferrer"
>
View installation instructions
</a>
.
</Trans>
</p>
)}
{appriseProviders.length === 0 && (
<p className="text-sm text-muted-foreground">No Apprise providers configured yet.</p>
<p className="text-sm text-muted-foreground">{t("settings:noAppriseProviders")}</p>
)}
<AppriseProviderList
providers={appriseProviders}
@@ -257,7 +265,7 @@ function ExternalProvidersSection({
onClick={() => onOpenForm("create")}
disabled={showAppriseForm}
>
Add Apprise Provider
{t("settings:addAppriseProvider")}
</Button>
{showAppriseForm && appriseFormMode === "create" && (
<AppriseProviderForm
@@ -305,6 +313,7 @@ function useTableData(state: NotificationsState) {
}
function useNotificationPageSaveState(state: NotificationsState, soundIsDirty: boolean) {
const { t } = useTranslation();
const providerIsDirty = useIsDirty(state);
const creatingApprise = state.showAppriseForm && state.appriseFormMode === "create";
const canSave = !creatingApprise || state.appriseUrls.trim().length > 0;
@@ -331,12 +340,13 @@ function useNotificationPageSaveState(state: NotificationsState, soundIsDirty: b
providerIsDirty,
cardIsDirty: providerIsDirty || soundIsDirty,
canSave,
invalidReason: canSave ? undefined : "At least one Apprise service URL is required.",
invalidReason: canSave ? undefined : t("settings:appriseUrlRequired"),
revision,
};
}
export function NotificationsSettings() {
const { t } = useTranslation();
const state = useNotificationsState();
const { notificationPermission, refreshPermission } = useNotificationPermission();
const saveRequest = useSaveRequest(state);
@@ -359,8 +369,8 @@ export function NotificationsSettings() {
const appriseProviders = providers.filter((provider) => provider.type === "apprise");
return (
<SettingsPageTemplate
title="Notifications"
description="Configure providers and choose which events should alert you."
title={t("settings:notifications")}
description={t("settings:notificationsDescription")}
isDirty={saveState.providerIsDirty}
cardIsDirty={saveState.cardIsDirty}
saveStatus={saveRequest.status}
@@ -402,9 +412,9 @@ export function NotificationsSettings() {
<Separator className="my-4" />
<div className="space-y-4">
<div>
<div className="text-base font-medium">Notification Events</div>
<div className="text-base font-medium">{t("settings:notificationEvents")}</div>
<p className="text-sm text-muted-foreground">
Select which providers should receive each notification type.
{t("settings:notificationEventsDescription")}
</p>
</div>
{tableProviders.length > 0 && (
@@ -450,24 +460,25 @@ function AppriseProviderForm({
formIsDirty = nameIsDirty || urlsIsDirty,
showSubmit = true,
}: AppriseProviderFormProps) {
const { t } = useTranslation();
return (
<div
className="rounded-lg border border-dashed border-muted p-4 space-y-3"
data-settings-dirty={formIsDirty}
data-settings-dirty-level="container"
>
<div className="text-base font-medium">Apprise Provider</div>
<div className="text-base font-medium">{t("settings:appriseProvider")}</div>
<Input
value={name}
onChange={(event) => onNameChange(event.target.value)}
placeholder="Provider name"
placeholder={t("settings:providerName")}
data-settings-dirty={nameIsDirty}
/>
<Textarea
value={urls}
onChange={(event) => onUrlsChange(event.target.value)}
onInput={onInput}
placeholder="Service URL(s)"
placeholder={t("settings:appriseServiceUrls")}
rows={1}
className="min-h-0 h-auto"
data-settings-dirty={urlsIsDirty}
@@ -475,11 +486,11 @@ function AppriseProviderForm({
<div className="flex items-center gap-2">
{showSubmit && (
<Button className="cursor-pointer" onClick={onSubmit}>
{mode === "create" ? "Add provider" : "Done"}
{mode === "create" ? t("settings:addProvider") : t("settings:done")}
</Button>
)}
<Button variant="ghost" className="cursor-pointer" onClick={onCancel}>
Cancel
{t("settings:cancel")}
</Button>
</div>
</div>
@@ -25,9 +25,25 @@ import { test, expect } from "../../fixtures/test-base";
const COVERAGE_ENABLED = process.env.KANDEV_I18N_COVERAGE === "1";
/** Migrated screens whose visible text is overwhelmingly UI chrome, not user data. */
const SCREENS = [
/**
* Migrated screens whose visible text is overwhelmingly UI chrome, not user data.
*
* `allow` extends `ALLOWED` for one screen only. Use it for text the frontend
* must NOT translate but cannot avoid rendering — records the backend owns, or a
* product name — and say where the value comes from, so the exemption stays
* auditable instead of quietly widening into a place to hide missed strings.
*/
const SCREENS: Array<{ name: string; url: string; allow?: string[] }> = [
{ name: "settings — appearance", url: "/settings/general/appearance" },
{
name: "settings — notifications",
url: "/settings/general/notifications",
// Provider names are rows in the notification_providers table. The backend
// seeds these two (apps/backend/internal/notifications/service/service.go)
// and users name their own Apprise ones, so they are data on the same
// footing as a task title. `Apprise` labels the provider type.
allow: ["Desktop Notifications", "System Notifications", "Apprise"],
},
{ name: "settings — secrets", url: "/settings/general/secrets" },
{ name: "settings — terminal", url: "/settings/general/terminal" },
];
@@ -111,7 +127,7 @@ test.describe("i18n pseudo-locale coverage", () => {
// Let lazy panels settle before scanning.
await testPage.waitForTimeout(1_000);
const leftovers = await findUnlocalizedText(testPage, ALLOWED);
const leftovers = await findUnlocalizedText(testPage, [...ALLOWED, ...(screen.allow ?? [])]);
expect(
leftovers,
`Un-externalized strings on ${screen.name}:\n${leftovers.map((s) => ` - ${s}`).join("\n")}`,
+13 -1
View File
@@ -30,7 +30,7 @@ export const noLiteralStringOptions = {
exclude: [
"^\\s*$",
"^[^A-Za-z]*$",
"^(Kandev|GitHub|GitLab|Jira|Linear|Slack|Sentry|Azure DevOps|Docker|Codex|OpenCode|Claude|Copilot|Amp|Sprites\\.dev)$",
"^(Kandev|GitHub|GitLab|Jira|Linear|Slack|Sentry|Azure DevOps|Docker|Codex|OpenCode|Claude|Copilot|Amp|Apprise|Sprites\\.dev)$",
"^(ACP|MCP|SSH|URL|ID|PR|CI|AI|API|JSON|YAML|LSP|TLS|SQL|JQL)$",
// Units, version prefixes, and keyboard glyphs — not translatable
// copy; these show up as fragments beside an interpolated value.
@@ -172,6 +172,9 @@ export const i18nGuardFiles = [
// Settings → General → Appearance, migrated end-to-end as the worked example:
// the page, the two sections it owns, and the settings chrome around them.
"app/settings/general/appearance/**/*.{ts,tsx}",
// Settings → General → Notifications; the shared settings chrome it renders
// is migrated with the page that owns it, not here.
"app/settings/general/notifications/**/*.{ts,tsx}",
// Settings → General → Secrets.
"app/settings/general/secrets/**/*.{ts,tsx}",
// Settings → General → Terminal; the shared settings chrome is migrated with
@@ -181,6 +184,15 @@ export const i18nGuardFiles = [
"components/settings/general-settings.tsx",
"components/settings/general-nav.ts",
"components/settings/language-settings.tsx",
"components/settings/notification-events-table.tsx",
"components/settings/notification-permission-section.tsx",
"components/settings/notification-sound-section.tsx",
// A `.ts` entry records that the file is migrated, but the rule cannot
// enforce it: `mode: "jsx-only"` means a file with no JSX is never inspected,
// so copy added here (toast/dialog/Notification arguments, thrown messages)
// is caught only by the pseudo-locale, never by lint.
"components/settings/notifications-settings-actions.ts",
"components/settings/notifications-settings.tsx",
"components/settings/secrets-settings.tsx",
"components/settings/settings-floating-save.tsx",
"components/settings/settings-layout-client.tsx",
+58 -4
View File
@@ -1,9 +1,15 @@
{
"aSecretNameAndValueAreRequired": "A secret name and value are required.",
"aSecretNameIsRequired": "A secret name is required.",
"addAppriseProvider": "Add Apprise Provider",
"addProvider": "Add provider",
"addSecret": "Add secret",
"agent": "Agent",
"appearance": "Appearance",
"aSecretNameAndValueAreRequired": "A secret name and value are required.",
"aSecretNameIsRequired": "A secret name is required.",
"appriseNotInstalled": "Apprise is not installed yet. You can add it later to enable remote notifications. <2>View installation instructions</2>.",
"appriseProvider": "Apprise Provider",
"appriseServiceUrls": "Service URL(s)",
"appriseUrlRequired": "At least one Apprise service URL is required.",
"builtInBrowserPanel": "Built-in browser panel",
"cancel": "Cancel",
"changesPanel": "Changes Panel",
@@ -40,17 +46,25 @@
"default": "Default",
"defaultMenloMonaco": "Default (Menlo / Monaco)",
"deleteSecret": "Delete secret",
"desktopNotifications": "Desktop Notifications",
"desktopNotificationsDescription": "Notify this device when a selected agent turn, question, or Office event occurs.",
"discardAndLeave": "Discard and leave",
"discarding": "Discarding...",
"disk": "Disk %",
"diskPath": "Disk path",
"displayChangedFilesAsAFlat": "Display changed files as a flat list with full paths, or as a folder tree.",
"displayLanguage": "Display language",
"done": "Done",
"edit": "Edit",
"editSecret": "Edit secret",
"editorIntegrationsAndDefaults": "Editor integrations and defaults",
"editors": "Editors",
"editSecret": "Edit secret",
"enableDesktopNotifications": "Enable desktop notifications",
"enableNotificationSound": "Enable notification sound",
"enterAShellPathOrCommand": "Enter a shell path or command available in the agent environment.",
"enterToSend": "Enter to send",
"externalProviders": "External Providers",
"externalProvidersDescription": "Configure external providers for remote notifications.",
"fileListView": "File list view",
"flatList": "Flat list",
"fontFamily": "Font Family",
@@ -78,8 +92,35 @@
"newBrowserTab": "New browser tab",
"newSecret": "New secret",
"newTaskSessionsWillUseThis": "New task sessions will use this shell. Existing sessions keep their current shell.",
"noAppriseProviders": "No Apprise providers configured yet.",
"noProvidersConfiguredYet": "No providers configured yet.",
"noSecretsYetAddYourFirst": "No secrets yet. Add your first secret to get started.",
"notificationEventClarificationRequested": "Agent needs an answer",
"notificationEventClarificationRequestedDescription": "Notify when the agent explicitly asks you a question.",
"notificationEventOfficeInboxItem": "Office inbox item",
"notificationEventOfficeInboxItemDescription": "Notify when a new inbox item needs your attention.",
"notificationEventToggle": "{{event}} for {{name}}",
"notificationEventTurnFinished": "Agent turn finished",
"notificationEventTurnFinishedDescription": "Notify after each completed agent turn.",
"notificationEventUpdateAvailable": "Kandev update available",
"notificationEventUpdateAvailableDescription": "Notify when a newer Kandev release is available.",
"notificationEvents": "Notification Events",
"notificationEventsDescription": "Select which providers should receive each notification type.",
"notificationPermissionCheckFailed": "Kandev could not check notification permission. Try again, then check your browser or OS notification settings.",
"notificationPermissionEnable": "Enable",
"notificationPermissionEnabled": "Enabled",
"notificationPermissionRetry": "Retry",
"notificationSound": "Notification Sound",
"notificationSoundDescription": "Play a sound on this device when a selected agent turn, question, or Office event occurs.",
"notificationSoundSelect": "Notification sound",
"notificationType": "Notification type",
"notifications": "Notifications",
"notificationsBlockedInBrowser": "Notifications are blocked in your browser. Enable them in site settings, then click Refresh.",
"notificationsBlockedInOs": "Notifications are blocked in your OS app notification settings. Enable them there, then click Refresh.",
"notificationsDescription": "Configure providers and choose which events should alert you.",
"notificationsNotShowingHint": "If you do not see notifications, check your OS settings and allow this browser.",
"notificationsUnsupported": "This browser does not support desktop notifications.",
"notifyWhenThisEventOccurs": "Notify when this event occurs.",
"openKandevTo": "Open Kandev to",
"openLinksIn": "Open links in",
"openSettingsMenu": "Open settings menu",
@@ -88,9 +129,14 @@
"preferredShell": "Preferred Shell",
"pressCmdCtrlEnterToSend": "Press Cmd/Ctrl+Enter to send messages. Press Enter for newlines.",
"pressEnterToSendMessagesPress": "Press Enter to send messages. Press Shift+Enter for newlines.",
"previewSound": "Preview sound",
"programming": "Programming",
"providerName": "Provider name",
"providersAndNotificationEvents": "Providers and notification events",
"pxRange": "px (8-24)",
"refreshNotificationPermission": "Refresh notification permission",
"refreshPermissionStatus": "Refresh permission status",
"remove": "Remove",
"removesTheHostMarkerAndProgress": "Removes the Host marker and progress bars while retaining metric icons and values.",
"resourceMetrics": "Resource Metrics",
"resumeTheMostRecentlyOpenedTask": "Resume the most recently opened task in this workspace on this device.",
@@ -101,9 +147,11 @@
"saved": "Saved",
"saving": "Saving...",
"savingChanges": "Saving changes",
"secrets": "Secrets",
"secretValue": "Secret value",
"secrets": "Secrets",
"selectAShell": "Select a shell",
"sendTestNotification": "Send test notification",
"sendTestNotificationFor": "Send test notification for {{name}}",
"setTheFontSizeForThe": "Set the font size for the terminal. Default is 13px.",
"settings": "Settings",
"shellOptionsUnavailable": "Shell options unavailable",
@@ -111,6 +159,10 @@
"showHostMetricsInStatusBar": "Show host metrics in status bar",
"showsKandevHostValuesOnlyCollection": "Shows Kandev host values only. Collection starts while a client displays them.",
"simplifiedMetrics": "Simplified metrics",
"soundPresetChime": "Chime",
"soundPresetDing": "Ding",
"soundPresetPlim": "Plim",
"soundPresetPop": "Pop",
"sprites": "Sprites",
"startOnYourSavedKanbanPipelineOrList": "Start on your saved Kanban, Pipeline, or List view.",
"startupPage": "Startup Page",
@@ -126,6 +178,8 @@
"terminalFontSize": "Terminal Font Size",
"terminalFontSizeMustBeA": "Terminal font size must be a number between 8 and 24.",
"terminalLinks": "Terminal Links",
"testNotificationBody": "If you can read this, browser notifications are working.",
"testNotificationTitle": "Test notification",
"themeMetricsAndChangesPanelPreferences": "Theme, startup page, metrics, and changes panel preferences",
"thisAppliesWhenKandevStarts": "This applies when Kandev starts or you open its bare home page. Home, Back, and workflow navigation always open the task overview.",
"thisPageHasUnsavedChangesSave": "This page has unsaved changes. Save them, discard them, or continue editing.",
+58 -4
View File
@@ -1,9 +1,15 @@
{
"aSecretNameAndValueAreRequired": "À śēćŕēţ ńàḿē àńď vàĺũē àŕē ŕēqũĩŕēď.",
"aSecretNameIsRequired": "À śēćŕēţ ńàḿē ĩś ŕēqũĩŕēď.",
"addAppriseProvider": "Àďď Àƥƥŕĩśē Ƥŕōvĩďēŕ",
"addProvider": "Àďď ƥŕōvĩďēŕ",
"addSecret": "Àďď śēćŕēţ",
"agent": "Àĝēńţ",
"appearance": "Àƥƥēàŕàńćē",
"aSecretNameAndValueAreRequired": "À śēćŕēţ ńàḿē àńď vàĺũē àŕē ŕēqũĩŕēď.",
"aSecretNameIsRequired": "À śēćŕēţ ńàḿē ĩś ŕēqũĩŕēď.",
"appriseNotInstalled": "Àƥƥŕĩśē ĩś ńōţ ĩńśţàĺĺēď ŷēţ. Ŷōũ ćàń àďď ĩţ ĺàţēŕ ţō ēńàƀĺē ŕēḿōţē ńōţĩƒĩćàţĩōńś. <2>Vĩēŵ ĩńśţàĺĺàţĩōń ĩńśţŕũćţĩōńś</2>.",
"appriseProvider": "Àƥƥŕĩśē Ƥŕōvĩďēŕ",
"appriseServiceUrls": "Śēŕvĩćē ŨŔĹ(ś)",
"appriseUrlRequired": "Àţ ĺēàśţ ōńē Àƥƥŕĩśē śēŕvĩćē ŨŔĹ ĩś ŕēqũĩŕēď.",
"builtInBrowserPanel": "Ɓũĩĺţ-ĩń ƀŕōŵśēŕ ƥàńēĺ",
"cancel": "Ćàńćēĺ",
"changesPanel": "Ćĥàńĝēś Ƥàńēĺ",
@@ -40,17 +46,25 @@
"default": "Ďēƒàũĺţ",
"defaultMenloMonaco": "Ďēƒàũĺţ (Ḿēńĺō / Ḿōńàćō)",
"deleteSecret": "Ďēĺēţē śēćŕēţ",
"desktopNotifications": "Ďēśķţōƥ Ńōţĩƒĩćàţĩōńś",
"desktopNotificationsDescription": "Ńōţĩƒŷ ţĥĩś ďēvĩćē ŵĥēń à śēĺēćţēď àĝēńţ ţũŕń, qũēśţĩōń, ōŕ Ōƒƒĩćē ēvēńţ ōććũŕś.",
"discardAndLeave": "Ďĩśćàŕď àńď ĺēàvē",
"discarding": "Ďĩśćàŕďĩńĝ...",
"disk": "Ďĩśķ %",
"diskPath": "Ďĩśķ ƥàţĥ",
"displayChangedFilesAsAFlat": "Ďĩśƥĺàŷ ćĥàńĝēď ƒĩĺēś àś à ƒĺàţ ĺĩśţ ŵĩţĥ ƒũĺĺ ƥàţĥś, ōŕ àś à ƒōĺďēŕ ţŕēē.",
"displayLanguage": "Ďĩśƥĺàŷ ĺàńĝũàĝē",
"done": "Ďōńē",
"edit": "Ēďĩţ",
"editSecret": "Ēďĩţ śēćŕēţ",
"editorIntegrationsAndDefaults": "Ēďĩţōŕ ĩńţēĝŕàţĩōńś àńď ďēƒàũĺţś",
"editors": "Ēďĩţōŕś",
"editSecret": "Ēďĩţ śēćŕēţ",
"enableDesktopNotifications": "Ēńàƀĺē ďēśķţōƥ ńōţĩƒĩćàţĩōńś",
"enableNotificationSound": "Ēńàƀĺē ńōţĩƒĩćàţĩōń śōũńď",
"enterAShellPathOrCommand": "Ēńţēŕ à śĥēĺĺ ƥàţĥ ōŕ ćōḿḿàńď àvàĩĺàƀĺē ĩń ţĥē àĝēńţ ēńvĩŕōńḿēńţ.",
"enterToSend": "Ēńţēŕ ţō śēńď",
"externalProviders": "Ēxţēŕńàĺ Ƥŕōvĩďēŕś",
"externalProvidersDescription": "Ćōńƒĩĝũŕē ēxţēŕńàĺ ƥŕōvĩďēŕś ƒōŕ ŕēḿōţē ńōţĩƒĩćàţĩōńś.",
"fileListView": "Ƒĩĺē ĺĩśţ vĩēŵ",
"flatList": "Ƒĺàţ ĺĩśţ",
"fontFamily": "Ƒōńţ Ƒàḿĩĺŷ",
@@ -78,8 +92,35 @@
"newBrowserTab": "Ńēŵ ƀŕōŵśēŕ ţàƀ",
"newSecret": "Ńēŵ śēćŕēţ",
"newTaskSessionsWillUseThis": "Ńēŵ ţàśķ śēśśĩōńś ŵĩĺĺ ũśē ţĥĩś śĥēĺĺ. Ēxĩśţĩńĝ śēśśĩōńś ķēēƥ ţĥēĩŕ ćũŕŕēńţ śĥēĺĺ.",
"noAppriseProviders": "Ńō Àƥƥŕĩśē ƥŕōvĩďēŕś ćōńƒĩĝũŕēď ŷēţ.",
"noProvidersConfiguredYet": "Ńō ƥŕōvĩďēŕś ćōńƒĩĝũŕēď ŷēţ.",
"noSecretsYetAddYourFirst": "Ńō śēćŕēţś ŷēţ. Àďď ŷōũŕ ƒĩŕśţ śēćŕēţ ţō ĝēţ śţàŕţēď.",
"notificationEventClarificationRequested": "Àĝēńţ ńēēďś àń àńśŵēŕ",
"notificationEventClarificationRequestedDescription": "Ńōţĩƒŷ ŵĥēń ţĥē àĝēńţ ēxƥĺĩćĩţĺŷ àśķś ŷōũ à qũēśţĩōń.",
"notificationEventOfficeInboxItem": "Ōƒƒĩćē ĩńƀōx ĩţēḿ",
"notificationEventOfficeInboxItemDescription": "Ńōţĩƒŷ ŵĥēń à ńēŵ ĩńƀōx ĩţēḿ ńēēďś ŷōũŕ àţţēńţĩōń.",
"notificationEventToggle": "{{event}} ƒōŕ {{name}}",
"notificationEventTurnFinished": "Àĝēńţ ţũŕń ƒĩńĩśĥēď",
"notificationEventTurnFinishedDescription": "Ńōţĩƒŷ àƒţēŕ ēàćĥ ćōḿƥĺēţēď àĝēńţ ţũŕń.",
"notificationEventUpdateAvailable": "Ķàńďēv ũƥďàţē àvàĩĺàƀĺē",
"notificationEventUpdateAvailableDescription": "Ńōţĩƒŷ ŵĥēń à ńēŵēŕ Ķàńďēv ŕēĺēàśē ĩś àvàĩĺàƀĺē.",
"notificationEvents": "Ńōţĩƒĩćàţĩōń Ēvēńţś",
"notificationEventsDescription": "Śēĺēćţ ŵĥĩćĥ ƥŕōvĩďēŕś śĥōũĺď ŕēćēĩvē ēàćĥ ńōţĩƒĩćàţĩōń ţŷƥē.",
"notificationPermissionCheckFailed": "Ķàńďēv ćōũĺď ńōţ ćĥēćķ ńōţĩƒĩćàţĩōń ƥēŕḿĩśśĩōń. Ţŕŷ àĝàĩń, ţĥēń ćĥēćķ ŷōũŕ ƀŕōŵśēŕ ōŕ ŌŚ ńōţĩƒĩćàţĩōń śēţţĩńĝś.",
"notificationPermissionEnable": "Ēńàƀĺē",
"notificationPermissionEnabled": "Ēńàƀĺēď",
"notificationPermissionRetry": "Ŕēţŕŷ",
"notificationSound": "Ńōţĩƒĩćàţĩōń Śōũńď",
"notificationSoundDescription": "Ƥĺàŷ à śōũńď ōń ţĥĩś ďēvĩćē ŵĥēń à śēĺēćţēď àĝēńţ ţũŕń, qũēśţĩōń, ōŕ Ōƒƒĩćē ēvēńţ ōććũŕś.",
"notificationSoundSelect": "Ńōţĩƒĩćàţĩōń śōũńď",
"notificationType": "Ńōţĩƒĩćàţĩōń ţŷƥē",
"notifications": "Ńōţĩƒĩćàţĩōńś",
"notificationsBlockedInBrowser": "Ńōţĩƒĩćàţĩōńś àŕē ƀĺōćķēď ĩń ŷōũŕ ƀŕōŵśēŕ. Ēńàƀĺē ţĥēḿ ĩń śĩţē śēţţĩńĝś, ţĥēń ćĺĩćķ Ŕēƒŕēśĥ.",
"notificationsBlockedInOs": "Ńōţĩƒĩćàţĩōńś àŕē ƀĺōćķēď ĩń ŷōũŕ ŌŚ àƥƥ ńōţĩƒĩćàţĩōń śēţţĩńĝś. Ēńàƀĺē ţĥēḿ ţĥēŕē, ţĥēń ćĺĩćķ Ŕēƒŕēśĥ.",
"notificationsDescription": "Ćōńƒĩĝũŕē ƥŕōvĩďēŕś àńď ćĥōōśē ŵĥĩćĥ ēvēńţś śĥōũĺď àĺēŕţ ŷōũ.",
"notificationsNotShowingHint": "Ĩƒ ŷōũ ďō ńōţ śēē ńōţĩƒĩćàţĩōńś, ćĥēćķ ŷōũŕ ŌŚ śēţţĩńĝś àńď àĺĺōŵ ţĥĩś ƀŕōŵśēŕ.",
"notificationsUnsupported": "Ţĥĩś ƀŕōŵśēŕ ďōēś ńōţ śũƥƥōŕţ ďēśķţōƥ ńōţĩƒĩćàţĩōńś.",
"notifyWhenThisEventOccurs": "Ńōţĩƒŷ ŵĥēń ţĥĩś ēvēńţ ōććũŕś.",
"openKandevTo": "Ōƥēń Ķàńďēv ţō",
"openLinksIn": "Ōƥēń ĺĩńķś ĩń",
"openSettingsMenu": "Ōƥēń śēţţĩńĝś ḿēńũ",
@@ -88,9 +129,14 @@
"preferredShell": "Ƥŕēƒēŕŕēď Śĥēĺĺ",
"pressCmdCtrlEnterToSend": "Ƥŕēśś Ćḿď/Ćţŕĺ+Ēńţēŕ ţō śēńď ḿēśśàĝēś. Ƥŕēśś Ēńţēŕ ƒōŕ ńēŵĺĩńēś.",
"pressEnterToSendMessagesPress": "Ƥŕēśś Ēńţēŕ ţō śēńď ḿēśśàĝēś. Ƥŕēśś Śĥĩƒţ+Ēńţēŕ ƒōŕ ńēŵĺĩńēś.",
"previewSound": "Ƥŕēvĩēŵ śōũńď",
"programming": "Ƥŕōĝŕàḿḿĩńĝ",
"providerName": "Ƥŕōvĩďēŕ ńàḿē",
"providersAndNotificationEvents": "Ƥŕōvĩďēŕś àńď ńōţĩƒĩćàţĩōń ēvēńţś",
"pxRange": "ƥx (8-24)",
"refreshNotificationPermission": "Ŕēƒŕēśĥ ńōţĩƒĩćàţĩōń ƥēŕḿĩśśĩōń",
"refreshPermissionStatus": "Ŕēƒŕēśĥ ƥēŕḿĩśśĩōń śţàţũś",
"remove": "Ŕēḿōvē",
"removesTheHostMarkerAndProgress": "Ŕēḿōvēś ţĥē Ĥōśţ ḿàŕķēŕ àńď ƥŕōĝŕēśś ƀàŕś ŵĥĩĺē ŕēţàĩńĩńĝ ḿēţŕĩć ĩćōńś àńď vàĺũēś.",
"resourceMetrics": "Ŕēśōũŕćē Ḿēţŕĩćś",
"resumeTheMostRecentlyOpenedTask": "Ŕēśũḿē ţĥē ḿōśţ ŕēćēńţĺŷ ōƥēńēď ţàśķ ĩń ţĥĩś ŵōŕķśƥàćē ōń ţĥĩś ďēvĩćē.",
@@ -101,9 +147,11 @@
"saved": "Śàvēď",
"saving": "Śàvĩńĝ...",
"savingChanges": "Śàvĩńĝ ćĥàńĝēś",
"secrets": "Śēćŕēţś",
"secretValue": "Śēćŕēţ vàĺũē",
"secrets": "Śēćŕēţś",
"selectAShell": "Śēĺēćţ à śĥēĺĺ",
"sendTestNotification": "Śēńď ţēśţ ńōţĩƒĩćàţĩōń",
"sendTestNotificationFor": "Śēńď ţēśţ ńōţĩƒĩćàţĩōń ƒōŕ {{name}}",
"setTheFontSizeForThe": "Śēţ ţĥē ƒōńţ śĩźē ƒōŕ ţĥē ţēŕḿĩńàĺ. Ďēƒàũĺţ ĩś 13ƥx.",
"settings": "Śēţţĩńĝś",
"shellOptionsUnavailable": "Śĥēĺĺ ōƥţĩōńś ũńàvàĩĺàƀĺē",
@@ -111,6 +159,10 @@
"showHostMetricsInStatusBar": "Śĥōŵ ĥōśţ ḿēţŕĩćś ĩń śţàţũś ƀàŕ",
"showsKandevHostValuesOnlyCollection": "Śĥōŵś Ķàńďēv ĥōśţ vàĺũēś ōńĺŷ. Ćōĺĺēćţĩōń śţàŕţś ŵĥĩĺē à ćĺĩēńţ ďĩśƥĺàŷś ţĥēḿ.",
"simplifiedMetrics": "Śĩḿƥĺĩƒĩēď ḿēţŕĩćś",
"soundPresetChime": "Ćĥĩḿē",
"soundPresetDing": "Ďĩńĝ",
"soundPresetPlim": "Ƥĺĩḿ",
"soundPresetPop": "Ƥōƥ",
"sprites": "Śƥŕĩţēś",
"startOnYourSavedKanbanPipelineOrList": "Śţàŕţ ōń ŷōũŕ śàvēď Ķàńƀàń, Ƥĩƥēĺĩńē, ōŕ Ĺĩśţ vĩēŵ.",
"startupPage": "Śţàŕţũƥ Ƥàĝē",
@@ -126,6 +178,8 @@
"terminalFontSize": "Ţēŕḿĩńàĺ Ƒōńţ Śĩźē",
"terminalFontSizeMustBeA": "Ţēŕḿĩńàĺ ƒōńţ śĩźē ḿũśţ ƀē à ńũḿƀēŕ ƀēţŵēēń 8 àńď 24.",
"terminalLinks": "Ţēŕḿĩńàĺ Ĺĩńķś",
"testNotificationBody": "Ĩƒ ŷōũ ćàń ŕēàď ţĥĩś, ƀŕōŵśēŕ ńōţĩƒĩćàţĩōńś àŕē ŵōŕķĩńĝ.",
"testNotificationTitle": "Ţēśţ ńōţĩƒĩćàţĩōń",
"themeMetricsAndChangesPanelPreferences": "Ţĥēḿē, śţàŕţũƥ ƥàĝē, ḿēţŕĩćś, àńď ćĥàńĝēś ƥàńēĺ ƥŕēƒēŕēńćēś",
"thisAppliesWhenKandevStarts": "Ţĥĩś àƥƥĺĩēś ŵĥēń Ķàńďēv śţàŕţś ōŕ ŷōũ ōƥēń ĩţś ƀàŕē ĥōḿē ƥàĝē. Ĥōḿē, Ɓàćķ, àńď ŵōŕķƒĺōŵ ńàvĩĝàţĩōń àĺŵàŷś ōƥēń ţĥē ţàśķ ōvēŕvĩēŵ.",
"thisPageHasUnsavedChangesSave": "Ţĥĩś ƥàĝē ĥàś ũńśàvēď ćĥàńĝēś. Śàvē ţĥēḿ, ďĩśćàŕď ţĥēḿ, ōŕ ćōńţĩńũē ēďĩţĩńĝ.",