feat(webmail): the new interface - logo, dark and light themes, command menu

A mark (a lambda on an ultramarine tile with the flap of an envelope), the
favicon, home-screen icons and an install manifest. Every colour is a variable
defined once per theme, and the Tailwind scales the screens already use read
them, so dark (the default) and light are a change of values and not of
classes. Geist is served by the app itself. Ctrl+K opens a command menu for
folders, actions, accounts and appearance, and typing something that matches no
command searches the mail; c writes a message and / goes to the search field.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-22 22:33:15 -03:00
1 parent 6b97807856
commit e667843b59
38 files changed
+1161 -316

No files matched your search

@@ -22,6 +22,8 @@ import { useTranslations } from "../../../i18n/provider";
import { LanguageSwitcher } from "../../../i18n/LanguageSwitcher";
import { useAccount } from "../../../lib/useAccount";
import { initialsFor } from "../../../lib/initials";
import { Logo } from "../../../components/Logo";
import { ThemeToggle } from "../../../components/ThemeToggle";
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
@@ -52,12 +54,10 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
const SidebarContent = (
<div className="flex h-full flex-col gap-4 p-3">
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
{"\u03bb"}
</div>
<Logo size={32} className="flex-none" />
<div className="min-w-0">
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
<div className="text-[11.5px] text-slate-400">
{t("common.adminPortal")}
</div>
</div>
@@ -91,7 +91,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="flex gap-[3px] rounded-[10px] bg-dark-panel p-[3px] shadow-edge">
<Link
href="/user/mail/inbox"
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-ink/[0.05] hover:text-slate-100"
>
<Mail className="h-3.5 w-3.5 flex-none" />
<span className="min-w-0 truncate">{t("admin.backToWebmail")}</span>
@@ -103,7 +103,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
</div>
<div className="flex items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 shadow-edge">
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]">
<span className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-full bg-indigo-900 text-[12px] uppercase text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]">
{initialsFor(account?.email)}
</span>
<span className="min-w-0 flex-1">
@@ -122,12 +122,13 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
<div className="flex items-center gap-1.5">
<LanguageSwitcher />
<ThemeToggle />
<button
type="button"
onClick={handleLogout}
title={t("settings.signOut")}
aria-label={t("settings.signOut")}
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-ink/[0.14] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
>
<LogOut className="h-[15px] w-[15px]" />
</button>
@@ -139,11 +140,9 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
return (
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
{/* Mobile Top Header */}
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] md:hidden">
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)] md:hidden">
<div className="flex min-w-0 items-center gap-2.5">
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
{"\u03bb"}
</div>
<Logo size={32} className="flex-none" />
<span className="min-w-0 truncate font-medium text-slate-100">
{t("common.appName")} {t("common.adminPortal")}
</span>
@@ -152,14 +151,14 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={t("ui.toggleMenu")}
aria-expanded={mobileOpen}
className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
className="flex h-9 w-9 flex-none items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{/* Desktop rail */}
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:flex">
{SidebarContent}
</aside>
@@ -171,7 +170,7 @@ export default function AdminLayout({ children }: { children: React.ReactNode })
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:hidden"
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:hidden"
>
{SidebarContent}
</motion.aside>
@@ -483,7 +483,7 @@ export default function AdminMailboxesPage() {
"w-full min-h-[36px] rounded-[10px] bg-dark-card px-3 py-2 text-[13.5px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent";
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
const iconButton =
"flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
"flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100";
return (
<div className="mx-auto flex w-full max-w-[1060px] flex-col gap-[18px] px-5 pb-11 pt-7 sm:px-8">
@@ -650,14 +650,14 @@ export default function AdminMailboxesPage() {
onChange={(e) => setSearch(e.target.value)}
placeholder={t("ui.searchPlaceholder")}
aria-label={t("ui.searchPlaceholder")}
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
/>
</div>
<select
value={roleFilter}
onChange={(e) => setRoleFilter(e.target.value)}
aria-label={t("ui.role")}
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.all")}</option>
<option value="USER">{roleLabel(t, "USER")}</option>
@@ -668,7 +668,7 @@ export default function AdminMailboxesPage() {
value={activeFilter}
onChange={(e) => setActiveFilter(e.target.value)}
aria-label={t("ui.accountStatus")}
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.all")}</option>
<option value="true">{t("ui.active")}</option>
@@ -167,14 +167,14 @@ export default function AdminQueuePage() {
onChange={(e) => setSearch(e.target.value)}
placeholder={t("ui.searchPlaceholder")}
aria-label={t("ui.searchPlaceholder")}
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
/>
</div>
<select
value={status}
onChange={(e) => setStatus(e.target.value)}
aria-label={t("common.status")}
className="rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
className="rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.all")}</option>
{/* Taken from the aggregate, so the choices are the states this
@@ -180,14 +180,14 @@ export default function AdminTestsPage() {
onChange={(e) => setSearch(e.target.value)}
placeholder={t("ui.searchPlaceholder")}
aria-label={t("ui.searchPlaceholder")}
className="w-full rounded-[10px] border border-white/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
className="w-full rounded-[10px] border border-ink/[0.14] bg-transparent py-2 pl-9 pr-3 text-[13px] text-slate-100 placeholder:text-slate-500"
/>
</div>
<select
value={selected}
onChange={(e) => setSelected(e.target.value)}
aria-label={t("admin.userChecks")}
className="min-w-[220px] rounded-[10px] border border-white/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
className="min-w-[220px] rounded-[10px] border border-ink/[0.14] bg-dark-panel px-3 py-2 text-[13px] text-slate-100"
>
<option value="">{t("ui.selectUser")}</option>
{users.map((u) => (
@@ -5,6 +5,8 @@ import Link from "next/link";
import { useTranslations } from "../../../../i18n/provider";
import { TotpEnrolment, RecoveryCodes } from "../../../../components/TotpEnrolment";
import { Button } from "../../../../components/ui/Button";
import { Logo } from "../../../../components/Logo";
import { ThemeToggle } from "../../../../components/ThemeToggle";
export default function AdminLoginPage() {
const t = useTranslations();
@@ -101,15 +103,14 @@ export default function AdminLoginPage() {
const fieldLabel = "mb-1.5 block text-xs text-slate-400";
return (
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
<div className="relative flex min-h-screen items-center justify-center bg-dark-bg px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
{/* The console sign-in wears the same mark as the webmail. It used to be
green over green, which made the second surface look like a
different product. */}
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-xl leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
{"\u03bb"}
</div>
<Logo size={40} className="flex-none" />
<div className="min-w-0">
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.adminLoginTitle")}</h1>
<p className="mt-0.5 text-[12.5px] leading-relaxed text-slate-400">{t("auth.adminStepUpNote")}</p>
@@ -96,7 +96,7 @@ export default function AdminStepUpPage() {
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-indigo-500 shadow-[inset_0_0_0_1px_rgb(var(--a-500))]">
<Sliders className="h-5 w-5" />
</div>
<div className="min-w-0">
@@ -4,6 +4,8 @@ import React, { useState } from "react";
import { Mail, Lock, AlertCircle } from "lucide-react";
import { useTranslations } from "../../../../i18n/provider";
import { Button } from "../../../../components/ui/Button";
import { Logo } from "../../../../components/Logo";
import { ThemeToggle } from "../../../../components/ThemeToggle";
export default function UserLoginPage() {
const t = useTranslations();
@@ -55,12 +57,11 @@ export default function UserLoginPage() {
/* No ambient glow behind the card any more: two blurred coloured circles
under a sign-in form are decoration that costs a full-viewport paint and
says nothing. Nocturne's depth is the surface and its edge. */
<div className="flex min-h-screen items-center justify-center bg-dark-bg px-4">
<div className="relative flex min-h-screen items-center justify-center bg-dark-bg px-4">
<ThemeToggle className="absolute right-4 top-4" />
<div className="w-full max-w-[400px] rounded-2xl bg-dark-panel p-7 shadow-edge">
<div className="mb-6 flex items-center gap-3">
<div className="flex h-10 w-10 flex-none items-center justify-center rounded-xl bg-dark-card text-xl leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
{"\u03bb"}
</div>
<Logo size={40} className="flex-none" />
<div className="min-w-0">
<h1 className="text-lg font-medium leading-tight text-slate-100">{t("auth.userLoginTitle")}</h1>
<p className="mt-0.5 text-[12.5px] text-slate-400">
+28 -16
View File
@@ -20,6 +20,7 @@ import {
ShieldAlert,
SlidersHorizontal,
Menu,
Search,
X,
} from "lucide-react";
import { useTranslations } from "../../../i18n/provider";
@@ -28,6 +29,8 @@ import { useAccount, isAdminRole } from "../../../lib/useAccount";
import { useFolders } from "../../../lib/useFolders";
import { folderBadge, customFolders } from "../../../lib/mailCounts";
import { AccountSwitcher } from "../../../components/AccountSwitcher";
import { Logo } from "../../../components/Logo";
import { CommandPalette } from "../../../components/CommandPalette";
export default function UserWebmailLayout({ children }: { children: React.ReactNode }) {
const t = useTranslations();
@@ -87,12 +90,10 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
{/* The brand mark. One mark for both surfaces - the line underneath is
the only thing that says which one you are in. */}
<div className="flex items-center gap-2.5 px-2 py-1">
<div className="flex h-8 w-8 flex-none items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
{"\u03bb"}
</div>
<Logo size={32} className="flex-none" />
<div className="min-w-0">
<div className="text-[15px] font-medium leading-tight">LambdaMail</div>
<div className="text-[10.5px] uppercase tracking-[0.08em] text-indigo-500">
<div className="text-[11.5px] text-slate-400">
{t("common.userPortal")}
</div>
</div>
@@ -101,12 +102,24 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
<Link
href="/user/compose"
onClick={() => setMobileOpen(false)}
className="flex w-full items-center justify-center gap-2 rounded-[10px] border border-indigo-500 px-3 py-2.5 text-sm font-medium text-indigo-500 transition-colors hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]"
className="flex w-full items-center justify-center gap-2 rounded-xl bg-fill px-3 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-fill-hover active:brightness-95"
>
<PenSquare className="h-4 w-4 flex-none" />
<span className="min-w-0">{t("mail.compose")}</span>
</Link>
{/* The way into everything that is not on screen. Quiet on purpose: the
sidebar keeps to folders, and this is where the rest lives. */}
<button
type="button"
onClick={() => window.dispatchEvent(new KeyboardEvent("keydown", { key: "k", ctrlKey: true }))}
className="-mt-1 flex w-full items-center gap-2 rounded-xl px-3 py-2 text-left text-[13px] text-slate-400 shadow-edge transition-colors hover:bg-ink/[0.05] hover:text-slate-200"
>
<Search className="h-4 w-4 flex-none" aria-hidden />
<span className="min-w-0 flex-1 truncate">{t("palette.open")}</span>
<kbd className="rounded-md px-1.5 py-0.5 text-[11px] shadow-edge">Ctrl K</kbd>
</button>
{/* Folder navigation. Every row is a mark, an icon, a label that may wrap,
and its numbers - nothing is sized by the length of the label, which
is what clipped the Portuguese and Spanish names before. */}
@@ -192,7 +205,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
if (!window.confirm(t("folders.deleteConfirm", { name: folder.name }))) return;
void manageFolder({ action: "delete", name: folder.name });
}}
className="absolute right-1.5 top-1/2 hidden h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-white/[0.09] hover:text-slate-200 focus:flex group-hover/folder:flex"
className="absolute right-1.5 top-1/2 hidden h-6 w-6 -translate-y-1/2 items-center justify-center rounded-md text-slate-500 transition-colors hover:bg-ink/[0.09] hover:text-slate-200 focus:flex group-hover/folder:flex"
>
<Trash2 className="h-3 w-3" />
</button>
@@ -223,7 +236,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
type="submit"
aria-label={t("folders.new")}
title={t("folders.new")}
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
className="flex h-7 w-7 flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
>
<Plus className="h-4 w-4" />
</button>
@@ -266,7 +279,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
href="/admin/step-up"
title={t("admin.openAdmin")}
aria-label={t("admin.openAdmin")}
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-white/[0.05] hover:text-slate-100"
className="flex flex-1 items-center justify-center gap-1.5 rounded-[8px] px-2 py-1.5 text-[12px] leading-snug text-slate-400 transition-colors hover:bg-ink/[0.05] hover:text-slate-100"
>
<ShieldCheck className="h-3.5 w-3.5 flex-none" />
<span className="min-w-0 truncate">{t("admin.openAdminShort")}</span>
@@ -283,7 +296,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
onClick={handleLogout}
title={t("settings.signOut")}
aria-label={t("settings.signOut")}
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-white/[0.14] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
className="flex h-8 w-8 flex-none items-center justify-center rounded-[9px] border border-ink/[0.14] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
>
<LogOut className="h-[15px] w-[15px]" />
</button>
@@ -294,26 +307,25 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
return (
<div className="flex h-screen overflow-hidden bg-dark-bg text-slate-100">
<CommandPalette isAdmin={isAdminRole(account?.role)} />
{/* Mobile Top Header */}
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] md:hidden">
<div className="fixed left-0 top-0 z-20 flex w-full items-center justify-between bg-dark-rail px-4 py-3 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)] md:hidden">
<div className="flex items-center gap-2.5">
<div className="flex h-8 w-8 items-center justify-center rounded-[10px] bg-dark-card text-[17px] leading-none text-indigo-500 shadow-[inset_0_0_0_1px_#9184d9]">
{"\u03bb"}
</div>
<Logo size={32} className="flex-none" />
<span className="font-medium text-slate-100">LambdaMail</span>
</div>
<button
onClick={() => setMobileOpen(!mobileOpen)}
aria-label={t("ui.toggleMenu")}
aria-expanded={mobileOpen}
className="flex h-9 w-9 items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
className="flex h-9 w-9 items-center justify-center rounded-[10px] text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"
>
{mobileOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
</button>
</div>
{/* Desktop rail */}
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:flex">
<aside className="hidden w-[244px] flex-none flex-col bg-dark-rail xl:w-[268px] shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:flex">
{SidebarContent}
</aside>
@@ -325,7 +337,7 @@ export default function UserWebmailLayout({ children }: { children: React.ReactN
animate={{ x: 0 }}
exit={{ x: "-100%" }}
transition={{ type: "spring", damping: 25, stiffness: 200 }}
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgba(233,233,237,0.09)] md:hidden"
className="fixed inset-y-0 left-0 z-30 w-[280px] overflow-y-auto bg-dark-rail pt-14 shadow-[inset_-1px_0_0_0_rgb(var(--ink)/0.09)] md:hidden"
>
{SidebarContent}
</motion.aside>
@@ -48,6 +48,7 @@ import {
import { useAccount } from "../../../../../lib/useAccount";
import { MessageReader } from "../../../../../components/MessageReader";
import { ThreadReader } from "../../../../../components/ThreadReader";
import { SEARCH_EVENT } from "../../../../../components/CommandPalette";
import { Avatar } from "../../../../../components/Avatar";
interface MessageSummary {
@@ -259,6 +260,13 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
return () => clearTimeout(handle);
}, [searchQuery, loadMessages, conversations.ready]);
// The command menu can ask this list to search.
useEffect(() => {
const onSearch = (event: Event) => setSearchQuery(String((event as CustomEvent).detail ?? ""));
window.addEventListener(SEARCH_EVENT, onSearch);
return () => window.removeEventListener(SEARCH_EVENT, onSearch);
}, []);
// Changing between the two lists closes whatever was open in the other.
useEffect(() => {
setSelected(null);
@@ -542,6 +550,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
type="search"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
id="mail-search"
placeholder={t("mail.searchPlaceholder")}
aria-label={t("common.search")}
className="min-h-[38px] w-full rounded-xl bg-dark-panel py-2 pl-[34px] pr-3 text-[13px] text-slate-100 placeholder-slate-500 shadow-edge transition-shadow focus:outline-none focus-visible:shadow-edge-accent"
@@ -605,7 +614,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
className={
msg.seen
? "bg-dark-card text-slate-400"
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
: "bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
}
/>
@@ -689,7 +698,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
onClick={() => void (msg.thread_id ? deleteConversationRow(msg) : deleteMessage(msg.uid))}
title={t("common.delete")}
aria-label={t("common.delete")}
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-white/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
className="absolute right-2 top-2 hidden h-7 w-7 items-center justify-center rounded-md text-slate-400 transition-colors hover:bg-ink/[0.09] hover:text-slate-100 focus:flex focus-visible:flex group-hover:flex"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
@@ -733,7 +742,7 @@ export default function MailFolderPage({ params }: { params: Promise<{ folder: s
e.preventDefault();
}}
className={`group relative hidden w-1.5 flex-none cursor-col-resize touch-none select-none md:block ${
dragging ? "bg-indigo-500/40" : "bg-white/[0.07] hover:bg-indigo-500/30"
dragging ? "bg-indigo-500/40" : "bg-ink/[0.07] hover:bg-indigo-500/30"
} focus-visible:bg-indigo-500/50 focus-visible:outline-none`}
>
{/* A wider invisible target, so the thin line is easy to grab. */}
@@ -510,7 +510,7 @@ export default function UserSettingsPage() {
type="button"
onClick={() => void revokeAppPassword(ap.id)}
aria-label={t("common.delete")}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
>
<Trash2 className="h-[15px] w-[15px]" />
</button>
@@ -551,7 +551,7 @@ export default function UserSettingsPage() {
type="button"
onClick={() => void revokeSession(session.id)}
aria-label={t("common.delete")}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
>
<Trash2 className="h-[15px] w-[15px]" />
</button>
@@ -769,7 +769,7 @@ export default function UserSettingsPage() {
disabled={index === 0}
aria-label={t("settings.ruleMoveUp")}
title={t("settings.ruleMoveUp")}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100 disabled:opacity-30"
>
<ChevronUp className="h-[15px] w-[15px]" />
</button>
@@ -779,7 +779,7 @@ export default function UserSettingsPage() {
disabled={index === rules.length - 1}
aria-label={t("settings.ruleMoveDown")}
title={t("settings.ruleMoveDown")}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-slate-100 disabled:opacity-30"
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-slate-100 disabled:opacity-30"
>
<ChevronDown className="h-[15px] w-[15px]" />
</button>
@@ -787,7 +787,7 @@ export default function UserSettingsPage() {
type="button"
onClick={() => removeSieveRule(r.id)}
aria-label={t("common.delete")}
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-white/[0.07] hover:text-rose-400"
className="flex h-[30px] w-[30px] flex-none items-center justify-center rounded-lg text-slate-400 transition-colors hover:bg-ink/[0.07] hover:text-rose-400"
>
<Trash2 className="h-[15px] w-[15px]" />
</button>
@@ -914,12 +914,12 @@ export default function UserSettingsPage() {
aria-label={b.title}
onMouseDown={(e) => e.preventDefault()}
onClick={() => applySignatureCommand(b.cmd)}
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100 ${b.cls}`}
className={`h-7 w-7 rounded-md text-[13px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100 ${b.cls}`}
>
{b.label}
</button>
))}
<span className="mx-1 h-4 w-px bg-white/10" />
<span className="mx-1 h-4 w-px bg-ink/10" />
<button
type="button"
title={t("ui.insertLink")}
@@ -929,7 +929,7 @@ export default function UserSettingsPage() {
const url = window.prompt(t("ui.insertLink"), "https://");
if (url) applySignatureCommand("createLink", url);
}}
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
>
{t("ui.insertLink")}
</button>
@@ -942,7 +942,7 @@ export default function UserSettingsPage() {
const url = window.prompt(t("ui.insertImage"), "https://");
if (url) applySignatureCommand("insertImage", url);
}}
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-white/[0.09] hover:text-slate-100"
className="h-7 rounded-md px-2 text-[12px] text-slate-300 transition-colors hover:bg-ink/[0.09] hover:text-slate-100"
>
{t("ui.insertImage")}
</button>
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+220 -117
View File
@@ -1,95 +1,218 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
/**
* Nocturne - the one visual language for both surfaces.
* LambdaMail's visual language, in two themes.
*
* The webmail used to be indigo over glass and the console green over glass,
* so the two halves of the same product did not look related. Everything below
* comes from the Nocturne design system: a near-neutral blue-grey ground, Inter
* at weight 500 for headings, solid surfaces with a 1px inset edge instead of
* blur, and a single blurple accent used as a line, a mark and a glow rather
* than a flood. Tailwind's slate/indigo/emerald scales are remapped onto these
* same values in tailwind.config.js, so a utility class and a variable here
* cannot drift apart.
* Every colour in the interface is a variable below, and the Tailwind scales the
* screens already use (slate, indigo, amber, rose, emerald) read those
* variables, so a theme is a change of values and not a change of classes.
* The ramps run the same direction in both themes: 100 is always the text you
* read, 950 always the ground it sits on, 400 the quieter text. That is why a
* screen written against `text-slate-400` is correct in light and in dark.
*
* Dark is the default. The theme is chosen before first paint by the script in
* the root layout, so there is no flash of the other one.
*/
/* GENERATED from the ramps in docs/design - edit the ramps, not these lines */
:root, [data-theme='dark'] {
--n-50: 245 247 251;
--n-100: 230 233 240;
--n-200: 211 216 227;
--n-300: 181 188 204;
--n-400: 142 150 168;
--n-500: 111 119 138;
--n-600: 82 90 108;
--n-700: 56 62 77;
--n-800: 35 40 52;
--n-900: 23 27 37;
--n-950: 15 18 24;
--a-50: 241 243 255;
--a-100: 228 232 255;
--a-200: 203 210 255;
--a-300: 169 180 255;
--a-400: 135 148 255;
--a-500: 101 116 255;
--a-600: 79 94 240;
--a-700: 61 73 196;
--a-800: 44 53 144;
--a-900: 29 35 96;
--a-950: 20 24 66;
--w-50: 255 248 230;
--w-100: 255 239 194;
--w-200: 255 224 138;
--w-300: 255 206 82;
--w-400: 245 184 46;
--w-500: 227 157 15;
--w-600: 185 122 8;
--w-700: 138 90 10;
--w-800: 92 61 11;
--w-900: 58 40 16;
--w-950: 36 25 9;
--b-50: 255 241 242;
--b-100: 255 224 227;
--b-200: 255 194 201;
--b-300: 255 154 166;
--b-400: 248 112 127;
--b-500: 229 72 90;
--b-600: 192 47 66;
--b-700: 150 38 55;
--b-800: 101 27 40;
--b-900: 65 22 32;
--b-950: 42 15 22;
--g-50: 236 253 245;
--g-100: 209 250 229;
--g-200: 167 243 208;
--g-300: 110 231 183;
--g-400: 52 211 153;
--g-500: 16 185 129;
--g-600: 5 150 105;
--g-700: 4 120 87;
--g-800: 6 95 70;
--g-900: 6 78 59;
--g-950: 2 44 34;
--a-fill: 79 94 240;
--a-fill-hover: 99 113 250;
--ink: 233 237 245;
--rail: 11 13 18;
--lm-shadow: 0 18px 48px rgb(0 0 0 / 0.55);
color-scheme: dark;
}
[data-theme='light'] {
--n-50: 11 14 20;
--n-100: 18 21 28;
--n-200: 35 40 52;
--n-300: 58 65 82;
--n-400: 91 98 114;
--n-500: 123 130 148;
--n-600: 163 169 184;
--n-700: 205 209 219;
--n-800: 233 236 241;
--n-900: 255 255 255;
--n-950: 243 244 247;
--a-50: 20 24 66;
--a-100: 29 35 96;
--a-200: 44 53 144;
--a-300: 51 65 194;
--a-400: 63 79 230;
--a-500: 63 79 230;
--a-600: 101 116 255;
--a-700: 135 148 255;
--a-800: 183 192 255;
--a-900: 225 229 255;
--a-950: 238 240 255;
--w-50: 36 25 9;
--w-100: 58 40 16;
--w-200: 92 61 11;
--w-300: 122 78 6;
--w-400: 138 90 10;
--w-500: 185 122 8;
--w-600: 227 157 15;
--w-700: 245 184 46;
--w-800: 255 224 138;
--w-900: 255 241 201;
--w-950: 255 248 230;
--b-50: 42 15 22;
--b-100: 65 22 32;
--b-200: 101 27 40;
--b-300: 142 33 51;
--b-400: 178 42 62;
--b-500: 214 58 78;
--b-600: 229 72 90;
--b-700: 248 112 127;
--b-800: 255 179 187;
--b-900: 255 227 230;
--b-950: 255 241 242;
--g-50: 2 44 34;
--g-100: 6 78 59;
--g-200: 6 95 70;
--g-300: 5 107 75;
--g-400: 4 120 87;
--g-500: 5 150 105;
--g-600: 16 185 129;
--g-700: 52 211 153;
--g-800: 167 243 208;
--g-900: 216 248 233;
--g-950: 236 253 245;
--a-fill: 63 79 230;
--a-fill-hover: 52 66 214;
--ink: 17 20 28;
--rail: 236 238 243;
--lm-shadow: 0 18px 48px rgb(20 28 50 / 0.16);
color-scheme: light;
}
:root {
/* Grounds, darkest to lightest: the rail, the page, a panel, a card. */
--lm-rail: #12131f;
--lm-bg: #161826;
--lm-panel: #1b1c28;
--lm-surface: #232532;
--lm-radius-md: 10px;
}
--lm-text: #e9e9ed;
--lm-text-muted: rgba(233, 233, 237, 0.62);
/* One accent, in the product's own blurple. */
--lm-accent: #9184d9;
--lm-accent-pale: #d2cefd;
/* Edges are drawn as an inset ring, not a border, so nothing shifts by 1px
when a surface becomes active. */
--lm-edge: rgba(233, 233, 237, 0.09);
--lm-edge-strong: rgba(233, 233, 237, 0.16);
--lm-divider: rgba(233, 233, 237, 0.14);
--lm-radius-md: 11px;
html {
-webkit-text-size-adjust: 100%;
}
body {
background-color: var(--lm-bg);
color: var(--lm-text);
font-family: 'Inter', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background-color: rgb(var(--n-950));
color: rgb(var(--n-100));
font-family: 'Geist Variable', 'Geist', system-ui, -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 14px;
line-height: 1.5;
font-feature-settings: 'cv11', 'ss01';
overflow-x: hidden;
/* A theme change is a crossfade, not a flash. Skipped for people who ask for
less motion, below. */
transition: background-color 0.2s ease, color 0.2s ease;
}
/* Hierarchy here is size and space; headings never go past weight 500. */
/* Hierarchy is size and weight; headings are tight and never shout. */
h1, h2, h3, h4, h5, h6 {
font-weight: 500;
letter-spacing: -0.015em;
font-weight: 600;
letter-spacing: -0.02em;
}
/* Keyboard focus is themed everywhere rather than left to the browser. */
:focus-visible {
outline: 2px solid var(--lm-accent);
outline: 2px solid rgb(var(--a-400));
outline-offset: 2px;
}
::selection {
background: rgba(145, 132, 217, 0.3);
background: rgb(var(--a-500) / 0.28);
}
@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;
}
}
/* --------------------------------------------------------------------
Surfaces
-------------------------------------------------------------------- */
/** A code or DNS-record block: the darkest ground, monospace, accent ink. */
/** A code or DNS-record block. */
.lm-code {
background: var(--lm-rail);
box-shadow: inset 0 0 0 1px var(--lm-edge);
background: rgb(var(--rail));
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.09);
border-radius: var(--lm-radius-md);
font-family: ui-monospace, Menlo, Consolas, monospace;
font-family: ui-monospace, 'SF Mono', Menlo, Consolas, monospace;
font-size: 12px;
line-height: 1.6;
color: var(--lm-accent-pale);
color: rgb(var(--a-300));
}
/**
* A rule that fades to transparent at both ends rather than stopping cleanly -
* the one Nocturne signature that has to be CSS, because Tailwind has no
* gradient border.
*/
/** A rule that fades out at both ends instead of stopping dead. */
.lm-rule {
height: 1px;
border: 0;
background: linear-gradient(
to right,
transparent,
var(--lm-divider) 24px,
var(--lm-divider) calc(100% - 24px),
rgb(var(--ink) / 0.14) 24px,
rgb(var(--ink) / 0.14) calc(100% - 24px),
transparent
);
}
@@ -99,58 +222,51 @@ h1, h2, h3, h4, h5, h6 {
-------------------------------------------------------------------- */
/**
* A sidebar item. The 3px mark on the left is the accent's whole appearance
* here - an active item is not a filled pill. Nothing in this block is sized
* by its text: the label wraps to a second line instead of being clipped,
* which is what broke when the interface was read in Portuguese or Spanish.
* A sidebar item. Active is a tint of the accent behind the label, the way
* system sidebars do it; the text does not shift and nothing is sized by it, so
* a long label wraps instead of being clipped.
*/
.lm-nav {
display: flex;
align-items: center;
gap: 10px;
padding: 8px 9px 8px 4px;
padding: 7px 10px;
border-radius: var(--lm-radius-md);
color: var(--lm-text-muted);
color: rgb(var(--n-300));
transition: background-color 0.15s ease, color 0.15s ease;
}
.lm-nav:hover {
background: rgba(233, 233, 237, 0.05);
color: var(--lm-text);
background: rgb(var(--ink) / 0.06);
color: rgb(var(--n-100));
}
.lm-nav[data-active='true'] {
background: var(--lm-surface);
color: var(--lm-text);
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.35);
background: rgb(var(--a-500) / 0.15);
color: rgb(var(--a-300));
font-weight: 500;
}
/* The old left-hand mark is gone; the tint carries "active" now. */
.lm-nav-mark {
width: 3px;
flex: none;
align-self: stretch;
border-radius: 9999px;
background: transparent;
}
.lm-nav[data-active='true'] .lm-nav-mark {
background: var(--lm-accent);
display: none;
}
/** A message row in the list pane. */
.lm-row {
display: flex;
gap: 11px;
gap: 12px;
align-items: flex-start;
padding: 12px;
padding: 11px 12px;
border-radius: 12px;
transition: background-color 0.12s ease;
}
.lm-row:hover {
background: rgba(233, 233, 237, 0.04);
background: rgb(var(--ink) / 0.045);
}
.lm-row[data-active='true'] {
background: var(--lm-surface);
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
background: rgb(var(--a-500) / 0.13);
box-shadow: inset 0 0 0 1px rgb(var(--a-500) / 0.32);
}
/** A filter chip: an outline that takes the accent when it is the one chosen. */
/** A filter chip: quiet until it is the one chosen. */
.lm-chip {
display: inline-flex;
align-items: center;
@@ -159,16 +275,18 @@ h1, h2, h3, h4, h5, h6 {
border-radius: 9999px;
font-size: 12.5px;
line-height: 1.3;
color: var(--lm-text-muted);
box-shadow: inset 0 0 0 1px var(--lm-edge-strong);
transition: color 0.15s ease, box-shadow 0.15s ease;
color: rgb(var(--n-300));
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.16);
transition: color 0.15s ease, box-shadow 0.15s ease, background-color 0.15s ease;
}
.lm-chip:hover {
box-shadow: inset 0 0 0 1px var(--lm-accent);
background: rgb(var(--ink) / 0.05);
color: rgb(var(--n-100));
}
.lm-chip[data-active='true'] {
color: var(--lm-accent);
box-shadow: inset 0 0 0 1px var(--lm-accent);
color: rgb(var(--a-300));
background: rgb(var(--a-500) / 0.14);
box-shadow: inset 0 0 0 1px rgb(var(--a-500) / 0.4);
}
/** A tab inside the segmented strip the settings, domain and mailbox screens use. */
@@ -180,17 +298,17 @@ h1, h2, h3, h4, h5, h6 {
border-radius: 9px;
font-size: 12.5px;
line-height: 1.3;
color: var(--lm-text-muted);
color: rgb(var(--n-300));
transition: background-color 0.15s ease, color 0.15s ease;
}
.lm-tab:hover {
color: var(--lm-text);
background: rgba(233, 233, 237, 0.05);
color: rgb(var(--n-100));
background: rgb(var(--ink) / 0.06);
}
.lm-tab[data-active='true'] {
background: var(--lm-surface);
color: var(--lm-accent);
box-shadow: inset 0 0 0 1px rgba(145, 132, 217, 0.4);
background: rgb(var(--n-800));
color: rgb(var(--n-50));
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.12), 0 1px 2px rgb(0 0 0 / 0.12);
}
.lm-tabstrip {
display: flex;
@@ -198,12 +316,12 @@ h1, h2, h3, h4, h5, h6 {
gap: 6px;
padding: 4px;
border-radius: 12px;
background: var(--lm-panel);
box-shadow: inset 0 0 0 1px var(--lm-edge);
background: rgb(var(--n-900));
box-shadow: inset 0 0 0 1px rgb(var(--ink) / 0.09);
}
/* --------------------------------------------------------------------
Tables - the header and the row rules fade at their ends, as the rules do
Tables
-------------------------------------------------------------------- */
.lm-table {
@@ -213,11 +331,9 @@ h1, h2, h3, h4, h5, h6 {
}
.lm-table th {
text-align: left;
font-size: 11px;
font-size: 12px;
font-weight: 500;
letter-spacing: 0.08em;
text-transform: uppercase;
color: rgba(233, 233, 237, 0.6);
color: rgb(var(--n-400));
padding: 8px 10px;
white-space: nowrap;
}
@@ -225,34 +341,25 @@ h1, h2, h3, h4, h5, h6 {
padding: 10px;
vertical-align: middle;
}
.lm-table thead tr {
background: linear-gradient(
to right,
transparent,
var(--lm-divider) 24px,
var(--lm-divider) calc(100% - 24px),
transparent
)
no-repeat bottom / 100% 1px;
}
.lm-table thead tr,
.lm-table tbody tr {
background: linear-gradient(
to right,
transparent,
rgba(233, 233, 237, 0.08) 24px,
rgba(233, 233, 237, 0.08) calc(100% - 24px),
rgb(var(--ink) / 0.1) 24px,
rgb(var(--ink) / 0.1) calc(100% - 24px),
transparent
)
no-repeat bottom / 100% 1px;
}
.lm-table tbody tr:hover {
background:
linear-gradient(rgba(233, 233, 237, 0.04), rgba(233, 233, 237, 0.04)) no-repeat 0 0 / 100% 100%,
linear-gradient(rgb(var(--ink) / 0.045), rgb(var(--ink) / 0.045)) no-repeat 0 0 / 100% 100%,
linear-gradient(
to right,
transparent,
rgba(233, 233, 237, 0.08) 24px,
rgba(233, 233, 237, 0.08) calc(100% - 24px),
rgb(var(--ink) / 0.1) 24px,
rgb(var(--ink) / 0.1) calc(100% - 24px),
transparent
)
no-repeat bottom / 100% 1px;
@@ -266,21 +373,17 @@ h1, h2, h3, h4, h5, h6 {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: transparent;
}
::-webkit-scrollbar-thumb {
background: #3f424d;
background: rgb(var(--n-700));
border-radius: 9999px;
}
::-webkit-scrollbar-thumb:hover {
background: #595d6c;
background: rgb(var(--n-600));
}
* {
scrollbar-width: thin;
scrollbar-color: #3f424d transparent;
scrollbar-color: rgb(var(--n-700)) transparent;
}
+14
View File
@@ -0,0 +1,14 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
<defs>
<linearGradient id="lm-tile" x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stop-color="#6F7EFF"/>
<stop offset="1" stop-color="#3441D8"/>
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill="url(#lm-tile)"/>
<!-- the flap of an envelope, as light on the tile: the fold every letter has -->
<path d="M0 15C0 6.7 6.7 0 15 0h34c8.3 0 15 6.7 15 15v2L32 40 0 17z" fill="#fff" fill-opacity=".11"/>
<!-- the lambda -->
<path d="M21 14.5 43 50" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
<path d="M32.1 32.4 21 50" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round"/>
</svg>

After

Width:  |  Height:  |  Size: 798 B

+27 -4
View File
@@ -1,12 +1,30 @@
import "@fontsource-variable/geist";
import "./globals.css";
import React from "react";
import type { Metadata, Viewport } from "next";
import { cookies, headers } from "next/headers";
import { I18nProvider } from "../i18n/provider";
import { LOCALE_COOKIE, isLocale, negotiateLocale } from "../i18n/config";
import { THEME_BOOT_SCRIPT } from "../lib/theme";
export const metadata = {
title: "LambdaMail - Secure Self-Hosted Webmail & Admin Console",
description: "Modern, open-source email server web interface with 2FA TOTP and surface isolation.",
export const metadata: Metadata = {
title: { default: "LambdaMail", template: "%s - LambdaMail" },
description: "Your own mail server, with a webmail that is a pleasure to use.",
applicationName: "LambdaMail",
// The tab icon, the home-screen icon and the install manifest all come from
// the files next to this one (icon.svg, favicon.ico, apple-icon.png) and
// from manifest.ts.
appleWebApp: { capable: true, title: "LambdaMail", statusBarStyle: "black-translucent" },
};
// The browser's own chrome follows the theme: the address bar on a phone is
// the page's ground in dark, and the paper in light.
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#F3F4F7" },
{ media: "(prefers-color-scheme: dark)", color: "#0F1218" },
],
colorScheme: "dark light",
};
/**
@@ -27,7 +45,12 @@ export default async function RootLayout({ children }: { children: React.ReactNo
const locale = await resolveLocale();
return (
<html lang={locale} className="dark">
// data-theme is set by the script below before the page paints, and React
// must not "correct" it back when it hydrates.
<html lang={locale} data-theme="dark" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_BOOT_SCRIPT }} />
</head>
<body className="min-h-screen bg-dark-bg font-sans text-slate-100 antialiased">
<I18nProvider locale={locale}>{children}</I18nProvider>
</body>
+23
View File
@@ -0,0 +1,23 @@
import type { MetadataRoute } from "next";
/**
* What lets the webmail be installed like an app: its own window, its own
* icon, and a splash that matches the ground it opens onto.
*/
export default function manifest(): MetadataRoute.Manifest {
return {
name: "LambdaMail",
short_name: "LambdaMail",
description: "Your own mail server, with a webmail that is a pleasure to use.",
start_url: "/user/mail/inbox",
scope: "/",
display: "standalone",
background_color: "#0F1218",
theme_color: "#0F1218",
icons: [
{ src: "/icons/icon-192.png", sizes: "192x192", type: "image/png" },
{ src: "/icons/icon-512.png", sizes: "512x512", type: "image/png" },
{ src: "/icons/maskable-512.png", sizes: "512x512", type: "image/png", purpose: "maskable" },
],
};
}