Merge feat/new-interface: logo and icons, dark and light themes, command menu

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
commit d29ecec8d9
38 files changed
+1161 -316

No files matched your search

+22
View File
@@ -564,5 +564,27 @@
"draftDiscarded": "Reply discarded",
"sentReply": "Reply sent",
"moreMessages": "Show {count} earlier messages"
},
"appearance": {
"title": "Appearance",
"dark": "Dark",
"light": "Light",
"system": "System"
},
"palette": {
"placeholder": "Type a command, a folder or something to search for",
"empty": "Nothing matches. Press Enter to search your mail for this.",
"searchFor": "Search mail for \"{query}\"",
"goTo": "Go to {name}",
"actions": "Actions",
"navigate": "Go to",
"theme": "Appearance",
"accounts": "Accounts",
"switchTo": "Switch to {email}",
"hintMove": "move",
"hintOpen": "open",
"hintClose": "close",
"open": "Command menu",
"signOut": "Sign out"
}
}
+22
View File
@@ -564,5 +564,27 @@
"draftDiscarded": "Respuesta descartada",
"sentReply": "Respuesta enviada",
"moreMessages": "Mostrar {count} mensajes anteriores"
},
"appearance": {
"title": "Apariencia",
"dark": "Oscuro",
"light": "Claro",
"system": "Sistema"
},
"palette": {
"placeholder": "Escribe un comando, una carpeta o algo que buscar",
"empty": "Sin resultados. Pulsa Enter para buscar esto en tu correo.",
"searchFor": "Buscar \"{query}\" en el correo",
"goTo": "Ir a {name}",
"actions": "Acciones",
"navigate": "Ir a",
"theme": "Apariencia",
"accounts": "Cuentas",
"switchTo": "Cambiar a {email}",
"hintMove": "mover",
"hintOpen": "abrir",
"hintClose": "cerrar",
"open": "Menú de comandos",
"signOut": "Cerrar sesión"
}
}
+22
View File
@@ -564,5 +564,27 @@
"draftDiscarded": "Resposta descartada",
"sentReply": "Resposta enviada",
"moreMessages": "Mostrar {count} mensagens anteriores"
},
"appearance": {
"title": "Aparência",
"dark": "Escuro",
"light": "Claro",
"system": "Sistema"
},
"palette": {
"placeholder": "Digite um comando, uma pasta ou algo para buscar",
"empty": "Nada encontrado. Pressione Enter para buscar este texto nos seus e-mails.",
"searchFor": "Buscar \"{query}\" nos e-mails",
"goTo": "Ir para {name}",
"actions": "Ações",
"navigate": "Ir para",
"theme": "Aparência",
"accounts": "Contas",
"switchTo": "Trocar para {email}",
"hintMove": "mover",
"hintOpen": "abrir",
"hintClose": "fechar",
"open": "Menu de comandos",
"signOut": "Sair"
}
}
+10
View File
@@ -8,6 +8,7 @@
"name": "@lambdamail/webmail",
"version": "0.1.0",
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"framer-motion": "^11.18.2",
@@ -718,6 +719,15 @@
}
}
},
"node_modules/@fontsource-variable/geist": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/@fontsource-variable/geist/-/geist-5.3.0.tgz",
"integrity": "sha512-j0m+vLQuG5XAYoHtGCVu0spvlGreR3EzpECUVzkFmI1mTVnAO38l/NEPDCFgZ177JxzYJCLSmTQibIiYPilGrA==",
"license": "OFL-1.1",
"funding": {
"url": "https://github.com/sponsors/ayuhito"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
+1
View File
@@ -10,6 +10,7 @@
"lint": "tsc --noEmit"
},
"dependencies": {
"@fontsource-variable/geist": "^5.3.0",
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"framer-motion": "^11.18.2",
Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 26 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

@@ -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" },
],
};
}
@@ -5,6 +5,7 @@ import Link from "next/link";
import { Check, ChevronsUpDown, LogOut, Plus, Settings } from "lucide-react";
import { useTranslations } from "../i18n/provider";
import { Avatar } from "./Avatar";
import { ThemeToggle } from "./ThemeToggle";
interface SignedInAccount {
id: string;
@@ -73,7 +74,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
};
const item =
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-white/[0.07]";
"flex w-full items-center gap-2.5 rounded-lg px-2.5 py-2 text-left text-[13px] transition-colors hover:bg-ink/[0.07]";
return (
<div ref={root} className="relative">
@@ -85,7 +86,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
title={t("accounts.switchAccount")}
className="flex w-full items-center gap-2.5 rounded-xl bg-dark-panel p-2.5 text-left shadow-edge transition-colors hover:bg-dark-card"
>
<Avatar address={active?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]" />
<Avatar address={active?.email} size={30} className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]" />
<span className="min-w-0 flex-1 truncate text-[12.5px]">{active?.email ?? t("common.loading")}</span>
<ChevronsUpDown className="h-3.5 w-3.5 flex-none text-slate-400" />
</button>
@@ -93,7 +94,7 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
{open && (
<div
role="menu"
className="absolute inset-x-0 bottom-full z-30 mb-2 rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10"
className="absolute inset-x-0 bottom-full z-30 mb-2 rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-ink/10"
>
{accounts.map((account) => (
<button
@@ -112,7 +113,13 @@ export function AccountSwitcher({ onNavigate }: { onNavigate?: () => void }) {
<Plus className="h-4 w-4 flex-none text-slate-400" />
{t("accounts.addAccount")}
</Link>
<div className="my-1 h-px bg-white/10" />
<div className="my-1 h-px bg-ink/10" />
{/* Three choices in one row, so changing the look is one click. */}
<div className="flex items-center gap-2 px-2.5 py-1.5">
<span className="flex-1 text-[12.5px] text-slate-400">{t("appearance.title")}</span>
<ThemeToggle />
</div>
<div className="my-1 h-px bg-ink/10" />
<Link href="/user/settings" role="menuitem" onClick={onNavigate} className={item}>
<Settings className="h-4 w-4 flex-none text-slate-400" />
{t("accounts.settings")}
@@ -0,0 +1,310 @@
"use client";
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useRouter } from "next/navigation";
import {
Archive,
AlertTriangle,
CornerDownLeft,
FileText,
Folder as FolderIcon,
Inbox,
Laptop,
LogOut,
Moon,
PenSquare,
Plus,
Search,
Send,
Settings,
ShieldCheck,
Sun,
Trash2,
UserRound,
} from "lucide-react";
import { useTranslations } from "../i18n/provider";
import { useFolders } from "../lib/useFolders";
import { customFolders } from "../lib/mailCounts";
import { setThemeChoice } from "../lib/theme";
/** Fired to ask the open mail list to show a search. */
export const SEARCH_EVENT = "lm:search";
interface Command {
id: string;
group: string;
label: string;
/** Extra words the label should also be found by. */
keywords?: string;
icon: React.ComponentType<{ className?: string }>;
hint?: string;
run: () => void;
}
interface SignedIn {
id: string;
email: string;
active: boolean;
}
/**
* The one place to reach anything: Ctrl+K or Cmd+K opens it.
*
* The screens stay quiet - a few folders, one main button - because everything
* else is a few keystrokes away here. Typing text that matches no command
* offers to search the mail for it, so it is also the search box.
*
* `c` writes a new message and `/` goes to the search field, from anywhere that
* is not itself a place to type.
*/
export function CommandPalette({ isAdmin }: { isAdmin: boolean }) {
const t = useTranslations();
const router = useRouter();
const { folders } = useFolders();
const [open, setOpen] = useState(false);
const [query, setQuery] = useState("");
const [cursor, setCursor] = useState(0);
const [accounts, setAccounts] = useState<SignedIn[]>([]);
const input = useRef<HTMLInputElement>(null);
const previouslyFocused = useRef<HTMLElement | null>(null);
const close = useCallback(() => {
setOpen(false);
setQuery("");
setCursor(0);
previouslyFocused.current?.focus?.();
}, []);
// Opening, and the two single-key shortcuts.
useEffect(() => {
const typingIn = (target: EventTarget | null) => {
const el = target as HTMLElement | null;
if (!el) return false;
return el.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(el.tagName);
};
const onKey = (event: KeyboardEvent) => {
if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "k") {
event.preventDefault();
previouslyFocused.current = document.activeElement as HTMLElement | null;
setOpen((v) => !v);
return;
}
if (event.metaKey || event.ctrlKey || event.altKey || typingIn(event.target)) return;
if (event.key === "c") {
event.preventDefault();
router.push("/user/compose");
} else if (event.key === "/") {
const field = document.getElementById("mail-search");
if (field) {
event.preventDefault();
field.focus();
}
}
};
window.addEventListener("keydown", onKey);
return () => window.removeEventListener("keydown", onKey);
}, [router]);
useEffect(() => {
if (!open) return;
input.current?.focus();
fetch("/api/v1/auth/accounts", { cache: "no-store" })
.then((res) => (res.ok ? res.json() : { accounts: [] }))
.then((data) => setAccounts(data.accounts ?? []))
.catch(() => undefined);
}, [open]);
const go = useCallback(
(href: string) => () => {
close();
router.push(href);
},
[close, router],
);
const commands = useMemo<Command[]>(() => {
const list: Command[] = [
{ id: "compose", group: t("palette.actions"), label: t("mail.compose"), keywords: "new write message nova escrever", icon: PenSquare, hint: "C", run: go("/user/compose") },
{ id: "settings", group: t("palette.actions"), label: t("settings.title"), keywords: "preferences signature filters", icon: Settings, run: go("/user/settings") },
{ id: "inbox", group: t("palette.navigate"), label: t("mail.inbox"), icon: Inbox, run: go("/user/mail/inbox") },
{ id: "sent", group: t("palette.navigate"), label: t("mail.sent"), icon: Send, run: go("/user/mail/sent") },
{ id: "drafts", group: t("palette.navigate"), label: t("mail.drafts"), icon: FileText, run: go("/user/mail/drafts") },
{ id: "archive", group: t("palette.navigate"), label: t("mail.archive"), icon: Archive, run: go("/user/mail/archive") },
{ id: "junk", group: t("palette.navigate"), label: t("mail.junk"), keywords: "spam", icon: AlertTriangle, run: go("/user/mail/junk") },
{ id: "trash", group: t("palette.navigate"), label: t("mail.trash"), keywords: "bin lixeira papelera", icon: Trash2, run: go("/user/mail/trash") },
{ id: "reports", group: t("palette.navigate"), label: t("mail.reports"), icon: ShieldCheck, run: go("/user/mail/reports") },
...customFolders(folders).map<Command>((folder) => ({
id: `folder-${folder.name}`,
group: t("palette.navigate"),
label: folder.name,
icon: FolderIcon,
run: go(`/user/mail/${encodeURIComponent(folder.name)}`),
})),
{ id: "theme-dark", group: t("palette.theme"), label: t("appearance.dark"), keywords: "theme tema", icon: Moon, run: () => { setThemeChoice("dark"); close(); } },
{ id: "theme-light", group: t("palette.theme"), label: t("appearance.light"), keywords: "theme tema", icon: Sun, run: () => { setThemeChoice("light"); close(); } },
{ id: "theme-system", group: t("palette.theme"), label: t("appearance.system"), keywords: "theme tema auto", icon: Laptop, run: () => { setThemeChoice("system"); close(); } },
...accounts
.filter((a) => !a.active)
.map<Command>((a) => ({
id: `account-${a.id}`,
group: t("palette.accounts"),
label: t("palette.switchTo", { email: a.email }),
keywords: a.email,
icon: UserRound,
run: () => {
void fetch("/api/v1/auth/switch", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ id: a.id }),
}).then((res) => {
if (res.ok) window.location.href = "/user/mail/inbox";
});
},
})),
{ id: "add-account", group: t("palette.accounts"), label: t("accounts.addAccount"), icon: Plus, run: go("/user/login?add=1") },
{
id: "signout",
group: t("palette.accounts"),
label: t("palette.signOut"),
icon: LogOut,
run: () => {
void fetch("/api/v1/auth/logout", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
})
.then((res) => (res.ok ? res.json() : { remaining: [] as unknown[] }))
.then((data: { remaining?: unknown[] }) => {
window.location.href = data.remaining?.length ? "/user/mail/inbox" : "/user/login";
});
},
},
];
if (isAdmin) {
// Next to the other actions, not at the end of the list, so the group stays whole.
list.splice(2, 0, { id: "admin", group: t("palette.actions"), label: t("admin.openAdminShort"), icon: ShieldCheck, run: go("/admin/dashboard") });
}
return list;
}, [t, go, close, folders, accounts, isAdmin]);
const shown = useMemo(() => {
const words = query.trim().toLowerCase().split(/\s+/).filter(Boolean);
if (words.length === 0) return commands;
return commands.filter((c) => {
const haystack = `${c.label} ${c.keywords ?? ""} ${c.group}`.toLowerCase();
return words.every((w) => haystack.includes(w));
});
}, [commands, query]);
// Typing something no command matches is a search, so Enter is never a dead key.
const searchText = query.trim();
const items: Command[] = useMemo(() => {
if (searchText.length === 0) return shown;
const search: Command = {
id: "search",
group: t("palette.actions"),
label: t("palette.searchFor", { query: searchText }),
icon: Search,
run: () => {
close();
router.push("/user/mail/inbox");
// The list mounts after the route changes, so the request waits a beat.
setTimeout(() => window.dispatchEvent(new CustomEvent(SEARCH_EVENT, { detail: searchText })), 250);
},
};
return shown.length === 0 ? [search] : [...shown, search];
}, [shown, searchText, t, close, router]);
useEffect(() => setCursor(0), [query]);
if (!open) return null;
const onKeyDown = (event: React.KeyboardEvent) => {
if (event.key === "Escape") {
event.preventDefault();
close();
} else if (event.key === "ArrowDown") {
event.preventDefault();
setCursor((c) => (c + 1) % Math.max(items.length, 1));
} else if (event.key === "ArrowUp") {
event.preventDefault();
setCursor((c) => (c - 1 + items.length) % Math.max(items.length, 1));
} else if (event.key === "Enter") {
event.preventDefault();
items[cursor]?.run();
}
};
// Group headings appear where the group changes, in the order given.
let lastGroup = "";
return (
<div className="fixed inset-0 z-50 flex items-start justify-center px-4 pt-[14vh]" onKeyDown={onKeyDown}>
<div className="absolute inset-0 bg-black/45 backdrop-blur-[2px]" onClick={close} aria-hidden />
<div
role="dialog"
aria-modal="true"
aria-label={t("palette.open")}
className="relative flex max-h-[min(520px,70vh)] w-full max-w-[580px] flex-col overflow-hidden rounded-2xl bg-dark-panel shadow-lift"
>
<div className="flex items-center gap-3 px-4 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)]">
<Search className="h-4 w-4 flex-none text-slate-400" aria-hidden />
<input
ref={input}
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={t("palette.placeholder")}
aria-label={t("palette.placeholder")}
role="combobox"
aria-expanded="true"
aria-controls="lm-palette-list"
aria-activedescendant={items[cursor] ? `lm-cmd-${items[cursor].id}` : undefined}
className="h-12 min-w-0 flex-1 bg-transparent text-[15px] text-slate-100 placeholder-slate-500 focus:outline-none"
/>
<kbd className="hidden rounded-md px-1.5 py-0.5 text-[11px] text-slate-400 shadow-edge sm:block">Esc</kbd>
</div>
<div id="lm-palette-list" role="listbox" className="overflow-y-auto p-1.5">
{items.length === 0 && <div className="px-3 py-6 text-center text-[13px] text-slate-400">{t("palette.empty")}</div>}
{items.map((item, index) => {
const heading = item.group !== lastGroup ? item.group : null;
lastGroup = item.group;
const Icon = item.icon;
return (
<React.Fragment key={item.id}>
{heading && <div className="px-3 pb-1 pt-2.5 text-[12px] font-medium text-slate-400">{heading}</div>}
<button
id={`lm-cmd-${item.id}`}
role="option"
aria-selected={index === cursor}
type="button"
onMouseMove={() => setCursor(index)}
onClick={item.run}
className={`flex w-full items-center gap-3 rounded-lg px-3 py-2 text-left text-[14px] ${
index === cursor ? "bg-indigo-500/15 text-slate-50" : "text-slate-200"
}`}
>
<Icon className={`h-4 w-4 flex-none ${index === cursor ? "text-indigo-300" : "text-slate-400"}`} />
<span className="min-w-0 flex-1 truncate">{item.label}</span>
{item.hint && <kbd className="rounded-md px-1.5 py-0.5 text-[11px] text-slate-400 shadow-edge">{item.hint}</kbd>}
</button>
</React.Fragment>
);
})}
</div>
<div className="hidden items-center gap-4 px-4 py-2 text-[12px] text-slate-400 shadow-[inset_0_1px_0_0_rgb(var(--ink)/0.09)] sm:flex">
<span className="inline-flex items-center gap-1.5">
<kbd className="rounded px-1 shadow-edge">&uarr;</kbd>
<kbd className="rounded px-1 shadow-edge">&darr;</kbd>
{t("palette.hintMove")}
</span>
<span className="inline-flex items-center gap-1.5">
<CornerDownLeft className="h-3 w-3" aria-hidden />
{t("palette.hintOpen")}
</span>
</div>
</div>
</div>
);
}
+8 -8
View File
@@ -378,12 +378,12 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
};
/** One row of the compose sheet: a fixed-width label beside the control. */
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]";
const fieldRow = "flex flex-wrap items-center gap-x-3 gap-y-2 px-4 py-2.5 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.07)]";
const fieldLabel = "w-14 flex-none text-[12.5px] leading-snug text-slate-400";
const bareInput =
"min-h-[30px] min-w-[160px] flex-1 border-0 bg-transparent text-sm text-slate-100 placeholder-slate-500 focus:outline-none";
const toolButton =
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-white/[0.07] hover:text-slate-100";
"flex h-8 w-8 items-center justify-center rounded-lg text-slate-300 transition-colors hover:bg-ink/[0.07] hover:text-slate-100";
return (
<div className={inline ? "" : "flex-1 overflow-y-auto bg-dark-bg px-5 pb-11 pt-7 sm:px-8"}>
@@ -409,7 +409,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
disabled={sending}
title={t("mail.highImportanceHint")}
className={`inline-flex h-10 items-center gap-2 rounded-xl px-3.5 text-sm shadow-edge transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-white/[0.07]"
important ? "bg-rose-900/70 text-rose-100" : "bg-dark-card text-slate-200 hover:bg-ink/[0.07]"
}`}
>
<Flag className="h-4 w-4" fill={important ? "currentColor" : "none"} />
@@ -531,7 +531,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
)}
{/* Formatting Toolbar */}
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.07)]">
<div className="flex flex-wrap items-center gap-0.5 px-3 py-2 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.07)]">
<button type="button" onClick={() => executeFormat("bold")} className={toolButton} title={t("mail.bold")}>
<Bold className="h-4 w-4" />
</button>
@@ -554,7 +554,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
>
<Strikethrough className="h-4 w-4" />
</button>
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
<span className="mx-1.5 h-[18px] w-px bg-ink/[0.14]" />
<button
type="button"
onClick={() => executeFormat("insertUnorderedList")}
@@ -571,7 +571,7 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
>
<ListOrdered className="h-4 w-4" />
</button>
<span className="mx-1.5 h-[18px] w-px bg-white/[0.14]" />
<span className="mx-1.5 h-[18px] w-px bg-ink/[0.14]" />
<button
type="button"
onClick={() => {
@@ -598,8 +598,8 @@ export function Composer({ initial, variant = "page", onSent, onDiscarded }: Com
/>
{/* Attachment Selector & Display */}
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgba(233,233,237,0.07)]">
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-white/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07]">
<div className="flex flex-wrap items-center gap-2.5 px-4 py-3.5 shadow-[inset_0_1px_0_0_rgb(var(--ink)/0.07)]">
<label className="flex flex-none cursor-pointer items-center gap-2 rounded-[10px] border border-ink/[0.14] px-3 py-2 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07]">
<Paperclip className="h-[15px] w-[15px]" />
<span>{t("mail.addFiles")}</span>
<input type="file" multiple onChange={handleFileUpload} className="hidden" />
+36
View File
@@ -0,0 +1,36 @@
import React, { useId } from "react";
/**
* The LambdaMail mark: a lambda on an ultramarine tile, with the flap of an
* envelope lit across the top. The same drawing as app/icon.svg, inline so it
* costs no request and takes any size.
*/
export function Logo({ size = 32, className = "" }: { size?: number; className?: string }) {
// Several of these can be on one page, and a gradient id must be unique.
const id = useId().replace(/:/g, "");
return (
<svg
viewBox="0 0 64 64"
width={size}
height={size}
className={className}
role="img"
aria-label="LambdaMail"
>
<defs>
<linearGradient id={`tile-${id}`} x1="0" y1="0" x2="0" y2="1">
<stop offset="0" stopColor="#6F7EFF" />
<stop offset="1" stopColor="#3441D8" />
</linearGradient>
</defs>
<rect width="64" height="64" rx="15" fill={`url(#tile-${id})`} />
<path
d="M0 15C0 6.7 6.7 0 15 0h34c8.3 0 15 6.7 15 15v2L32 40 0 17z"
fill="#fff"
fillOpacity=".11"
/>
<path d="M21 14.5 43 50" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
<path d="M32.1 32.4 21 50" fill="none" stroke="#fff" strokeWidth="6" strokeLinecap="round" strokeLinejoin="round" />
</svg>
);
}
@@ -30,7 +30,7 @@ export function MessageFrame({ document: html }: { document: string }) {
}, []);
return (
<div className="overflow-hidden rounded-xl bg-white shadow-edge">
<div className="overflow-hidden rounded-xl bg-white shadow-edge ring-1 ring-ink/10">
<iframe
ref={frame}
title={t("ui.messageBody")}
@@ -105,21 +105,21 @@ function initialsOf(name: string, address: string): string {
/** A short type tag and colour for an attachment, from its extension. */
function attachmentKind(filename: string): { tag: string; className: string } {
const ext = (filename.split(".").pop() ?? "").toLowerCase();
if (ext === "pdf") return { tag: "PDF", className: "bg-rose-700" };
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-sky-700" };
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-blue-700" };
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-emerald-700" };
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-amber-700" };
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-slate-600" };
if (ext === "pdf") return { tag: "PDF", className: "bg-[#c2352f]" };
if (["png", "jpg", "jpeg", "gif", "webp", "bmp"].includes(ext)) return { tag: "IMG", className: "bg-[#1f7aa8]" };
if (["doc", "docx", "odt", "rtf", "txt"].includes(ext)) return { tag: "DOC", className: "bg-[#2f5fb8]" };
if (["xls", "xlsx", "ods", "csv"].includes(ext)) return { tag: "XLS", className: "bg-[#1f8a5b]" };
if (["zip", "rar", "7z", "gz", "tar"].includes(ext)) return { tag: "ZIP", className: "bg-[#a8690f]" };
return { tag: (ext || "FILE").slice(0, 4).toUpperCase(), className: "bg-[#5b6272]" };
}
// One look for every control on the command bar, so the bar reads as a single
// strip. The label hides on narrow screens and the icon stays, which is what
// keeps six actions on one row at phone width.
const commandClass =
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
const infoLinkClass =
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
export function MessageReader(props: Props) {
const { message, row, folder, actions } = props;
@@ -181,7 +181,7 @@ export function MessageReader(props: Props) {
<div
role="toolbar"
aria-label={t("mail.messageActions")}
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgba(233,233,237,0.09)] sm:px-4"
className="flex flex-none flex-wrap items-center gap-0.5 bg-dark-bg px-2 py-1.5 shadow-[0_1px_0_0_rgb(var(--ink)/0.09)] sm:px-4"
>
<button
type="button"
@@ -218,7 +218,7 @@ export function MessageReader(props: Props) {
</Link>
)}
<span aria-hidden className="mx-1 h-5 w-px bg-white/[0.12]" />
<span aria-hidden className="mx-1 h-5 w-px bg-ink/[0.12]" />
<button type="button" onClick={props.onDelete} className={commandClass} title={deleteLabel}>
<Trash2 className="h-4 w-4" />
@@ -252,7 +252,7 @@ export function MessageReader(props: Props) {
setMoveOpen(false);
props.onMove(target.name);
}}
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:bg-white/[0.07] focus-visible:outline-none"
className="block w-full px-3.5 py-2.5 text-left text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:bg-ink/[0.07] focus-visible:outline-none"
>
{target.label}
</button>
@@ -321,7 +321,7 @@ export function MessageReader(props: Props) {
address={row?.sender_address ?? address}
initials={initialsOf(displayName, address)}
size={44}
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
/>
<div className="flex min-w-0 flex-1 flex-col gap-0.5">
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
@@ -339,7 +339,7 @@ export function MessageReader(props: Props) {
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
props.senderTrusted
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
: "text-slate-400 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] hover:bg-ink/[0.07] hover:text-slate-200"
}`}
>
<ShieldCheck className="h-3 w-3" aria-hidden />
@@ -364,7 +364,7 @@ export function MessageReader(props: Props) {
type="button"
onClick={() => setRecipientsOpen((open) => !open)}
aria-expanded={recipientsOpen}
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
className="ml-1 inline-flex items-center gap-0.5 rounded px-1 text-indigo-300 hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400"
>
{recipientsOpen ? t("mail.showFewerRecipients") : `+${hiddenCount}`}
<ChevronDown className={`h-3 w-3 transition-transform ${recipientsOpen ? "rotate-180" : ""}`} />
@@ -103,7 +103,7 @@ export function RecipientInput({ id, value, onChange, placeholder, disabled, inv
title={parsed.valid ? parsed.address : t("mail.invalidAddress")}
className={`inline-flex max-w-full items-center gap-1 rounded-full py-0.5 pl-2.5 pr-1 text-[12.5px] ${
parsed.valid
? "bg-indigo-900/70 text-indigo-100 shadow-[inset_0_0_0_1px_#5d5294]"
? "bg-indigo-900/70 text-indigo-100 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
: "bg-rose-900/60 text-rose-100 shadow-[inset_0_0_0_1px_rgba(244,63,94,0.5)]"
}`}
>
@@ -116,7 +116,7 @@ export function RecipientInput({ id, value, onChange, placeholder, disabled, inv
remove(index);
}}
aria-label={t("mail.removeRecipient", { name: parsed.label })}
className="flex h-5 w-5 flex-none items-center justify-center rounded-full hover:bg-white/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
className="flex h-5 w-5 flex-none items-center justify-center rounded-full hover:bg-ink/15 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-300"
>
<X className="h-3 w-3" />
</button>
@@ -0,0 +1,37 @@
"use client";
import React from "react";
import { Laptop, Moon, Sun } from "lucide-react";
import { useTranslations } from "../i18n/provider";
import { useTheme, type ThemeChoice } from "../lib/theme";
/** Dark, light or follow the system: three buttons, one click each. */
export function ThemeToggle({ className = "" }: { className?: string }) {
const t = useTranslations();
const theme = useTheme();
const options: [ThemeChoice, React.ComponentType<{ className?: string }>, string][] = [
["dark", Moon, t("appearance.dark")],
["light", Sun, t("appearance.light")],
["system", Laptop, t("appearance.system")],
];
return (
<div role="radiogroup" aria-label={t("appearance.title")} className={`flex rounded-lg p-0.5 shadow-edge ${className}`}>
{options.map(([value, Icon, label]) => (
<button
key={value}
type="button"
role="radio"
aria-checked={theme.choice === value}
aria-label={label}
title={label}
onClick={() => theme.set(value)}
className={`flex h-7 w-8 items-center justify-center rounded-md transition-colors ${
theme.choice === value ? "bg-indigo-500/20 text-indigo-300" : "text-slate-400 hover:text-slate-100"
}`}
>
<Icon className="h-3.5 w-3.5" />
</button>
))}
</div>
);
}
@@ -75,11 +75,11 @@ interface Props {
}
const commandClass =
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
"inline-flex h-10 min-w-10 items-center justify-center gap-1.5 rounded-lg px-2.5 text-[13px] text-slate-200 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 md:px-3";
const infoLinkClass =
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
"rounded-md px-1.5 py-1 text-[12.5px] font-medium text-indigo-300 transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
const replyButtonClass =
"inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] transition-colors hover:bg-white/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
"inline-flex h-10 items-center gap-2 rounded-xl px-4 text-[13.5px] text-slate-200 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] transition-colors hover:bg-ink/[0.07] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400";
/** The name and address in a header like `Jenny <jenny@x.test>`. */
function splitSender(from: string): { name: string; address: string } {
@@ -306,7 +306,7 @@ export function ThreadReader(props: Props) {
aria-label={thread.subject}
>
{/* The commands that apply to the whole conversation. */}
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 shadow-[inset_0_-1px_0_0_rgba(233,233,237,0.09)] sm:px-4">
<div className="flex flex-wrap items-center gap-0.5 px-2 py-1.5 shadow-[inset_0_-1px_0_0_rgb(var(--ink)/0.09)] sm:px-4">
<button type="button" onClick={props.onClose} className={`${commandClass} md:hidden`} aria-label={t("mail.backToList")}>
<ArrowLeft className="h-4 w-4" />
</button>
@@ -320,7 +320,7 @@ export function ThreadReader(props: Props) {
<span className="hidden sm:inline">{t("mail.replyAll")}</span>
</button>
)}
<span className="mx-1 hidden h-5 w-px bg-white/10 sm:block" />
<span className="mx-1 hidden h-5 w-px bg-ink/10 sm:block" />
<button type="button" onClick={() => void deleteThread()} disabled={busy || inThisFolder.length === 0} className={commandClass}>
<Trash2 className="h-4 w-4" />
<span className="hidden lg:inline">{props.deleteIsPermanent ? t("mail.deleteForever") : t("common.delete")}</span>
@@ -339,14 +339,14 @@ export function ThreadReader(props: Props) {
<ChevronDown className="h-3 w-3" />
</button>
{moveOpen && (
<div role="menu" className="absolute left-0 top-full z-30 mt-1 min-w-[180px] rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-white/10">
<div role="menu" className="absolute left-0 top-full z-30 mt-1 min-w-[180px] rounded-xl bg-dark-panel p-1.5 shadow-edge ring-1 ring-ink/10">
{props.moveTargets.map((target) => (
<button
key={target.name}
role="menuitem"
type="button"
onClick={() => void moveThread(target.name)}
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-200 hover:bg-white/[0.07]"
className="flex w-full items-center rounded-lg px-2.5 py-2 text-left text-[13px] text-slate-200 hover:bg-ink/[0.07]"
>
{target.label}
</button>
@@ -422,7 +422,7 @@ export function ThreadReader(props: Props) {
address={info.sender_address}
initials={initialsOf(displayName, info.sender_address)}
size={isOpen ? 40 : 30}
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_#5d5294]"
className="bg-indigo-900 text-indigo-300 shadow-[inset_0_0_0_1px_rgb(var(--a-700))]"
/>
<span className="flex min-w-0 flex-1 flex-col">
<span className="flex items-baseline gap-2">
@@ -430,7 +430,7 @@ export function ThreadReader(props: Props) {
{displayName}
</span>
{isOpen && !info.own && sender.address && (
<span className="min-w-0 truncate text-[12.5px] text-slate-400">&lt;{sender.address}&gt;</span>
<span className="hidden min-w-0 truncate text-[12.5px] text-slate-400 sm:inline">&lt;{sender.address}&gt;</span>
)}
</span>
{!isOpen && (
@@ -477,7 +477,7 @@ export function ThreadReader(props: Props) {
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-indigo-400 ${
trustedHere
? "bg-indigo-500/20 text-indigo-200 hover:bg-indigo-500/30"
: "text-slate-400 shadow-[inset_0_0_0_1px_rgba(233,233,237,0.18)] hover:bg-white/[0.07] hover:text-slate-200"
: "text-slate-400 shadow-[inset_0_0_0_1px_rgb(var(--ink)/0.18)] hover:bg-ink/[0.07] hover:text-slate-200"
}`}
>
<ShieldCheck className="h-3 w-3" aria-hidden />
@@ -486,7 +486,7 @@ export function ThreadReader(props: Props) {
)}
<Link
href={`/user/compose?mode=forward&subject=${encodeURIComponent(loaded.subject)}`}
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-indigo-300 hover:bg-white/[0.07]"
className="ml-auto inline-flex items-center gap-1 rounded-md px-1.5 py-1 text-indigo-300 hover:bg-ink/[0.07]"
>
<Forward className="h-3.5 w-3.5" aria-hidden />
{t("mail.forward")}
@@ -86,7 +86,7 @@ export function TotpEnrolment({ secret, uri }: { secret: string; uri: string | n
<button
type="button"
onClick={() => void copySecret()}
className="flex-none rounded-[10px] border border-white/[0.14] px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-white/[0.07] hover:text-slate-100"
className="flex-none rounded-[10px] border border-ink/[0.14] px-3 py-2 text-xs text-slate-200 transition-colors hover:bg-ink/[0.07] hover:text-slate-100"
>
{copied ? t("common.copied") : t("common.copy")}
</button>
@@ -132,7 +132,7 @@ export function RecoveryCodes({
<p className="mb-3 leading-relaxed">{t("settings.saveRecoveryCodes")}</p>
<div className="lm-code grid grid-cols-2 gap-1.5 p-3 text-center text-[11px] sm:grid-cols-3">
{codes.map((code) => (
<span key={code} className="rounded bg-white/[0.05] p-1">
<span key={code} className="rounded bg-ink/[0.05] p-1">
{code}
</span>
))}
@@ -143,7 +143,7 @@ export function RecoveryCodes({
<button
type="button"
onClick={() => void copyAll()}
className="flex-1 rounded-[10px] border border-white/[0.14] py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-white/[0.07]"
className="flex-1 rounded-[10px] border border-ink/[0.14] py-2.5 text-sm font-medium text-slate-200 transition-colors hover:bg-ink/[0.07]"
>
{copied ? t("common.copied") : t("common.copy")}
</button>
@@ -30,11 +30,11 @@ export function Button({
const variants = {
primary:
'border-indigo-500 text-indigo-500 bg-transparent hover:bg-indigo-500/[0.12] active:bg-indigo-500/[0.22]',
'border-transparent bg-fill text-white shadow-sm hover:bg-fill-hover active:brightness-95',
secondary:
'border-white/[0.14] text-slate-100 bg-transparent hover:bg-white/[0.07] active:bg-white/[0.14]',
'border-ink/[0.14] text-slate-100 bg-transparent hover:bg-ink/[0.07] active:bg-ink/[0.14]',
outline:
'border-white/[0.14] text-slate-300 bg-transparent hover:text-slate-100 hover:bg-white/[0.07] active:bg-white/[0.14]',
'border-ink/[0.14] text-slate-300 bg-transparent hover:text-slate-100 hover:bg-ink/[0.07] active:bg-ink/[0.14]',
danger:
'border-rose-400 text-rose-400 bg-transparent hover:bg-rose-400/[0.12] active:bg-rose-400/[0.22]',
ghost:
+94
View File
@@ -0,0 +1,94 @@
"use client";
import { useCallback, useEffect, useState } from "react";
/**
* The interface has two themes, dark and light, and a third choice that
* follows the system. Dark is what a first visit gets.
*
* The choice is stored in this browser, not on the account: a laptop in a
* bright office and a phone in bed are different answers to the same question.
*/
export type ThemeChoice = "dark" | "light" | "system";
export type Theme = "dark" | "light";
export const THEME_KEY = "lm_theme";
const CHANGED = "lm:theme-changed";
/**
* Runs in <head>, before anything is painted, so the page never shows the
* wrong theme for a moment. It is a string rather than a function because it
* has to run as written, outside React, and must not throw: a browser that
* refuses storage still gets the default.
*/
export const THEME_BOOT_SCRIPT = `(function(){var d=document.documentElement,t="dark";try{var c=localStorage.getItem("${THEME_KEY}");if(c==="light")t="light";else if(c==="system")t=matchMedia("(prefers-color-scheme: light)").matches?"light":"dark"}catch(e){}d.setAttribute("data-theme",t);d.style.colorScheme=t})();`;
export function readChoice(): ThemeChoice {
try {
const stored = window.localStorage.getItem(THEME_KEY);
return stored === "light" || stored === "system" ? stored : "dark";
} catch {
return "dark";
}
}
/** What a choice means right now: "system" is whatever the system is set to. */
export function resolve(choice: ThemeChoice): Theme {
if (choice === "system") {
return window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
return choice;
}
function apply(theme: Theme) {
const root = document.documentElement;
root.setAttribute("data-theme", theme);
root.style.colorScheme = theme;
// Keeps the browser's own bar in step with a choice made on the page, which
// the viewport metadata alone cannot follow.
const meta = document.querySelector<HTMLMetaElement>('meta[name="theme-color"]:not([media])');
if (meta) meta.content = theme === "light" ? "#F3F4F7" : "#0F1218";
}
export function setThemeChoice(choice: ThemeChoice) {
try {
window.localStorage.setItem(THEME_KEY, choice);
} catch {
// The choice holds for this visit and is not remembered.
}
apply(resolve(choice));
window.dispatchEvent(new Event(CHANGED));
}
export function useTheme() {
const [choice, setChoice] = useState<ThemeChoice>("dark");
const [theme, setTheme] = useState<Theme>("dark");
useEffect(() => {
const sync = () => {
const next = readChoice();
setChoice(next);
setTheme(resolve(next));
};
sync();
const media = window.matchMedia("(prefers-color-scheme: light)");
const onSystem = () => {
if (readChoice() === "system") {
apply(resolve("system"));
sync();
}
};
window.addEventListener(CHANGED, sync);
window.addEventListener("storage", sync);
media.addEventListener("change", onSystem);
return () => {
window.removeEventListener(CHANGED, sync);
window.removeEventListener("storage", sync);
media.removeEventListener("change", onSystem);
};
}, []);
const set = useCallback((next: ThemeChoice) => setThemeChoice(next), []);
return { choice, theme, set };
}
@@ -0,0 +1,146 @@
import { describe, it, expect, afterEach } from "vitest";
import { readFileSync, readdirSync, statSync, existsSync } from "node:fs";
import { join, resolve } from "node:path";
import { THEME_BOOT_SCRIPT, THEME_KEY } from "./lib/theme";
/**
* The two themes, and what keeps them from drifting apart.
*
* Every colour is a variable defined once per theme, and the screens read the
* Tailwind scales that point at those variables. The failures worth guarding
* are the quiet ones: a variable one theme forgot, a class that bypasses the
* variables and is right in dark and unreadable in light, and a first paint in
* the wrong theme.
*/
const root = process.cwd();
const read = (path: string) => readFileSync(resolve(root, path), "utf8");
function sourceFiles(dir: string): string[] {
const out: string[] = [];
for (const entry of readdirSync(dir)) {
const path = join(dir, entry);
if (statSync(path).isDirectory()) out.push(...sourceFiles(path));
else if (path.endsWith(".tsx")) out.push(path);
}
return out;
}
describe("the theme variables", () => {
const css = read("src/app/globals.css");
const block = (selector: string) => {
const start = css.indexOf(selector);
return css.slice(start, css.indexOf("\n}", start));
};
const names = (text: string) => [...text.matchAll(/--([a-z0-9-]+):/g)].map((m) => m[1]).sort();
it("defines the same variables in dark and in light", () => {
const dark = names(block(":root, [data-theme='dark']"));
const light = names(block("[data-theme='light']"));
expect(dark.length).toBeGreaterThan(50);
expect(light).toEqual(dark);
});
it("keeps the ramps running the same way: 100 is the text, 950 the ground", () => {
const channel = (selector: string, name: string) =>
block(selector)
.match(new RegExp(`--${name}: (\\d+) (\\d+) (\\d+);`))!
.slice(1)
.map(Number)
.reduce((a, b) => a + b, 0);
const dark = ":root, [data-theme='dark']";
const light = "[data-theme='light']";
// In dark the text is lighter than the ground; in light it is darker.
expect(channel(dark, "n-100")).toBeGreaterThan(channel(dark, "n-950"));
expect(channel(light, "n-100")).toBeLessThan(channel(light, "n-950"));
});
});
describe("the first paint", () => {
afterEach(() => {
document.documentElement.removeAttribute("data-theme");
localStorage.clear();
});
const boot = () => new Function(THEME_BOOT_SCRIPT)();
it("is dark when nothing was ever chosen", () => {
boot();
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("is light when light was chosen", () => {
localStorage.setItem(THEME_KEY, "light");
boot();
expect(document.documentElement.getAttribute("data-theme")).toBe("light");
});
it("is dark for anything it does not recognise", () => {
localStorage.setItem(THEME_KEY, "purple");
boot();
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
});
it("is dark, and does not throw, when storage refuses", () => {
const original = Storage.prototype.getItem;
Storage.prototype.getItem = () => {
throw new Error("blocked");
};
try {
expect(() => boot()).not.toThrow();
expect(document.documentElement.getAttribute("data-theme")).toBe("dark");
} finally {
Storage.prototype.getItem = original;
}
});
it("is set before anything renders: the layout puts it in the head", () => {
expect(read("src/app/layout.tsx")).toMatch(/<head>[\s\S]*THEME_BOOT_SCRIPT[\s\S]*<\/head>/);
});
});
describe("colours that would only be right in one theme", () => {
const files = sourceFiles(resolve(root, "src"));
// white at low opacity is a tint that vanishes on a light ground; `ink` is the
// colour that sits on whichever ground there is.
it("does not tint with white", () => {
for (const file of files) {
expect(readFileSync(file, "utf8"), file).not.toMatch(/\b(bg|border|text|ring|shadow|divide)-white\/\d/);
}
});
it("does not hard-code the old accent", () => {
for (const file of files) {
expect(readFileSync(file, "utf8"), file).not.toMatch(/#9184d9|#5d5294/i);
}
});
it("reads colours from variables in the Tailwind config", () => {
const config = read("tailwind.config.js");
expect(config).toContain("var(--${name}-${step})");
expect(config).toContain("[data-theme='dark']");
});
});
describe("the app's identity", () => {
it("has a tab icon, a home-screen icon and an install manifest", () => {
for (const file of ["src/app/icon.svg", "src/app/favicon.ico", "src/app/apple-icon.png", "src/app/manifest.ts"]) {
expect(existsSync(resolve(root, file)), file).toBe(true);
}
for (const file of ["icon-192.png", "icon-512.png", "maskable-512.png"]) {
expect(existsSync(resolve(root, "public/icons", file)), file).toBe(true);
}
});
it("serves its font itself rather than asking a third party on every visit", () => {
expect(read("src/app/globals.css")).not.toMatch(/fonts\.googleapis\.com/);
expect(read("src/app/layout.tsx")).toContain("@fontsource-variable/geist");
});
it("offers the choice of theme on the account menu, the admin console and both sign-ins", () => {
expect(read("src/components/AccountSwitcher.tsx")).toContain("ThemeToggle");
expect(read("src/app/(admin)/admin/layout.tsx")).toContain("ThemeToggle");
expect(read("src/app/(auth)/user/login/page.tsx")).toContain("ThemeToggle");
expect(read("src/app/(auth)/admin/login/page.tsx")).toContain("ThemeToggle");
});
});
+43 -90
View File
@@ -1,83 +1,31 @@
/**
* Nocturne, expressed as Tailwind scales.
* The interface's colours, as Tailwind scales that read CSS variables.
*
* The redesign replaces two visual languages with one: the webmail's indigo and
* the console's green both become the single blurple accent, and every grey
* comes from one perceptual ramp instead of Tailwind's stock slate. Rather than
* rename several thousand utility classes across the screens, the scales those
* classes already name are remapped onto the design system's own values - so
* `bg-slate-900`, `text-emerald-400` and `border-indigo-500/30` all resolve
* into Nocturne, and a colour cannot enter the interface from outside it.
*
* The values here are the same ones globals.css carries as CSS variables.
* The values live in globals.css, once per theme. The scales the screens
* already name - slate for neutrals, indigo for the accent, amber, rose and
* emerald for the signals - point at those variables, so switching theme
* changes what `text-slate-400` is without touching a class. `ink` is the
* colour that sits on the ground (white on dark, near-black on light): hover
* tints and hairlines are `ink` at low opacity.
*
* @type {import('tailwindcss').Config}
*/
const ramp = (name) =>
Object.fromEntries(
[50, 100, 200, 300, 400, 500, 600, 700, 800, 900, 950].map((step) => [
step,
`rgb(var(--${name}-${step}) / <alpha-value>)`,
]),
);
// The neutral ramp, ordered the way Tailwind expects (light to dark) so the
// existing `text-slate-400` / `bg-slate-900` idiom keeps meaning what it meant.
const neutral = {
50: '#f3f5fe',
100: '#e9e9ed', // body text
200: '#e4e7f5',
300: '#cfd3e5',
400: '#9397ab', // muted text
500: '#75798c',
600: '#595d6c',
700: '#3f424d', // borders
800: '#232532', // card
900: '#1b1c28', // panel
950: '#161826', // page ground
};
// The one accent, in the product's own blurple (OKLCH hue 289).
const accent = {
50: '#faf9ff',
100: '#f5f4ff',
200: '#e7e5fe',
300: '#d2cefd',
400: '#b5abfc',
500: '#9184d9',
600: '#796cbf',
700: '#5d5294',
800: '#423a6a',
900: '#2b2741',
950: '#211d33',
};
// Warning and danger are the only other hues, and both are desaturated: on this
// ground a saturated amber or rose reads as an error in the design, not in the
// data.
const warn = {
50: '#fbf8ee',
100: '#f6efd8',
200: '#ecdfb2',
300: '#dfcb8b',
400: '#cbb46a',
500: '#b39b52',
600: '#8f7b41',
700: '#6b5c31',
800: '#473d21',
900: '#2f2917',
950: '#1f1b10',
};
const bad = {
50: '#fcf3f4',
100: '#f8e3e5',
200: '#f0c8cc',
300: '#e2a8ae',
400: '#d08a92',
500: '#b96f78',
600: '#985860',
700: '#74434a',
800: '#4e2e33',
900: '#3a2a2d',
950: '#241a1c',
};
const neutral = ramp('n');
const accent = ramp('a');
const warn = ramp('w');
const bad = ramp('b');
const good = ramp('g');
module.exports = {
darkMode: 'class',
darkMode: ['selector', "[data-theme='dark']"],
content: [
'./src/pages/**/*.{js,ts,jsx,tsx,mdx}',
'./src/components/**/*.{js,ts,jsx,tsx,mdx}',
@@ -90,46 +38,51 @@ module.exports = {
gray: neutral,
zinc: neutral,
neutral: neutral,
// Every accent name in the codebase collapses onto the one accent. The
// console's emerald and teal are the point of the exercise: the two
// surfaces are one product and now look like it.
// Every blue, violet and teal name in the codebase is the one accent.
indigo: accent,
violet: accent,
purple: accent,
emerald: accent,
teal: accent,
cyan: accent,
sky: accent,
blue: accent,
brand: accent,
// Green means answered, trusted and healthy, and nothing else.
emerald: good,
teal: good,
green: good,
amber: warn,
yellow: warn,
orange: warn,
rose: bad,
red: bad,
pink: bad,
ink: 'rgb(var(--ink) / <alpha-value>)',
// The solid fill of the one main action on a screen.
fill: {
DEFAULT: 'rgb(var(--a-fill) / <alpha-value>)',
hover: 'rgb(var(--a-fill-hover) / <alpha-value>)',
},
dark: {
rail: '#12131f',
bg: '#161826',
panel: '#1b1c28',
card: '#232532',
border: 'rgba(233, 233, 237, 0.09)',
rail: 'rgb(var(--rail) / <alpha-value>)',
bg: 'rgb(var(--n-950) / <alpha-value>)',
panel: 'rgb(var(--n-900) / <alpha-value>)',
card: 'rgb(var(--n-800) / <alpha-value>)',
border: 'rgb(var(--ink) / 0.09)',
},
},
fontFamily: {
sans: ['Inter', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['ui-monospace', 'Menlo', 'Consolas', 'monospace'],
sans: ['"Geist Variable"', 'Geist', 'system-ui', '-apple-system', 'sans-serif'],
mono: ['ui-monospace', '"SF Mono"', 'Menlo', 'Consolas', 'monospace'],
},
borderRadius: {
xl: '12px',
'2xl': '14px',
'2xl': '16px',
},
boxShadow: {
// On a dark ground elevation is an edge plus ambient darkness, never a
// stack of coloured glows.
edge: 'inset 0 0 0 1px rgba(233, 233, 237, 0.09)',
'edge-accent': 'inset 0 0 0 1px rgba(145, 132, 217, 0.4)',
lift: '0 0 0 1px #3f424d, 0 16px 40px rgba(0, 0, 0, 0.5)',
// Elevation is a hairline plus ambient shade; never a coloured glow.
edge: 'inset 0 0 0 1px rgb(var(--ink) / 0.09)',
'edge-accent': 'inset 0 0 0 1px rgb(var(--a-500) / 0.45)',
lift: '0 0 0 1px rgb(var(--ink) / 0.12), var(--lm-shadow)',
},
},
},