Files
jmap-webmail/docs/UI_UX_PATTERNS.md
T
Matthieu MALVACHE 40c7fa3649 feat: add UI/UX polish, templates settings, confirm dialog, welcome banner, and navigation rail
- Add ConfirmDialog component with promise-based useConfirmDialog hook
- Add WelcomeBanner onboarding component with localStorage persistence
- Add NavigationRail (desktop icon rail + mobile bottom tab bar)
- Polish login form (shake on error, TOTP slide, password visibility, session expired banner)
- Add inline form validation with shake animation in email composer and contacts
- Add empty state patterns for contacts (no data vs no search results)
- Improve toast system with undo action support and typed durations
- Add template settings tab to settings page
- Refactor sidebar (cleaner code, remove unused imports)
- Add WCAG AA reduced-motion, safe area insets, sr-only live region
- Add shake/slide animations in globals.css
- Update i18n for all 8 locales
2026-02-17 02:30:09 +01:00

29 KiB

UI/UX Patterns Reference

This document catalogs the UI/UX patterns implemented in this codebase. Every pattern references actual files and code so that new components remain consistent with established conventions.


Table of Contents

  1. Component Patterns
  2. Interaction Patterns
  3. Visual Patterns
  4. Mobile Patterns
  5. Accessibility Patterns
  6. i18n Patterns
  7. Navigation Patterns

1. Component Patterns

ConfirmDialog (Promise-based)

The confirm dialog is driven by a hook that returns a Promise, allowing callers to await a user decision before proceeding.

Files:

  • components/ui/confirm-dialog.tsx -- the visual component
  • hooks/use-confirm-dialog.ts -- the hook that manages state and returns a promise

How it works:

// In any component
const { dialogProps, confirm } = useConfirmDialog();

const handleDelete = async () => {
  const confirmed = await confirm({
    title: t("discard_draft_title"),
    message: t("discard_draft_confirm"),
    confirmText: t("discard"),
    variant: "destructive",
  });
  if (confirmed) {
    // proceed with destructive action
  }
};

// Render once at the bottom of the component tree
<ConfirmDialog {...dialogProps} />

The confirm() call opens the dialog and suspends via a Promise. The dialog resolves true when confirmed, false when cancelled or dismissed (backdrop click, Escape key).

Variants:

  • "default" -- neutral confirmation (save, proceed)
  • "destructive" -- red styling with AlertTriangle icon (delete, discard)

DO:

  • Use useConfirmDialog for any destructive or irreversible action.
  • Spread dialogProps directly onto <ConfirmDialog>.
  • Provide translated title and message via useTranslations.

DON'T:

  • Manage dialog open/close state manually; the hook handles it.
  • Use window.confirm() -- it blocks the thread and is not styleable.

Toast Notifications (with undo actions)

Toasts are managed by a global Zustand store and rendered via a container component positioned at the bottom-right corner.

Files:

  • components/ui/toast.tsx -- ToastItem and ToastContainer
  • stores/toast-store.ts -- Zustand store with convenience methods

Usage:

import { toast } from "@/stores/toast-store";

// Simple
toast.success(t("email_moved"));
toast.error(t("move_failed"), t("move_error"));

// With undo action
toast.success(t("email_deleted"), {
  action: {
    label: t("undo"),
    onClick: () => restoreEmail(emailId),
  },
  duration: 5000,
});

Type durations:

  • success, info, warning: 5000ms (default)
  • error: 10000ms (longer visibility for errors)
  • Custom: pass duration in options

Rendering:

The ToastContainer is positioned fixed bottom-4 right-4 z-50 with role="status" and aria-live="polite" so screen readers announce new toasts.

DO:

  • Use toast.error() for failures, toast.success() for confirmations.
  • Provide an undo action for reversible destructive operations.
  • Keep toast titles short (under 60 characters).

DON'T:

  • Stack more than 3-4 toasts at once; the container has max-w-sm and no scroll.
  • Use toasts for information that requires user acknowledgement -- use a banner or dialog instead.

WelcomeBanner (One-time Onboarding)

A dismissible banner shown to first-time users, persisted via localStorage.

File: components/ui/welcome-banner.tsx

Behavior:

  1. On mount, checks localStorage for key "onboarding_completed".
  2. If absent, renders the banner with tips.
  3. On dismiss: sets localStorage flag, animates out with opacity-0 scale-95, then removes from DOM via onTransitionEnd.
  4. Escape key also dismisses.

Key implementation details:

  • Uses role="complementary" and aria-label for accessibility.
  • Transition uses CSS classes, not JS animation, respecting prefers-reduced-motion.
  • Wrapped in a try/catch around localStorage for SSR safety and private browsing.

DO:

  • Use this pattern (localStorage guard + fade-out transition) for any one-time banner.
  • Place onboarding banners after the main search/filter UI but before the content list.

DON'T:

  • Show onboarding banners on every page load; check persistence first.
  • Animate removal with JS timers; use onTransitionEnd for clean DOM removal.

Empty States

Empty states use a centered column layout with a large faded icon, a primary heading, a secondary hint, and an action button.

File: components/contacts/contact-list.tsx (lines 162-197)

Structure:

<div className="flex flex-col items-center justify-center h-full px-6 text-center">
  <BookUser className="w-12 h-12 mb-3 text-muted-foreground/30" />
  <p className="text-sm font-medium text-foreground">{t("empty_state_title")}</p>
  <p className="text-xs text-muted-foreground mt-1">{t("empty_state_subtitle")}</p>
  <Button size="sm" className="mt-4" onClick={onCreateNew}>
    <Plus className="w-4 h-4 mr-1.5" />
    {t("create_new")}
  </Button>
</div>

There are two variants used in the contacts list:

  • No contacts at all: shows icon + title + subtitle + "Create" and "Import" buttons.
  • No search results: shows Search icon + different copy + "Clear search" button.

DO:

  • Always provide an action button that resolves the empty state.
  • Differentiate "no data" from "no results" with distinct copy and icons.
  • Use /30 opacity on the icon to keep it subtle.

DON'T:

  • Leave an empty white area with no guidance.
  • Show the same empty state for "loading" -- use a spinner instead.

NavigationRail (Module Switcher)

A vertical (desktop) or horizontal (mobile bottom tab bar) navigation component for switching between app modules.

File: components/layout/navigation-rail.tsx

Props:

  • orientation: "vertical" (default, desktop sidebar) or "horizontal" (mobile bottom bar)
  • collapsed: When true, shows icon-only mode in vertical orientation

Features:

  • Items are capability-gated (e.g., Calendar only renders if supportsCalendar is true).
  • Active item shows aria-current="page", bg-primary/10, and text-primary.
  • Horizontal mode: active indicator is a small bar under the icon, 10px label text.
  • Badge support: unread inbox count as a red pill (bg-red-500 text-white), capped at "99+".
  • Touch targets: min-w-[64px] min-h-[44px] in horizontal, max-lg:min-h-[44px] in vertical.

DO:

  • Add hidden: true to NavItem for capability-gated features.
  • Use aria-current="page" on active links (not aria-selected).

DON'T:

  • Add more than 5 items to the bottom bar; it becomes cramped on small screens.
  • Hardcode routes; use the labelKey pattern with translations.

2. Interaction Patterns

Touch Targets (44px minimum)

All interactive elements on mobile meet the Apple HIG 44x44px minimum.

File: app/globals.css

.touch-target {
  min-height: 44px;
  min-width: 44px;
}

In components, this is implemented via Tailwind classes:

// Mobile header buttons
<Button className="h-11 w-11" />

// Sidebar mailbox items on mobile
className="max-lg:py-3 max-lg:min-h-[44px]"

// Mobile bottom nav items
className="min-w-[64px] min-h-[44px]"

DO:

  • Use h-11 w-11 (44px) for icon buttons on mobile.
  • Use max-lg:min-h-[44px] for list items that are compact on desktop.

DON'T:

  • Make touch targets smaller than 44px on any device.
  • Rely on padding alone; set explicit min-height/min-width.

Focus Management

Focus traps are implemented via a reusable hook.

File: hooks/use-focus-trap.ts

const modalRef = useFocusTrap({
  isActive: isOpen,
  onEscape: onClose,
  restoreFocus: true,
});

// Attach to modal container
<div ref={modalRef} role="dialog" aria-modal="true">

Behavior:

  1. When active, stores document.activeElement and focuses first focusable child.
  2. Tab/Shift+Tab cycle stays within the container.
  3. Escape fires onEscape callback.
  4. On deactivation, restores focus to the previously focused element.

Used in:

  • ConfirmDialog (alertdialog)
  • KeyboardShortcutsModal (dialog)
  • EmailComposer save-as-template modal
  • EventModal (manual focus trap implementation)

DO:

  • Use useFocusTrap for every modal/dialog/overlay.
  • Set restoreFocus: true so keyboard users return to their previous context.

DON'T:

  • Implement focus trap logic inline; use the shared hook.
  • Forget aria-modal="true" on the container.

Keyboard Shortcuts

Global keyboard shortcuts are managed by a single hook that registers on window.

Files:

  • hooks/use-keyboard-shortcuts.ts -- hook + KEYBOARD_SHORTCUTS definition
  • components/keyboard-shortcuts-modal.tsx -- help modal

Design decisions:

  • Shortcuts are disabled when typing in inputs (isInputFocused() check).
  • Shortcuts are disabled when the composer is open.
  • Modifier keys are only used for Ctrl+A (select all); all other shortcuts are single keys.
  • The handlersRef pattern avoids re-registering the listener on every render.

Shortcut categories:

Category Keys Actions
Navigation j/k, arrows, Enter Next/prev email, open, close
Actions r, R/a, f, s, e, # Reply, reply all, forward, star, archive, delete
Global c, /, ?, Shift+G Compose, search, help, refresh
Threads x Expand/collapse thread
Composer t Open template picker

DO:

  • Register shortcuts in the KEYBOARD_SHORTCUTS const so they appear in the help modal.
  • Use the enabled flag to disable shortcuts during modal states.

DON'T:

  • Add shortcuts that conflict with browser defaults (Ctrl+W, Ctrl+T, etc.).
  • Use multi-key chords beyond simple modifier+key combos.

Form Validation

Two validation styles are used depending on context.

Inline validation (on blur) -- used in contact-form.tsx:

const handleEmailBlur = (index: number, address: string) => {
  if (address.trim() && !validateEmail(address)) {
    setEmailErrors(prev => ({ ...prev, [index]: t("email_error_inline") }));
  } else {
    setEmailErrors(prev => { const next = { ...prev }; delete next[index]; return next; });
  }
};

// Render
<Input className={emailErrors[i] ? "border-red-500 focus:ring-red-500" : ""} />
{emailErrors[i] && (
  <p className="text-xs text-red-600 dark:text-red-400 mt-1">{emailErrors[i]}</p>
)}

Submit-time validation with shake -- used in email-composer.tsx:

if (!canSend) {
  const errors = {};
  if (toAddresses.length === 0) { errors.to = true; first_error_field = "to"; }
  if (!subject) errors.subject = true;
  if (!hasContent) errors.body = true;
  setValidationErrors(errors);
  setShakeField(first_error_field);
  setTimeout(() => setShakeField(null), 400);
  toInputRef.current?.focus();
  return;
}

// Render
<div className={cn("flex items-center gap-2", shakeField === "to" && "animate-shake")}>
  <Input className={cn(validationErrors.to && "ring-2 ring-red-500")} aria-invalid={validationErrors.to} />
  {validationErrors.to && (
    <p className="text-xs text-red-600 dark:text-red-400">{t("validation.recipient_required")}</p>
  )}
</div>

Login form also uses a shake animation on the entire form on auth error:

<form className={shakeError ? "animate-shake" : ""}>

DO:

  • Use inline validation (on blur) for fields where immediate feedback helps (email format).
  • Use submit-time validation with shake + focus for required fields.
  • Always set aria-invalid on invalid inputs.
  • Always show error text below the field, not just a color change.

DON'T:

  • Validate on every keystroke; it is distracting.
  • Use alert() for validation errors.

Confirmation Flows

Three patterns for destructive action confirmation exist in the codebase:

  1. Promise-based dialog (useConfirmDialog) -- for draft discard in composer.
  2. Inline confirmation -- for calendar event deletion in event-modal.tsx (lines 633-668): clicking Delete shows inline "Are you sure?" text with confirm/cancel buttons in the same footer area.
  3. Two-step toast with undo -- for spam marking: the action executes immediately, and a toast with "Undo" appears for 5 seconds.

When to use which:

  • Dialog: when the action is irreversible AND requires user to read context (e.g., "This draft will be permanently deleted").
  • Inline confirmation: when the action is within a form/modal and switching to another modal would be jarring.
  • Toast with undo: when the action is reversible and speed matters (e.g., move to trash, mark as spam).

3. Visual Patterns

CSS Variables (never hardcoded hex)

All colors in components MUST use CSS variables or Tailwind semantic classes.

File: app/globals.css (lines 5-40)

Light mode tokens:

Token Value Usage
--color-background #ffffff Page and card backgrounds
--color-foreground #0f172a Primary text
--color-primary #3b82f6 Interactive elements, links
--color-muted-foreground #64748b Secondary text
--color-accent #dbeafe Selected item backgrounds
--color-accent-foreground #1e40af Selected item text
--color-border #e2e8f0 Borders, dividers
--color-muted #f1f5f9 Hover backgrounds

Settings variables:

Variable Default Purpose
--font-size-base 16px Root font size
--list-item-height 48px Email list item height
--transition-duration 0.2s Animation duration (0 when animations disabled)

DO:

// Tailwind classes (preferred)
className="text-foreground bg-background border-border"
className="text-muted-foreground hover:text-foreground"

// Inline styles when dynamic
style={{ color: "var(--color-muted-foreground)" }}

DON'T:

// Never hardcode hex in components
style={{ color: "#666" }}
className="text-gray-400"  // without dark: variant

WCAG AA Contrast

Minimum contrast ratios enforced:

  • Normal text: 4.5:1 (target 7:1 for AAA)
  • Large text (18pt+): 3:1

Tested combinations (light mode):

Element Color Background Ratio
Primary text #0f172a #ffffff 15.8:1
Muted text #64748b #ffffff 5.7:1
Blockquote/quoted text #4b5563 #ffffff 7.8:1
Primary interactive #3b82f6 #ffffff 4.8:1

Semantic color pairs (always provide both modes):

// Success
className="text-green-600 dark:text-green-400"
// Error
className="text-red-600 dark:text-red-400"
// Warning
className="text-amber-700 dark:text-amber-400"
// Info
className="text-blue-600 dark:text-blue-400"

Opacity limits on muted text:

  • Acceptable: /70, /80, /90, /95
  • Avoid: /50, /60 (too faint for AA compliance)
  • Exception: disabled states may use disabled:opacity-50

Dark Mode

Dark mode is toggled by adding the dark class to the document root.

File: tailwind.config.ts -- darkMode: 'class' File: app/globals.css -- .dark { ... } block with inverted tokens

Email content dark mode: HTML emails with hardcoded dark text colors are transformed using lib/color-transform.ts:

  • Luminance < 0.4: inverted and brightened
  • Luminance 0.4-0.6: lightened by 40-50%
  • Luminance > 0.6: preserved

This is applied during DOMPurify sanitization in email-viewer.tsx.


Loading States

Full-page spinner (auth check, initial load):

<div className="flex h-screen items-center justify-center bg-background">
  <div className="animate-spin rounded-full h-12 w-12 border-b-2 border-foreground" />
  <p className="mt-4 text-sm text-muted-foreground">{t("common.loading")}</p>
</div>

Inline spinner (login button, saving):

<Loader2 className="w-4 h-4 animate-spin" />

Draft save status (email composer):

// Saving
<Save className="w-3 h-3 animate-pulse" /> "Saving..."
// Saved
<Check className="w-3 h-3" /> "Draft saved" (green text, auto-clears after 2s)
// Error
<X className="w-3 h-3" /> "Save failed" (red text, auto-clears after 3s)

Config/server error (login page):

<div className="inline-flex items-center justify-center w-20 h-20 rounded-2xl bg-red-500/10 mb-6">
  <AlertCircle className="w-10 h-10 text-red-500" />
</div>
<h1 className="text-xl font-medium">{t("config_error.title")}</h1>
<p className="text-muted-foreground text-sm">{t("config_error.fetch_failed")}</p>

Notification Banner Hierarchy

The email viewer renders up to three contextual banners in a unified container. They appear in a fixed visual order.

File: components/email/email-viewer.tsx (lines 1321-1382)

Order (top to bottom):

  1. External Content -- gray/muted background (bg-muted/50), offers "Load images" and "Trust sender" buttons.
  2. Unsubscribe -- blue tint (bg-blue-50/50 dark:bg-blue-950/20), from RFC 2369 List-Unsubscribe header.
  3. Calendar Invitation -- amber tint (bg-amber-50/50 dark:bg-amber-950/20), RSVP Accept/Maybe/Decline.

Container:

<div className="border-b border-border bg-muted/30 isolate">
  <div className="max-w-4xl mx-auto px-6 py-1.5">
    <div className="flex flex-col gap-3 isolate">
      {/* banners render here in order */}
    </div>
  </div>
</div>

The outer container only renders if at least one banner is visible. Each banner has a distinct background tint for visual hierarchy.

DO:

  • Add new notification banners inside this unified container, not as separate full-width bars.
  • Follow the color convention: neutral = gray, informational = blue, action-required = amber/yellow, danger = red.
  • Include 44px touch targets for action buttons on mobile.

DON'T:

  • Render banners above the email header area.
  • Use identical background colors for different banner types.

4. Mobile Patterns

Responsive Breakpoints

The codebase uses Tailwind's default breakpoints with a mobile-first approach.

Breakpoint Width Layout
Default < 768px Mobile: single pane, bottom nav
md >= 768px Tablet: two panes, collapsible list
lg >= 1024px Desktop: three panes, rail + sidebar + content

Detection hook: hooks/use-media-query.ts provides useDeviceDetection():

const { isMobile, isTablet } = useDeviceDetection();

View state is managed by stores/ui-store.ts:

  • activeView: "list" | "viewer" (mobile only)
  • sidebarOpen: overlay sidebar on mobile/tablet
  • tabletListVisible: collapse/expand list pane on tablet

Input Modes

Use inputMode for mobile keyboard optimization.

// TOTP code entry
<Input inputMode="numeric" maxLength={6} />

// Email field
<Input type="email" inputMode="email" />

// Phone field
<Input type="tel" inputMode="tel" />

File references:

  • app/[locale]/login/page.tsx -- TOTP input with inputMode="numeric"
  • components/contacts/contact-form.tsx -- email with inputMode="email", phone with inputMode="tel"

Bottom Tab Bar

On mobile, the NavigationRail renders horizontally at the bottom of the viewport.

File: app/[locale]/page.tsx (line 948-950):

{isMobile && activeView !== "viewer" && (
  <NavigationRail orientation="horizontal" />
)}

The bar hides when viewing an email to maximize reading space.

Safe area insets are defined in app/globals.css for notched devices:

.safe-area-inset-bottom {
  padding-bottom: env(safe-area-inset-bottom);
}

Full-Screen Modals on Mobile

The email composer goes full-screen on mobile, windowed on desktop.

File: app/[locale]/page.tsx (lines 954-989):

<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4 lg:p-0">
  <div className={cn(
    "w-full h-full lg:h-[600px] lg:max-w-3xl",
    "max-lg:flex max-lg:flex-col"
  )}>
    <EmailComposer />
  </div>
</div>
  • Mobile (max-lg): w-full h-full (full viewport).
  • Desktop (lg): max-w-3xl h-[600px] (centered card).

Mobile Header

The MobileHeader component provides a consistent top bar with left action (menu/back), title, and right actions.

File: components/layout/mobile-header.tsx

  • Only visible below lg breakpoint (lg:hidden).
  • Back button: ArrowLeft icon.
  • Menu button: Menu icon, toggles to X when sidebar is open.
  • Touch targets: h-11 w-11 (44px) for all icon buttons.
  • aria-expanded on menu button for screen readers.

MobileViewerHeader is a variant for the email reader view with a centered truncated subject line.


5. Accessibility Patterns

ARIA Live Regions

A screen-reader-only live region is placed at the root layout for dynamic status announcements.

File: app/[locale]/page.tsx (line 998):

<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />

The toast container also uses role="status" and aria-live="polite":

<div role="status" aria-live="polite">
  {toasts.map(...)}
</div>

ARIA Roles

Dialogs:

// Confirm dialog (requires acknowledgement)
role="alertdialog" aria-modal="true"
aria-labelledby="confirm-dialog-title"
aria-describedby="confirm-dialog-message"

// Regular dialog
role="dialog" aria-modal="true"
aria-labelledby="shortcuts-dialog-title"

Navigation:

<nav role="navigation" aria-label={t("nav_label")}>
  <Link aria-current={is_active ? "page" : undefined}>

Autocomplete (combobox):

<Input
  role="combobox"
  aria-expanded={results.length > 0}
  aria-autocomplete="list"
  aria-controls="autocomplete-to"
  aria-activedescendant={`autocomplete-option-${index}`}
/>
<div id="autocomplete-to" role="listbox">
  <button role="option" aria-selected={isSelected}>

Banners:

// Welcome/onboarding
role="complementary" aria-label={t("title")}

// Session expired
role="status" aria-live="polite"

Reduced Motion

File: app/globals.css (lines 402-411):

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

This is a global reset. Additionally, the --transition-duration CSS variable can be set to 0 via the user's "Animations" setting toggle, which components read from.


sr-only Announcements

Screen-reader-only text uses Tailwind's sr-only class:

// Loading state spinner
<span className="sr-only">{t("loading")}</span>

// Live region for dynamic updates
<div className="sr-only" aria-live="polite" aria-atomic="true" id="sr-status" />

// Accessible checkbox (visually custom)
<input type="checkbox" className="peer sr-only" />

Focus Traps

Every modal and overlay implements a focus trap. See the Focus Management section for the reusable useFocusTrap hook.

All implementations share these properties:

  • Escape key closes the modal.
  • Focus is restored to the previous element on close.
  • Tab cycles within the modal boundary.

6. i18n Patterns

Namespace Conventions

Translations are grouped by feature area in locales/{locale}/common.json.

Namespace Scope
login.* Login page
sidebar.* Sidebar navigation, search
email_list.* Email list component
email_viewer.* Email viewer, banners
email_composer.* Composer, validation
calendar.* Calendar views, events, form
contacts.* Contact list, form, groups
settings.* All settings tabs
templates.* Email templates
identity.* Identity management
sub_address.* Sub-addressing
shortcuts.* Keyboard shortcuts modal
notifications.* Toast messages
common.* Shared strings (loading, close)
confirm_dialog.* Confirm/cancel button labels
welcome.* Onboarding banner
advanced_search.* Search filter panel

Adding New Keys

  1. Add the key to all 8 locale files: en, fr, ja, es, it, de, nl, pt.
  2. Use hierarchical, descriptive keys: email_viewer.unsubscribe_banner.title.
  3. Use useTranslations("namespace") scoped to the component's domain.
  4. For dynamic content, use ICU message syntax:
// In locale file
"alerts.minutes_before": "{count, plural, one {# minute before} other {# minutes before}}"

// In component
t("alerts.minutes_before", { count: 5 })

ICU Pluralization

Used for calendar alerts and bulk operations. All 8 locales must include the pluralization rules.

Example (English):

"bulk.selected": "{count, plural, one {# contact selected} other {# contacts selected}}"

Example (French):

"bulk.selected": "{count, plural, one {# contact selectionne} other {# contacts selectionnes}}"

Locale-Aware Formatting

Dates and times use next-intl's useFormatter hook, not hardcoded format strings:

const format = useFormatter();
format.dateTime(date, { month: "short", day: "numeric" });

The calendar views use this extensively for month/day names, respecting the user's locale.


7. Navigation Patterns

NavigationRail

The NavigationRail is the primary module-level navigation, rendering as:

  • Desktop: Vertical icon rail (14px wide, w-14) to the left of the sidebar.
  • Mobile: Horizontal bottom tab bar.

File: components/layout/navigation-rail.tsx

Items are defined as an array of NavItem objects with:

  • id, icon, labelKey, href
  • hidden (optional): for capability-gated features
  • badge (optional): unread count for inbox

Active state detection:

const getIsActive = (href: string) => {
  if (href === "/") return pathname === "/" || pathname === "";
  return pathname.startsWith(href);
};

This handles both exact match for root (/) and prefix match for sub-routes (/calendar, /contacts, etc.).


Module Switching

Each module (Mail, Calendar, Contacts, Settings) is a separate page route under app/[locale]/.

Navigation uses Link from @/i18n/navigation which automatically prepends the current locale:

import { Link } from "@/i18n/navigation";

<Link href="/calendar">Calendar</Link>
// Renders as: /en/calendar, /fr/calendar, etc.

Back Navigation

Mobile: The MobileHeader and MobileViewerHeader components handle back navigation:

  • Back arrow returns to the previous view (list from viewer, or uses goBack() from UI store).
  • On tablet, back re-shows the list pane via setTabletListVisible(true).

Desktop: The email viewer has an optional back chevron for tablet mode:

onBack={() => {
  setTabletListVisible(true);
  selectEmail(null);
}}

Sidebar: On mobile/tablet, sidebar is an overlay (fixed inset-y-0 left-0) with backdrop (bg-black/50). Closing happens via:

  • Backdrop click
  • Close button (X icon)
  • Mailbox selection (auto-closes sidebar, switches to list view)

RSVP Button Hierarchy (Calendar)

The event modal renders differently for organizers vs. attendees.

File: components/calendar/event-modal.tsx

Attendee mode (not the organizer): shows read-only event details with an RSVP bar at the bottom:

[Accept (green)]  [Maybe (amber)]  [Decline (red/ghost)]

Active status is indicated by filled background + check icon. Inactive options use outline/ghost variant.

Color mapping:

  • Accepted: bg-green-600 text-white (active) / text-green-600 border-green-300 (inactive)
  • Tentative: bg-amber-600 text-white (active) / text-amber-600 border-amber-500 (inactive)
  • Declined: bg-red-600 text-white (active) / text-red-600 ghost (inactive)

Organizer mode: shows full edit form with a status summary of attendee responses:

"3 accepted, 2 pending"