feat(webmail): a better reader and a drawn logo

The mark is rebuilt as a lambda of two square-cut strokes of one weight, joined
without a seam, on a flat ultramarine tile with continuous corners: the rounded
strokes and the gradient are gone. A message written plainly is now drawn in the
app's own colours instead of a white slab, with text too dark for the ground
lifted and white backdrops cleared; a designed one (newsletter, receipt) keeps
its white sheet. A reply's quoted history folds behind a button. A narrower
reading column, a larger title, and a way to see a plain message as sent.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Lucas RayanandClaude Sonnet 5.5 committed 2026-10-23 05:57:52 -03:00
1 parent 9e50b47b5d
commit fece870d49
17 files changed
+336 -77

No files matched your search

@@ -30,6 +30,7 @@ import {
type ThreadSummary,
} from "../lib/conversations";
import { hasBlockedRemoteContent, messageDocument } from "../lib/renderMessage";
import { useTheme } from "../lib/theme";
import { offersReplyAll, replyAllRecipients, replyRecipients } from "../lib/recipients";
import { plainSnippet } from "../lib/snippet";
import { notifyMailStateChanged } from "../lib/useFolders";
@@ -97,6 +98,9 @@ function initialsOf(name: string, address: string): string {
export function ThreadReader(props: Props) {
const { thread, folder, ownAddress, trusted } = props;
const { t, locale } = useI18n();
const { theme } = useTheme();
// Plain messages that are shown as sent instead of in the app's colours.
const [originals, setOriginals] = useState<Set<string>>(new Set());
const [infos, setInfos] = useState<ThreadMessageInfo[] | null>(null);
const [error, setError] = useState<string | null>(null);
@@ -365,13 +369,15 @@ export function ThreadReader(props: Props) {
</div>
<div className="min-h-0 flex-1 overflow-y-auto">
<div className="mx-auto flex w-full max-w-[1000px] flex-col gap-3 px-4 pb-10 pt-5 sm:px-7 2xl:max-w-[1140px]">
<div className="mx-auto flex w-full max-w-[820px] flex-col gap-3 px-4 pb-14 pt-6 sm:px-8">
<div className="flex flex-col gap-2">
<h1 className="text-[19px] font-semibold leading-snug tracking-[-0.01em] text-slate-100 [overflow-wrap:anywhere] [text-wrap:pretty] sm:text-[22px]">
<h1 className="text-[22px] font-semibold leading-[1.22] tracking-[-0.022em] text-slate-50 [overflow-wrap:anywhere] [text-wrap:balance] sm:text-[27px]">
{thread.subject || t("mail.noSubject")}
</h1>
<div className="flex flex-wrap items-center gap-2 text-[12.5px] text-slate-400">
<span>{t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })}</span>
{(infos?.length ?? thread.message_count) > 1 && (
<span>{t("conversations.messagesInConversation", { count: infos?.length ?? thread.message_count })}</span>
)}
{infos && waitingOnMe && lastIncoming && (
<span
className={`inline-flex items-center gap-1 rounded-full px-2 py-0.5 text-[11.5px] font-medium ${
@@ -404,7 +410,7 @@ export function ThreadReader(props: Props) {
const displayName = info.own ? t("conversations.me") : sender.name || info.from_display_name || sender.address;
const trustedHere = loaded ? trusted.isTrusted(loaded.from) : false;
const showImages = trustedHere || imagesOnce.has(key);
const doc = loaded ? messageDocument(loaded, showImages) : null;
const rendered = loaded ? messageDocument(loaded, showImages, { theme, original: originals.has(key) }) : null;
const blocked = loaded ? hasBlockedRemoteContent(loaded) : false;
return (
@@ -532,10 +538,29 @@ export function ThreadReader(props: Props) {
</div>
)}
{doc ? (
<MessageFrame document={doc} />
{rendered?.canAdapt && (
<div className="-mb-1 flex justify-end">
<button
type="button"
onClick={() =>
setOriginals((current) => {
const next = new Set(current);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
})
}
className={infoLinkClass}
>
{originals.has(key) ? t("viewer.adapt") : t("viewer.asSent")}
</button>
</div>
)}
{rendered ? (
<MessageFrame document={rendered.document} paper={rendered.paper} />
) : (
<pre className="whitespace-pre-wrap break-words rounded-xl bg-dark-bg px-4 py-4 font-sans text-[14.5px] leading-relaxed text-slate-200">
<pre className="whitespace-pre-wrap break-words font-sans text-[15.5px] leading-[1.68] text-slate-100">
{loaded.text || t("mail.emptyBody")}
</pre>
)}