diff --git a/app/(main)/[locale]/[...rest]/page.tsx b/app/(main)/[locale]/[...rest]/page.tsx new file mode 100644 index 000000000..75c6193d9 --- /dev/null +++ b/app/(main)/[locale]/[...rest]/page.tsx @@ -0,0 +1,8 @@ +import { notFound } from 'next/navigation'; + +// Unknown paths below a locale (/de/typo) end here, so the 404 renders +// inside the locale layout and is translated ([locale]/not-found.tsx). +// Removed from the static Lite export, like (main)/[...rest]. +export default function LocaleCatchAll() { + notFound(); +} diff --git a/app/(main)/[locale]/calendar/[[...segments]]/page.tsx b/app/(main)/[locale]/calendar/[[...segments]]/page.tsx index 7ac1875fd..3419c08b2 100644 --- a/app/(main)/[locale]/calendar/[[...segments]]/page.tsx +++ b/app/(main)/[locale]/calendar/[[...segments]]/page.tsx @@ -1,5 +1,6 @@ import { CalendarApp } from "@/components/calendar/calendar-app"; import { generateLiteSegmentParams } from "@/lib/lite-static-params"; +import { AppErrorBoundary } from "@/components/error/app-error-boundary"; // Calendar permalinks (#733): // /calendar the stored view, today @@ -17,5 +18,9 @@ export default async function CalendarRoute({ params: Promise<{ segments?: string[] }>; }) { const { segments } = await params; - return ; + return ( + + + + ); } diff --git a/app/(main)/[locale]/contacts/[[...segments]]/page.tsx b/app/(main)/[locale]/contacts/[[...segments]]/page.tsx index 7c18db300..da9f8ae44 100644 --- a/app/(main)/[locale]/contacts/[[...segments]]/page.tsx +++ b/app/(main)/[locale]/contacts/[[...segments]]/page.tsx @@ -1,5 +1,6 @@ import { ContactsApp } from "@/components/contacts/contacts-app"; import { generateLiteSegmentParams } from "@/lib/lite-static-params"; +import { AppErrorBoundary } from "@/components/error/app-error-boundary"; // Contact permalinks (#733): /contacts/[/edit], plus /contacts/new // for the "add this sender" flow. The older query form (?contactId=, ?addEmail=) @@ -15,5 +16,9 @@ export default async function ContactsRoute({ params: Promise<{ segments?: string[] }>; }) { const { segments } = await params; - return ; + return ( + + + + ); } diff --git a/app/(main)/[locale]/error.tsx b/app/(main)/[locale]/error.tsx index e8a0caeac..f25e2ad46 100644 --- a/app/(main)/[locale]/error.tsx +++ b/app/(main)/[locale]/error.tsx @@ -2,9 +2,7 @@ import { useEffect } from "react"; import { useTranslations } from "next-intl"; -import { AlertCircle, RefreshCw, Home } from "@/components/icons"; -import { Button } from "@/components/ui/button"; -import { useRouter } from "@/i18n/navigation"; +import { ErrorState } from "@/components/error/error-fallbacks"; /** * Route-level error boundary for locale pages. @@ -18,35 +16,19 @@ export default function LocaleError({ reset: () => void; }) { const t = useTranslations("errors"); - const router = useRouter(); useEffect(() => { console.error("Route error:", error); }, [error]); return ( -
-
-
- -
-

- {t("page_error_title")} -

-

- {t("page_error_description")} -

-
- - -
-
-
+ ); } diff --git a/app/(main)/[locale]/files/[[...segments]]/page.tsx b/app/(main)/[locale]/files/[[...segments]]/page.tsx index a9782d6f6..a7a885744 100644 --- a/app/(main)/[locale]/files/[[...segments]]/page.tsx +++ b/app/(main)/[locale]/files/[[...segments]]/page.tsx @@ -1,5 +1,6 @@ import { FilesApp } from "@/components/files/files-app"; import { generateLiteSegmentParams } from "@/lib/lite-static-params"; +import { AppErrorBoundary } from "@/components/error/app-error-boundary"; // Files permalinks (#733): /files//, one path segment per // level, with ?preview= to open a file straight into the preview modal. @@ -14,5 +15,9 @@ export default async function FilesRoute({ params: Promise<{ segments?: string[] }>; }) { const { segments } = await params; - return ; + return ( + + + + ); } diff --git a/app/(main)/[locale]/layout.tsx b/app/(main)/[locale]/layout.tsx index 76f9396ad..a44a30286 100644 --- a/app/(main)/[locale]/layout.tsx +++ b/app/(main)/[locale]/layout.tsx @@ -11,6 +11,7 @@ import { ProInterfaceRedirect } from "@/components/pro/pro-interface-redirect"; import { ImpersonationReconciler } from "@/components/impersonation/impersonation-reconciler"; import { PluginDialogHost } from "@/components/plugins/plugin-dialog-host"; import { CommandPaletteHost } from "@/components/command-palette/command-palette"; +import { TotpReauthDialog } from "@/components/totp-reauth-dialog"; import { PluginConnectDialog } from "@/components/plugins/plugin-connect-dialog"; import { QuickRuleHost } from "@/components/filters/quick-rule-host"; import { PluginConsentDialog } from "@/components/plugins/plugin-consent-dialog"; @@ -64,6 +65,7 @@ export default async function LocaleLayout({ {children} + diff --git a/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx b/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx new file mode 100644 index 000000000..40b45873c --- /dev/null +++ b/app/(main)/[locale]/login/__tests__/login-page-i18n.test.tsx @@ -0,0 +1,65 @@ +import React from 'react'; +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { describe, expect, it, vi } from 'vitest'; + +// The login page in another language: the subtitle, the theme menu and the +// version details come from the translations, and the version details open +// from the keyboard, not only on hover. + +vi.mock('next/navigation', () => ({ + useParams: () => ({ locale: 'de' }), + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', +})); +vi.mock('@/i18n/navigation', () => ({ + useRouter: () => ({ push: vi.fn(), replace: vi.fn(), prefetch: vi.fn() }), + usePathname: () => '/login', + Link: ({ children }: { children: React.ReactNode }) => children, +})); + +vi.mock('@/hooks/use-config', () => ({ + useConfig: () => ({ + appName: 'Webmail', + isLoading: false, + error: null, + jmapServerUrl: 'https://mail.example.org', + jmapServers: [], + loginShowHeading: true, + loginShowSubtitle: true, + loginShowVersion: true, + loginShowTotp: true, + loginShowRememberMe: true, + }), +})); + +const { default: LoginPage } = await import('../page'); + +describe('login page translations', () => { + it('shows the translated subtitle whatever the app is called', async () => { + render(); + expect(await screen.findByText('subtitle', {}, { timeout: 10000 })).toBeInTheDocument(); + expect(screen.queryByText('Sign in to your account')).toBeNull(); + }, 30_000); + + it('labels the theme menu from the translations', async () => { + render(); + const button = await screen.findByRole('button', { name: 'label: system' }, { timeout: 10000 }); + fireEvent.click(button); + expect(screen.getByRole('menu', { name: 'label' })).toBeInTheDocument(); + expect(screen.getByRole('menuitemradio', { name: 'dark' })).toBeInTheDocument(); + }, 30_000); + + it('opens the version details from a button', async () => { + render(); + const trigger = await screen.findByRole('button', { name: /^v\d/ }, { timeout: 10000 }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + act(() => { fireEvent.click(trigger); }); + expect(trigger).toHaveAttribute('aria-expanded', 'true'); + const details = screen.getByRole('group', { name: 'details' }); + expect(details).toHaveTextContent('version_line'); + expect(screen.getByRole('button', { name: 'copy' })).toBeInTheDocument(); + act(() => { fireEvent.keyDown(document, { key: 'Escape' }); }); + expect(trigger).toHaveAttribute('aria-expanded', 'false'); + }, 30_000); +}); diff --git a/app/(main)/[locale]/login/page.tsx b/app/(main)/[locale]/login/page.tsx index efc06c706..d5b972efa 100644 --- a/app/(main)/[locale]/login/page.tsx +++ b/app/(main)/[locale]/login/page.tsx @@ -1,10 +1,11 @@ "use client"; -import { Suspense, useState, useEffect, useRef, useCallback } from "react"; +import { Suspense, useState, useEffect, useRef, useCallback, useId } from "react"; import { useRouter } from "@/i18n/navigation"; import { useParams, useSearchParams } from "next/navigation"; import { useTranslations } from "next-intl"; import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; import { Input } from "@/components/ui/input"; import { useAuthStore, consumeSignedOut } from "@/stores/auth-store"; import { useAccountStore } from "@/stores/account-store"; @@ -57,19 +58,46 @@ function findServerByDomain(servers: PublicJmapServerEntry[], email: string | un const APP_VERSION = process.env.NEXT_PUBLIC_APP_VERSION || "0.0.0"; const GIT_COMMIT = process.env.NEXT_PUBLIC_GIT_COMMIT || "unknown"; +/** Labels are keys under settings.appearance.theme. */ const THEME_OPTIONS = [ - { value: "light" as const, icon: Sun, label: "Light" }, - { value: "dark" as const, icon: Moon, label: "Dark" }, - { value: "system" as const, icon: Monitor, label: "System" }, + { value: "light" as const, icon: Sun, label: "light" }, + { value: "dark" as const, icon: Moon, label: "dark" }, + { value: "system" as const, icon: Monitor, label: "system" }, ]; function VersionBadge() { + const t = useTranslations("login.version"); const [copied, setCopied] = useState(false); + const [open, setOpen] = useState(false); + const rootRef = useRef(null); + const triggerRef = useRef(null); + const detailsId = useId(); const banner = useUpdateStore(useShallow(selectBanner)); const startPolling = useUpdateStore((s) => s.startPolling); useEffect(() => { startPolling(); }, [startPolling]); + // Opened by click or keyboard (hover alone left keyboard and touch users + // without it): closed again by Escape or a click elsewhere. + useEffect(() => { + if (!open) return; + const onPointerDown = (e: MouseEvent) => { + if (!rootRef.current?.contains(e.target as Node)) setOpen(false); + }; + const onKeyDown = (e: KeyboardEvent) => { + if (e.key !== "Escape") return; + setOpen(false); + triggerRef.current?.focus(); + }; + document.addEventListener("mousedown", onPointerDown); + document.addEventListener("keydown", onKeyDown); + return () => { + document.removeEventListener("mousedown", onPointerDown); + document.removeEventListener("keydown", onKeyDown); + }; + }, [open]); + + // Stays English: it is pasted into bug reports. const versionInfo = `Version: ${APP_VERSION}\nBuild: ${GIT_COMMIT}${banner?.latest ? `\nLatest: ${banner.latest}` : ""}`; const handleCopy = () => { @@ -83,10 +111,10 @@ function VersionBadge() { const triggerText = !banner ? `v${APP_VERSION}` : banner.severity === "security" - ? "Security update available" + ? t("security_update") : banner.severity === "deprecated" - ? "Version no longer supported" - : "New version available"; + ? t("unsupported") + : t("new_version"); const triggerColor = !banner ? "text-muted-foreground/40" @@ -95,41 +123,52 @@ function VersionBadge() { : "text-amber-600/80 dark:text-amber-400/80 hover:text-amber-600 dark:hover:text-amber-400"; const triggerClass = cn( - "peer text-center text-xs transition-colors", + "peer text-center text-xs transition-colors rounded-sm underline-offset-2 hover:underline", + "focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring", triggerColor, - banner?.url ? "cursor-pointer underline-offset-2 hover:underline" : "cursor-default", - ); - - const trigger = banner?.url ? ( - - {triggerText} - - ) : ( -

{triggerText}

); return ( -
- {trigger} -
+
+ +
-

Version: {APP_VERSION}

-

Build: {GIT_COMMIT}

+

{t("version_line", { value: APP_VERSION })}

+

{t("build_line", { value: GIT_COMMIT })}

{banner?.latest && ( -

Latest: {banner.latest}

+

{t("latest_line", { value: banner.latest })}

)} {banner?.advisory && ( -

{banner.advisory}

+

{banner.advisory}

+ )} + {banner?.url && ( + + {t("release_notes")} + )}
- + + {copied ? : } +
@@ -139,6 +178,7 @@ function VersionBadge() { function LoginPageContent() { const router = useRouter(); const t = useTranslations("login"); + const tTheme = useTranslations("settings.appearance.theme"); const params = useParams(); const searchParams = useSearchParams(); const isAddAccountMode = searchParams.get("mode") === "add-account"; @@ -977,7 +1017,7 @@ function LoginPageContent() { return (
{/* Theme toggle, hidden while the theme policy locks the mode (#1280) */} - - - {/* Content */} -
{/* Create form */} @@ -368,22 +349,21 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) { {app.openMode === 'inline' ? t('inline_badge') : t('tab_badge')}
- - + +
); @@ -397,10 +377,9 @@ export function SidebarAppsModal({ isOpen, onClose }: SidebarAppsModalProps) {
)}
-
- + - + ); } diff --git a/components/layout/sidebar.tsx b/components/layout/sidebar.tsx index d9019376e..f8d48490d 100644 --- a/components/layout/sidebar.tsx +++ b/components/layout/sidebar.tsx @@ -222,6 +222,7 @@ function SidebarRowCounts({ isSelected: boolean; onUnreadClick?: () => void; }) { + const tShell = useTranslations('shell'); const showFolderTotalCount = useSettingsStore(s => s.showFolderTotalCount); const mountainView = useSettingsStore(s => s.interfaceLayout === 'mountain-view'); // Per-folder override from the folder settings (#982). @@ -272,7 +273,7 @@ function SidebarRowCounts({ } }} className={cn(unreadClass, "cursor-pointer hover:underline")} - title={`${unreadCount} unread`} + title={tShell("folder_unread", { count: unreadCount })} > {unreadCount} @@ -284,7 +285,7 @@ function SidebarRowCounts({ return ( 0 ? `${unreadCount} unread / ${totalCount} total` : `${unreadCount} unread`} + title={totalCount > 0 ? tShell("folder_counts", { unread: unreadCount, total: totalCount }) : tShell("folder_unread", { count: unreadCount })} data-testid="folder-counts" data-unread={unreadCount} data-total={totalCount} @@ -894,7 +895,7 @@ function VacationBanner() { return ( - + + {sheet && ( - + + + )} {entries.length === 0 ? ( @@ -153,6 +141,11 @@ export function NotificationPanel({ onClose, className, sheet = false }: { onClo ); } +/** The panel's enabled controls, in tab order. */ +function panelControls(panel: HTMLElement): HTMLElement[] { + return Array.from(panel.querySelectorAll("button:not(:disabled), a[href]")); +} + /** The phone version: the panel over the whole screen. */ export function NotificationSheet({ onClose }: { onClose: () => void }) { const t = useTranslations("notification_center"); @@ -205,6 +198,32 @@ export function NotificationBell({ placement, className }: { placement: "side" | if (open) position(); }, [open, position]); + // The panel is portalled to the end of , so Tab from the bell would + // never reach it: focus moves in on open, to its first control (or the + // panel itself while every control is disabled). + useEffect(() => { + if (!open) return; + const panel = panelRef.current; + if (!panel) return; + (panelControls(panel)[0] ?? panel).focus(); + }, [open]); + + // Tab past either end of the panel closes it and goes back to the bell, so + // the next Tab carries on from where the panel was opened. + const onPanelKeyDown = (e: React.KeyboardEvent) => { + if (e.key !== "Tab" || !panelRef.current) return; + const controls = panelControls(panelRef.current); + const first = controls[0]; + const last = controls[controls.length - 1]; + const active = document.activeElement; + const leaving = controls.length === 0 + || (e.shiftKey ? active === first || active === panelRef.current : active === last); + if (!leaving) return; + e.preventDefault(); + setOpen(false); + buttonRef.current?.focus(); + }; + useEffect(() => { if (!open) return; const onPointerDown = (e: MouseEvent) => { @@ -264,6 +283,8 @@ export function NotificationBell({ placement, className }: { placement: "side" | ref={panelRef} role="dialog" aria-label={t("title")} + tabIndex={-1} + onKeyDown={onPanelKeyDown} style={style} className="z-50 w-[22rem] max-w-[calc(100vw-1rem)] max-h-[min(32rem,80vh)] flex flex-col overflow-hidden rounded-lg border border-border bg-background shadow-lg" > diff --git a/components/plugins/__tests__/plugin-actions.test.tsx b/components/plugins/__tests__/plugin-actions.test.tsx index 5ddb87ba4..6c25aec0a 100644 --- a/components/plugins/__tests__/plugin-actions.test.tsx +++ b/components/plugins/__tests__/plugin-actions.test.tsx @@ -66,3 +66,12 @@ describe('PluginActions', () => { expect(screen.getByRole('menuitem', { name: /Labels/ })).toHaveTextContent('99+'); }); }); + +describe('PluginActions overflow button', () => { + it('uses the vertical "more" icon, as every other overflow menu does (R2)', () => { + act(() => setPluginActions('tasks', 'Email to task', 'message-bulk-actions', [{ id: 'make', label: 'Make tasks' }])); + render(); + const more = screen.getByRole('button', { name: 'more_actions' }); + expect(more.querySelector('.tabler-icon-dots-vertical')).not.toBeNull(); + }); +}); diff --git a/components/plugins/plugin-actions.tsx b/components/plugins/plugin-actions.tsx index 7af509c2c..f0bd6b8e3 100644 --- a/components/plugins/plugin-actions.tsx +++ b/components/plugins/plugin-actions.tsx @@ -2,7 +2,7 @@ import { useSyncExternalStore, useState } from 'react'; import { useTranslations } from 'next-intl'; -import { iconForName, MoreHorizontal, Puzzle } from '@/components/icons'; +import { iconForName, MoreVertical, Puzzle } from '@/components/icons'; import { ContextMenu, ContextMenuItem } from '@/components/ui/context-menu'; import { useContextMenu } from '@/hooks/use-context-menu'; import { cn } from '@/lib/utils'; @@ -139,7 +139,7 @@ function PluginActionsOverflow({ surface, context, mailboxRole, mimeType, classN 'hover:bg-muted hover:text-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring', )} > - + { e.preventDefault(); void submit(); }}>
-

{t('title', { label: spec.label })}

+

{t('title', { label: spec.label })}

{intro}

diff --git a/components/plugins/plugin-consent-dialog.tsx b/components/plugins/plugin-consent-dialog.tsx index 8699f05f4..4fd931004 100644 --- a/components/plugins/plugin-consent-dialog.tsx +++ b/components/plugins/plugin-consent-dialog.tsx @@ -38,7 +38,7 @@ export function PluginConsentDialog(): React.JSX.Element | null { className="flex max-h-[90vh] w-full max-w-lg flex-col rounded-lg border border-border bg-background shadow-xl" >
-

+

{t('title', { name: current.pluginName })}

{t('intro')}

diff --git a/components/plugins/plugin-dialog-host.tsx b/components/plugins/plugin-dialog-host.tsx index e3bb43df2..a98ff0138 100644 --- a/components/plugins/plugin-dialog-host.tsx +++ b/components/plugins/plugin-dialog-host.tsx @@ -19,7 +19,7 @@ import { usePluginStore } from '@/stores/plugin-store'; import { toast } from '@/stores/toast-store'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { iconForName, Loader2, MoreHorizontal, Power, Puzzle, X } from '@/components/icons'; +import { iconForName, Loader2, MoreVertical, Power, Puzzle, X } from '@/components/icons'; import { cn } from '@/lib/utils'; import { PluginIframeSlot } from './plugin-iframe-slot'; import type { SlotName } from '@/lib/plugin-types'; @@ -339,7 +339,7 @@ function PluginDialogMenu({ name, onTurnOff }: { name: string; onTurnOff: () => title={t('plugin_menu', { name })} className={cn('p-1.5 rounded-md hover:bg-muted transition-colors duration-150 text-muted-foreground hover:text-foreground', open && 'bg-muted text-foreground')} > - + {open && (
diff --git a/components/plugins/plugin-page-app.tsx b/components/plugins/plugin-page-app.tsx index 8a0f76939..d6f3ad189 100644 --- a/components/plugins/plugin-page-app.tsx +++ b/components/plugins/plugin-page-app.tsx @@ -8,6 +8,7 @@ import { useAuthStore, redirectToLogin, saveRedirectAfterLogin } from "@/stores/ import { useEmailStore } from "@/stores/email-store"; import { usePluginStore } from "@/stores/plugin-store"; import { NavigationRail } from "@/components/layout/navigation-rail"; +import { PhoneAppHeader } from "@/components/layout/phone-header-actions"; import { SidebarAppsModal } from "@/components/layout/sidebar-apps-modal"; import { InlineAppView } from "@/components/layout/inline-app-view"; import { MvTopBar } from "@/components/layout/mv-top-bar"; @@ -144,6 +145,7 @@ export function PluginPageApp({ linkSegments = [] }: PluginPageAppProps) {
)}
+ {!isEmbedded && } {inlineApp && }
{content} diff --git a/components/plugins/plugin-sidebar-sections.tsx b/components/plugins/plugin-sidebar-sections.tsx index bb77c2287..65f287da6 100644 --- a/components/plugins/plugin-sidebar-sections.tsx +++ b/components/plugins/plugin-sidebar-sections.tsx @@ -52,7 +52,7 @@ function PluginSidebarSection({ pluginId, extraProps }: { pluginId: string; extr className="group flex flex-1 min-w-0 items-center select-none rounded-sm hover:bg-muted/40 transition-colors" > {collapsed ? ( - + ) : ( )} diff --git a/components/push-notification-prompt.tsx b/components/push-notification-prompt.tsx index da584f836..279145cca 100644 --- a/components/push-notification-prompt.tsx +++ b/components/push-notification-prompt.tsx @@ -3,7 +3,10 @@ import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react"; import { usePathname } from "next/navigation"; import { useTranslations } from "next-intl"; -import { Bell, Loader2, X } from "@/components/icons"; +import { Bell, X } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; +import { Spinner } from "@/components/ui/spinner"; import { useAccountStore } from "@/stores/account-store"; import { useAuthStore } from "@/stores/auth-store"; import { usePolicyStore } from "@/stores/policy-store"; @@ -327,28 +330,22 @@ export function PushNotificationPrompt() { if (!showPrompt) return null; return ( -
+
- +
-

+

{tPush("title")}

-

+

{tPush("description")}

- + + +
{error ? ( @@ -357,32 +354,17 @@ export function PushNotificationPrompt() {

) : null}
- - + +
- +
); diff --git a/components/pwa-install-prompt.tsx b/components/pwa-install-prompt.tsx index 20195bdaf..61545ae85 100644 --- a/components/pwa-install-prompt.tsx +++ b/components/pwa-install-prompt.tsx @@ -3,6 +3,8 @@ import { useEffect, useState } from "react"; import { useTranslations } from "next-intl"; import { X, Download } from "@/components/icons"; +import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; import { useConfig } from "@/hooks/use-config"; import { withBasePath } from "@/lib/browser-navigation"; @@ -88,7 +90,7 @@ export function PWAInstallPrompt() { const darkLogoSrc = withBasePath(appLogoDarkUrl || faviconUrl); return ( -
+
{logoSrc ? ( @@ -98,7 +100,7 @@ export function PWAInstallPrompt() { className="w-8 h-8 shrink-0 object-contain dark:hidden" /> ) : ( - + )} {logoSrc && ( )}
-

+

{t("title", { appName })}

-

+

{t("description")}

- + + +
- - + +
- +
); diff --git a/components/settings/settings-app.tsx b/components/settings/settings-app.tsx index 9de63f354..acdb7a001 100644 --- a/components/settings/settings-app.tsx +++ b/components/settings/settings-app.tsx @@ -63,6 +63,7 @@ import { useSettingsStore } from '@/stores/settings-store'; import { useManagedAccountStore } from '@/stores/managed-account-store'; import { useIsDesktop } from '@/hooks/use-media-query'; import { NavigationRail } from '@/components/layout/navigation-rail'; +import { PhoneHeaderActions } from '@/components/layout/phone-header-actions'; import { SidebarAppsModal } from '@/components/layout/sidebar-apps-modal'; import { InlineAppView } from '@/components/layout/inline-app-view'; import { useSidebarApps } from '@/hooks/use-sidebar-apps'; @@ -844,6 +845,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =

{activeTabLabel}

+
@@ -880,6 +882,7 @@ export function SettingsApp({ linkSegments: routeSegments }: SettingsAppProps =

{t('title')}

+
diff --git a/components/totp-reauth-dialog.tsx b/components/totp-reauth-dialog.tsx index 51a3958dc..7b4d45e1b 100644 --- a/components/totp-reauth-dialog.tsx +++ b/components/totp-reauth-dialog.tsx @@ -1,107 +1,79 @@ "use client"; import { useState, useEffect, useRef } from "react"; -import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { useTranslations } from "next-intl"; import { useTotpReauthStore } from "@/stores/totp-reauth-store"; import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; +import { Dialog } from "@/components/ui/dialog"; import { Shield } from "@/components/icons"; /** - * Modal dialog that prompts the user for a fresh TOTP code when their - * 2FA session expires (TOTP rotates every ~30 seconds). + * Asks for a fresh TOTP code when a 2FA session expires (TOTP rotates every + * ~30 seconds). * - * Rendered once at the app root level. The JMAP client triggers it via - * the useTotpReauthStore when a 401 is received on a TOTP-authenticated session. + * Mounted once in the locale layout, so the prompt opens in whichever app + * made the request. The JMAP client opens it through useTotpReauthStore + * when a 401 comes back on a TOTP-authenticated session. */ export function TotpReauthDialog() { + const t = useTranslations("totp_reauth"); + const tCommon = useTranslations("common"); const { isOpen, submit, cancel } = useTotpReauthStore(); const [code, setCode] = useState(""); const inputRef = useRef(null); - const dialogRef = useFocusTrap({ - isActive: isOpen, - onEscape: cancel, - restoreFocus: true, - }); - useEffect(() => { - if (isOpen) { - setCode(""); - setTimeout(() => inputRef.current?.focus(), 50); - } + if (isOpen) setCode(""); }, [isOpen]); - if (!isOpen) return null; - const handleSubmit = (e: React.FormEvent) => { e.preventDefault(); - if (code.length >= 6) { - submit(code); - } + if (code.length >= 6) submit(code); }; return ( -
-
-
-
-
- -
-
-

Session Expired

-

Your 2FA code has rotated

-
+ +
- -

- Enter a fresh authentication code from your authenticator app to continue. -

- -

- To avoid being prompted repeatedly, ask your administrator to enable OAuth authentication - (either Stalwart's built-in OAuth or an external identity provider). -

- -
- setCode(e.target.value.replace(/\D/g, ''))} - className="h-12 text-center font-mono tracking-widest text-lg bg-muted/40 border-border/60 rounded-xl focus:bg-background focus:border-primary/50 transition-all duration-200" - placeholder="000000" - autoComplete="one-time-code" - aria-label="Authentication code" - /> -
- - -
-
-
-
+ } + size="sm" + showClose={false} + dirty={code.length > 0} + initialFocus={inputRef} + onSubmit={handleSubmit} + footer={ + <> + + + + } + > +

+ {t("oauth_hint")} +

+ setCode(e.target.value.replace(/\D/g, ""))} + className="h-12 text-center font-mono tracking-widest text-lg" + placeholder="000000" + autoComplete="one-time-code" + aria-label={t("code_label")} + /> + ); } diff --git a/components/tour/__tests__/tour.test.tsx b/components/tour/__tests__/tour.test.tsx new file mode 100644 index 000000000..6c0b1de38 --- /dev/null +++ b/components/tour/__tests__/tour.test.tsx @@ -0,0 +1,135 @@ +import { act, fireEvent, render, screen } from '@testing-library/react'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +// The tour on a phone, where the rail's anchors are not rendered, and its +// keyboard handling. + +const tour = vi.hoisted(() => ({ + currentStep: 0, + nextStep: vi.fn(), + prevStep: vi.fn(), + stopTour: vi.fn(), +})); + +vi.mock('../tour-provider', () => ({ + useTour: () => ({ + currentStep: tour.currentStep, + totalSteps: 3, + steps: [ + { id: 'one', target: '[data-tour="one"]', titleKey: 'one', descriptionKey: 'one_desc', placement: 'right' }, + { id: 'two', target: '[data-tour="two"]', titleKey: 'two', descriptionKey: 'two_desc', placement: 'right' }, + { id: 'three', target: '[data-tour="three"]', titleKey: 'three', descriptionKey: 'three_desc', placement: 'right' }, + ], + nextStep: tour.nextStep, + prevStep: tour.prevStep, + stopTour: tour.stopTour, + }), +})); + +const { TourOverlay, findTourTarget } = await import('../tour-overlay'); +const { getTourSteps } = await import('../tour-steps'); + +/** jsdom lays nothing out: give the elements marked `data-size` a box. */ +const realRect = HTMLElement.prototype.getBoundingClientRect; +beforeEach(() => { + vi.useFakeTimers(); + tour.currentStep = 0; + tour.nextStep.mockClear(); + tour.prevStep.mockClear(); + tour.stopTour.mockClear(); + HTMLElement.prototype.getBoundingClientRect = function () { + const sized = this.hasAttribute('data-size'); + return { top: 10, left: 10, width: sized ? 40 : 0, height: sized ? 40 : 0, right: 50, bottom: 50, x: 10, y: 10, toJSON: () => ({}) } as DOMRect; + }; + Element.prototype.scrollIntoView = vi.fn(); +}); +afterEach(() => { + vi.useRealTimers(); + HTMLElement.prototype.getBoundingClientRect = realRect; + hosts.splice(0).forEach((h) => h.remove()); + document.documentElement.removeAttribute('dir'); +}); + +const hosts: HTMLElement[] = []; +function anchors(html: string) { + const host = document.createElement('div'); + hosts.push(host); + host.innerHTML = html; + document.body.appendChild(host); +} + +describe('tour targets', () => { + it('points at the copy of an anchor that is on screen', () => { + anchors(''); + expect(findTourTarget('[data-tour="one"]')?.id).toBe('tabbar'); + }); + + it('ignores a copy in a closed drawer', () => { + anchors('
'); + expect(findTourTarget('[data-tour="one"]')).toBeNull(); + }); + + it('skips a step whose target is in the page but not shown, instead of leaving the user behind the overlay', () => { + anchors(''); + render(); + act(() => { vi.advanceTimersByTime(1100); }); + expect(tour.nextStep).toHaveBeenCalledTimes(1); + }); + + it('shows a step whose target is on screen', () => { + anchors(''); + render(); + act(() => { vi.advanceTimersByTime(1100); }); + expect(tour.nextStep).not.toHaveBeenCalled(); + expect(screen.getByRole('dialog', { name: 'one' })).toBeInTheDocument(); + }); + + it('leaves out the shortcuts step on a touch screen and contacts when they are off', () => { + const ids = (o: Partial[0]>) => + getTourSteps({ isDemoMode: false, supportsCalendar: true, supportsWebDAV: true, ...o }).map((s) => s.id); + expect(ids({})).toContain('shortcuts'); + expect(ids({ touch: true })).not.toContain('shortcuts'); + expect(ids({})).toContain('nav-contacts'); + expect(ids({ supportsContacts: false })).not.toContain('nav-contacts'); + }); +}); + +describe('tour keyboard', () => { + function showStep() { + anchors(''); + render(); + act(() => { vi.advanceTimersByTime(400); }); + } + + it('lets Enter press the focused Skip button instead of going to the next step', () => { + showStep(); + const skip = screen.getByRole('button', { name: 'tour.skip' }); + skip.focus(); + act(() => { fireEvent.keyDown(skip, { key: 'Enter' }); }); + expect(tour.nextStep).not.toHaveBeenCalled(); + }); + + it('still goes on with Enter when no button has the focus', () => { + showStep(); + act(() => { fireEvent.keyDown(document.body, { key: 'Enter' }); }); + expect(tour.nextStep).toHaveBeenCalledTimes(1); + }); + + it('leaves Enter and the arrows to a field being typed in', () => { + showStep(); + const field = document.createElement('input'); + hosts[0].appendChild(field); + act(() => { fireEvent.keyDown(field, { key: 'Enter' }); }); + act(() => { fireEvent.keyDown(field, { key: 'ArrowRight' }); }); + expect(tour.nextStep).not.toHaveBeenCalled(); + }); + + it('mirrors the arrows in right-to-left languages', () => { + document.documentElement.setAttribute('dir', 'rtl'); + showStep(); + act(() => { fireEvent.keyDown(document.body, { key: 'ArrowLeft' }); }); + expect(tour.nextStep).toHaveBeenCalledTimes(1); + act(() => { fireEvent.keyDown(document.body, { key: 'ArrowRight' }); }); + expect(tour.prevStep).toHaveBeenCalledTimes(1); + }); +}); diff --git a/components/tour/tour-overlay.tsx b/components/tour/tour-overlay.tsx index 27674fd1f..8751bbc03 100644 --- a/components/tour/tour-overlay.tsx +++ b/components/tour/tour-overlay.tsx @@ -6,6 +6,9 @@ import { useTranslations } from "next-intl"; import { useTour } from "./tour-provider"; import { cn } from "@/lib/utils"; import { useFocusTrap } from "@/hooks/use-focus-trap"; +import { Button } from "@/components/ui/button"; +import { isEditableEventTarget } from "@/lib/keyboard"; +import { isDocumentRTL } from "@/i18n/direction"; interface Rect { top: number; @@ -18,15 +21,35 @@ const PADDING = 8; const TOOLTIP_GAP = 12; const TOOLTIP_MAX_W = 360; +/** + * The first element for the selector that is on screen. The same anchor can + * exist twice (the rail and the phone's tab bar); one with no size is not + * rendered in this layout, and one in a closed drawer (inert, or pushed off + * the side of the screen) can't be seen: neither has anything to point at. + */ +export function findTourTarget(selector: string): Element | null { + for (const el of document.querySelectorAll(selector)) { + if (el.closest("[inert]")) continue; + const r = el.getBoundingClientRect(); + if (r.width === 0 && r.height === 0) continue; + if (r.right <= 0 || r.left >= window.innerWidth) continue; + return el; + } + return null; +} + function getTargetRect(selector: string): Rect | null { - const el = document.querySelector(selector); + const el = findTourTarget(selector); if (!el) return null; const r = el.getBoundingClientRect(); - // Element might exist but be hidden (zero dimensions) - if (r.width === 0 && r.height === 0) return null; return { top: r.top, left: r.left, width: r.width, height: r.height }; } +/** Polls (100 ms apart) before a step whose target is in the DOM but has no size is skipped. */ +const HIDDEN_TARGET_ATTEMPTS = 10; +/** Polls before a step whose target never appeared (another page loading) is skipped. */ +const MISSING_TARGET_ATTEMPTS = 50; + function computeTooltipPosition( target: Rect, placement: "top" | "bottom" | "left" | "right", @@ -90,7 +113,13 @@ export function TourOverlay() { // Use refs for callbacks to avoid stale closures in timers/intervals const updatePositionRef = useRef<() => void>(() => {}); const nextStepRef = useRef<() => void>(() => {}); + const prevStepRef = useRef<() => void>(() => {}); + // A step with nothing to point at is skipped in the direction the user went. + const directionRef = useRef<1 | -1>(1); + const goNext = useCallback(() => { directionRef.current = 1; nextStep(); }, [nextStep]); + const goPrev = useCallback(() => { directionRef.current = -1; prevStep(); }, [prevStep]); nextStepRef.current = nextStep; + prevStepRef.current = prevStep; const focusTrapRef = useFocusTrap({ isActive: visible, @@ -123,7 +152,6 @@ export function TourOverlay() { // Wait for target element to appear, then show useEffect(() => { if (!step) return; - console.log(`[Tour] Step ${currentStep + 1}/${totalSteps}: "${step.id}" - target: ${step.target}, placement: ${step.placement}, interactive: ${!!step.interactive}`); setVisible(false); // Keep old targetRect and tooltipPos so the cutout/tooltip animate to the new position // instead of disappearing and reappearing @@ -140,20 +168,16 @@ export function TourOverlay() { } let attempts = 0; - const maxAttempts = 50; // 5 seconds let cancelled = false; const tryFind = () => { if (cancelled) return true; - const el = document.querySelector(step.target); + const el = findTourTarget(step.target); if (el) { - const rect = el.getBoundingClientRect(); - console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element FOUND (${rect.width}x${rect.height} at ${Math.round(rect.left)},${Math.round(rect.top)})`); el.scrollIntoView({ behavior: "smooth", block: "nearest" }); // Delay after scroll for layout to settle pendingTimerRef.current = setTimeout(() => { if (cancelled) return; - console.log(`[Tour] Step ${currentStep + 1} "${step.id}": showing tooltip`); updatePositionRef.current(); setVisible(true); // Second position update after tooltip renders with final dimensions @@ -163,24 +187,26 @@ export function TourOverlay() { }, 200); return true; } - if (attempts % 10 === 0) { - console.log(`[Tour] Step ${currentStep + 1} "${step.id}": element NOT found (attempt ${attempts + 1}/${maxAttempts})`); - } return false; }; if (tryFind()) return () => { cancelled = true; }; - // Poll for element appearance (for page navigation) + // Poll for the element to appear (another page loading). One that is in + // the DOM but not rendered in this layout (the rail on a phone) is given + // up on sooner. const interval = setInterval(() => { attempts++; - if (tryFind() || attempts >= maxAttempts) { + if (tryFind()) { clearInterval(interval); - if (attempts >= maxAttempts && !cancelled) { - // Skip this step if element never appears - console.warn(`[Tour] Step ${currentStep + 1} "${step.id}": SKIPPED - element never appeared after ${maxAttempts} attempts`); - nextStepRef.current(); - } + return; + } + const limit = document.querySelector(step.target) ? HIDDEN_TARGET_ATTEMPTS : MISSING_TARGET_ATTEMPTS; + if (attempts >= limit) { + clearInterval(interval); + if (cancelled) return; + if (directionRef.current === -1 && currentStep > 0) prevStepRef.current(); + else nextStepRef.current(); } }, 100); @@ -192,7 +218,7 @@ export function TourOverlay() { pendingTimerRef.current = null; } }; - }, [step, currentStep]); // eslint-disable-line react-hooks/exhaustive-deps + }, [step, currentStep]); // Recalculate on resize/scroll (debounced) useEffect(() => { @@ -213,17 +239,23 @@ export function TourOverlay() { }; }, [visible, updatePosition]); - // Keyboard navigation + // Keyboard navigation. Enter on a focused button (Skip, Back) and keys + // typed into a field (the search step) are theirs, not the tour's. useEffect(() => { const handler = (e: KeyboardEvent) => { - if (e.key === "ArrowRight" || e.key === "Enter") { + const onControl = e.target instanceof HTMLElement && !!e.target.closest("button, a[href]"); + const typing = isEditableEventTarget(e); + // The arrow pointing the way you read goes on: → in LTR, ← in RTL. + const forward = isDocumentRTL() ? "ArrowLeft" : "ArrowRight"; + const back = isDocumentRTL() ? "ArrowRight" : "ArrowLeft"; + if ((e.key === forward && !typing) || (e.key === "Enter" && !onControl && !typing)) { e.preventDefault(); e.stopPropagation(); - nextStep(); - } else if (e.key === "ArrowLeft") { + goNext(); + } else if (e.key === back && !typing) { e.preventDefault(); e.stopPropagation(); - prevStep(); + goPrev(); } else if (e.key === "Escape") { e.preventDefault(); e.stopPropagation(); @@ -232,7 +264,7 @@ export function TourOverlay() { }; window.addEventListener("keydown", handler, true); return () => window.removeEventListener("keydown", handler, true); - }, [nextStep, prevStep, stopTour]); + }, [goNext, goPrev, stopTour]); // Re-position after tooltip content renders with new dimensions useEffect(() => { @@ -365,31 +397,16 @@ export function TourOverlay() { {/* Navigation buttons */}
- +
- - + +
diff --git a/components/tour/tour-provider.tsx b/components/tour/tour-provider.tsx index cce7e4653..9d241e1b5 100644 --- a/components/tour/tour-provider.tsx +++ b/components/tour/tour-provider.tsx @@ -9,6 +9,7 @@ import { useWebDAVStore } from "@/stores/webdav-store"; import { useSettingsStore } from "@/stores/settings-store"; import { usePolicyStore } from "@/stores/policy-store"; import { getTourSteps, type TourStep } from "./tour-steps"; +import { useMediaQuery } from "@/hooks/use-media-query"; import { TourOverlay } from "./tour-overlay"; const TOUR_COMPLETED_KEY = "tour_completed"; @@ -50,7 +51,17 @@ export function TourProvider({ children }: { children: ReactNode }) { const [currentStep, setCurrentStep] = useState(0); const [hasCompletedTour, setHasCompletedTour] = useState(false); - const steps = getTourSteps({ isDemoMode, supportsCalendar: supportsCalendar && calendarEnabled, supportsWebDAV: supportsWebDAV !== false }); + const client = useAuthStore((s) => s.client); + const contactsEnabled = usePolicyStore((s) => s.isFeatureEnabled('contactsEnabled')); + const touch = useMediaQuery("(pointer: coarse)"); + + const steps = getTourSteps({ + isDemoMode, + supportsCalendar: supportsCalendar && calendarEnabled, + supportsWebDAV: supportsWebDAV !== false, + supportsContacts: (client?.supportsContacts?.() ?? false) && contactsEnabled, + touch, + }); useEffect(() => { // One-time migration: if the legacy per-device flag is set but the synced diff --git a/components/tour/tour-steps.ts b/components/tour/tour-steps.ts index 9f3fdde1c..4074e32a1 100644 --- a/components/tour/tour-steps.ts +++ b/components/tour/tour-steps.ts @@ -201,12 +201,22 @@ export function getTourSteps(options: { isDemoMode: boolean; supportsCalendar: boolean; supportsWebDAV: boolean; + /** Contacts available and allowed; defaults to true. */ + supportsContacts?: boolean; + /** A touch screen: no keyboard to use the shortcuts with. */ + touch?: boolean; }): TourStep[] { let steps = [...BASE_TOUR_STEPS]; if (!options.supportsCalendar) { steps = steps.filter((s) => s.id !== "nav-calendar"); } + if (options.supportsContacts === false) { + steps = steps.filter((s) => s.id !== "nav-contacts"); + } + if (options.touch) { + steps = steps.filter((s) => s.id !== "shortcuts"); + } if (options.isDemoMode) { const demoSteps = DEMO_TOUR_STEPS.filter((s) => { diff --git a/components/ui/__tests__/welcome-banner.test.tsx b/components/ui/__tests__/welcome-banner.test.tsx index f91c9b069..2ff6b50fb 100644 --- a/components/ui/__tests__/welcome-banner.test.tsx +++ b/components/ui/__tests__/welcome-banner.test.tsx @@ -1,4 +1,4 @@ -import { render, screen } from '@testing-library/react'; +import { fireEvent, render, screen } from '@testing-library/react'; import { beforeEach, describe, expect, it, vi } from 'vitest'; import React from 'react'; @@ -35,3 +35,18 @@ describe('WelcomeBanner tips', () => { expect(screen.queryByText('tip_shortcuts')).toBeNull(); }); }); + +describe('WelcomeBanner and Escape', () => { + beforeEach(() => { + useSettingsStore.setState({ onboardingCompleted: false }); + }); + + it('stays when Escape is pressed elsewhere, and is not marked as seen', () => { + device.isMobile = false; + render(); + fireEvent.keyDown(window, { key: 'Escape' }); + fireEvent.keyDown(document.body, { key: 'Escape' }); + expect(screen.getByRole('complementary')).toBeInTheDocument(); + expect(useSettingsStore.getState().onboardingCompleted).toBe(false); + }); +}); diff --git a/components/ui/button.tsx b/components/ui/button.tsx index 038b9c4d4..accbee32a 100644 --- a/components/ui/button.tsx +++ b/components/ui/button.tsx @@ -27,7 +27,7 @@ const SIZES: Record = { // that big. `relative` gives it something to centre on; a className with // another position (absolute, fixed, sticky) replaces it through // tailwind-merge and still holds the ::after. -const TOUCH_HIT_AREA = +export const TOUCH_HIT_AREA = "relative pointer-coarse:after:absolute pointer-coarse:after:top-1/2 pointer-coarse:after:left-1/2 " + "pointer-coarse:after:size-full pointer-coarse:after:min-h-11 pointer-coarse:after:min-w-11 " + "pointer-coarse:after:-translate-x-1/2 pointer-coarse:after:-translate-y-1/2"; diff --git a/components/ui/toast.tsx b/components/ui/toast.tsx index 79636b10e..d2e1b91af 100644 --- a/components/ui/toast.tsx +++ b/components/ui/toast.tsx @@ -5,6 +5,7 @@ import { createPortal } from "react-dom"; import { useTranslations } from "next-intl"; import { X, CheckCircle, AlertCircle, Info, AlertTriangle, Loader2 } from "@/components/icons"; import { cn } from "@/lib/utils"; +import { TOUCH_HIT_AREA } from "@/components/ui/button"; export type ToastType = "success" | "error" | "info" | "warning"; @@ -180,6 +181,7 @@ export function ToastItem({ toast, onClose }: ToastProps) { }} className={cn( "text-[13px] font-semibold text-foreground px-1.5 py-0.5 rounded-md transition-colors", + TOUCH_HIT_AREA, "underline underline-offset-[3px] hover:bg-foreground/5" )} > @@ -262,6 +264,7 @@ export function ToastItem({ toast, onClose }: ToastProps) { }} className={cn( "flex-shrink-0 p-1 rounded-md transition-all max-sm:hidden", + TOUCH_HIT_AREA, "text-muted-foreground hover:text-foreground hover:bg-foreground/5", "opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 pointer-coarse:opacity-100" )} diff --git a/components/ui/welcome-banner.tsx b/components/ui/welcome-banner.tsx index 736418b37..e45338cf4 100644 --- a/components/ui/welcome-banner.tsx +++ b/components/ui/welcome-banner.tsx @@ -4,6 +4,7 @@ import { useState, useEffect, useCallback } from "react"; import { useTranslations } from "next-intl"; import { X, Lightbulb, Settings, PlayCircle } from "@/components/icons"; import { Button } from "@/components/ui/button"; +import { IconButton } from "@/components/ui/icon-button"; import { useRouter } from "@/i18n/navigation"; import { useTour } from "@/components/tour/tour-provider"; import { useSettingsStore } from "@/stores/settings-store"; @@ -59,14 +60,9 @@ export function WelcomeBanner() { } catch { /* localStorage unavailable */ } }, [updateSetting]); - useEffect(() => { - if (!visible) return; - const handle = (e: KeyboardEvent) => { - if (e.key === "Escape") dismiss(); - }; - window.addEventListener("keydown", handle); - return () => window.removeEventListener("keydown", handle); - }, [visible, dismiss]); + // No Escape handler: Escape closes whatever has the focus (a message, a + // dialog, a selection) and must not hide these tips for good (R8). The X + // and "Got it" dismiss them. if (!visible) return null; @@ -99,38 +95,34 @@ export function WelcomeBanner() {
- + + +
diff --git a/lib/__tests__/de-formal-address.test.ts b/lib/__tests__/de-formal-address.test.ts new file mode 100644 index 000000000..ce3af2f5a --- /dev/null +++ b/lib/__tests__/de-formal-address.test.ts @@ -0,0 +1,27 @@ +import { readFileSync } from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +// German addresses the user formally ("Sie") throughout (R10). Informal +// pronouns and du-imperatives slipped into some strings. + +const INFORMAL = /\b(du|dich|dir|dein|deine|deinen|deinem|deiner|deines)\b|(^|[.!?]\s+|\()(Klicke|Tippe|Wähle|Prüfe|Melde|Lege|Entferne|Frag|Öffne|drücke)\b/i; + +function strings(node: unknown, path: string[] = []): Array<[string, string]> { + if (typeof node === 'string') return [[path.join('.'), node]]; + if (node && typeof node === 'object') { + return Object.entries(node).flatMap(([key, value]) => strings(value, [...path, key])); + } + return []; +} + +describe('German translations', () => { + it('use the formal "Sie" everywhere', () => { + const de = JSON.parse(readFileSync('locales/de/common.json', 'utf8')); + const informal = strings(de) + // Words the composer looks for in "[Dein Name]"-style fill-ins, not UI text. + .filter(([key]) => !key.includes('placeholder_words')) + .filter(([, text]) => INFORMAL.test(text)) + .map(([key]) => key); + expect(informal).toEqual([]); + }); +}); diff --git a/lib/__tests__/lite-scripts.test.ts b/lib/__tests__/lite-scripts.test.ts index e879ebb6f..7eb8bf3e2 100644 --- a/lib/__tests__/lite-scripts.test.ts +++ b/lib/__tests__/lite-scripts.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from 'vitest'; +import { describe, expect, it, vi } from 'vitest'; import { createHash } from 'node:crypto'; import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync, existsSync } from 'node:fs'; import { tmpdir } from 'node:os'; @@ -208,6 +208,28 @@ describe('lite build helpers', () => { expect(LITE_PENDING_PATH_KEY).toBe(APP_LITE_PENDING_PATH_KEY); }); + it('sends a path without a locale to the same path in the reader locale', () => { + const html = buildNotFoundShim({ basePath: '/webmail', locales: ['de', 'en', 'zh-TW'], defaultLocale: 'en' }); + const script = html.match(/ diff --git a/scripts/lite/postbuild.mjs b/scripts/lite/postbuild.mjs index 744b95726..dbe423c25 100644 --- a/scripts/lite/postbuild.mjs +++ b/scripts/lite/postbuild.mjs @@ -78,7 +78,7 @@ function staticFiles({ config, basePath, locales, defaultLocale, version, commit "index.html": buildRootRedirect({ basePath, locales, defaultLocale }), // Replaces Next's default not-found page: only this shim replays deep links // on hosts without rewrite rules (see buildNotFoundShim). - "404.html": buildNotFoundShim({ basePath, locales }), + "404.html": buildNotFoundShim({ basePath, locales, defaultLocale }), "_redirects": buildRedirects({ basePath, locales }), "_headers": buildHeaders({ basePath, connectSrc }), "nginx.conf.example": buildNginxExample({ basePath, connectSrc }),