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:
1 parent
9e50b47b5d
commit
fece870d49
17 files changed
+336
-77
No files matched your search
@@ -33,18 +33,69 @@ export function resolveInlineImages(html: string, images: InlineImages): string
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a message was designed, as a newsletter or a receipt is, rather than
|
||||
* just written, as a letter between two people is.
|
||||
*
|
||||
* A designed message brings its own colours, tables and background images, and
|
||||
* they only make sense on the white sheet it was drawn for. A written one has
|
||||
* nothing of the kind and reads best when it takes the app's own colours - on a
|
||||
* dark ground, a plain note as a bright white slab is the loudest thing on the
|
||||
* screen and says nothing the words do not.
|
||||
*
|
||||
* Plain white is not design: Outlook paints it behind everything it sends.
|
||||
*/
|
||||
export function looksDesigned(html: string): boolean {
|
||||
if (!html) return false;
|
||||
const neutral = /^\s*(?:#f{3}(?:f{3})?|white|transparent|none|rgba?\(\s*255\s*,\s*255\s*,\s*255(?:\s*,\s*(?:1|1\.0|0?\.\d+))?\s*\)|rgba?\(\s*0\s*,\s*0\s*,\s*0\s*,\s*0\s*\))\s*(?:!important)?\s*;?\s*$/i;
|
||||
|
||||
// bgcolor="#336699" and background="hero.png" are always a statement.
|
||||
for (const m of html.matchAll(/\b(?:bgcolor|background)\s*=\s*(["']?)([^"'\s>]+)\1/gi)) {
|
||||
if (!neutral.test(m[2])) return true;
|
||||
}
|
||||
// background / background-color in a style attribute or a <style> block.
|
||||
for (const m of html.matchAll(/background(?:-color|-image)?\s*:\s*([^;}"']+)/gi)) {
|
||||
const value = m[1].trim();
|
||||
if (/url\(/i.test(value) || !neutral.test(value)) return true;
|
||||
}
|
||||
// A layout built from tables is a design even where nothing is coloured.
|
||||
return (html.match(/<table\b/gi) ?? []).length >= 3;
|
||||
}
|
||||
|
||||
/** The colours a message is drawn in, by the way it is shown. */
|
||||
const SURFACES = {
|
||||
paper: { fg: "#1d1d1f", muted: "#5b6272", link: "#3f4fe6", rule: "#d6d9e0", wash: "rgba(20,28,50,.05)", scheme: "light" },
|
||||
dark: { fg: "#e6e9f0", muted: "#8e96a8", link: "#8794ff", rule: "#383e4d", wash: "rgba(233,237,245,.07)", scheme: "dark" },
|
||||
light: { fg: "#12151c", muted: "#5b6272", link: "#3f4fe6", rule: "#cdd1db", wash: "rgba(20,28,50,.05)", scheme: "light" },
|
||||
} as const;
|
||||
|
||||
export interface ReaderDocumentOptions {
|
||||
/** The app's theme, which a message written plainly takes the colours of. */
|
||||
theme?: "dark" | "light";
|
||||
/**
|
||||
* Draw the message on a white sheet, as it was written. A designed message
|
||||
* always is; a plain one only when the reader asks to see it as sent.
|
||||
*/
|
||||
paper?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Wraps a message body in the document the reader frame renders.
|
||||
*
|
||||
* The frame is sandboxed with an opaque origin, so it cannot inherit anything
|
||||
* from the app: every style, the light surface mail assumes, and the height
|
||||
* report the host page sizes the frame from all have to travel inside it.
|
||||
* from the app: every style, the surface mail assumes, the height report the
|
||||
* host page sizes the frame from and the little script that tidies a message
|
||||
* all have to travel inside it.
|
||||
*/
|
||||
export function buildReaderDocument(bodyHtml: string): string {
|
||||
export function buildReaderDocument(bodyHtml: string, options: ReaderDocumentOptions = {}): string {
|
||||
const paper = options.paper ?? true;
|
||||
const surface = paper ? SURFACES.paper : SURFACES[options.theme ?? "dark"];
|
||||
const native = !paper;
|
||||
return `<!doctype html>
|
||||
<html><head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="color-scheme" content="${surface.scheme}">
|
||||
<!-- Every link leaves this document: it has an opaque origin, so navigating in
|
||||
place strands the reader on a blank pane with no way back. Declared here
|
||||
rather than only in script so it holds even if script does not run. -->
|
||||
@@ -53,11 +104,13 @@ export function buildReaderDocument(bodyHtml: string): string {
|
||||
with it, and this holds without script. -->
|
||||
<meta name="referrer" content="no-referrer">
|
||||
<style>
|
||||
html { background:#ffffff; }
|
||||
:root { color-scheme:${surface.scheme}; }
|
||||
html { background:${native ? "transparent" : "#ffffff"}; }
|
||||
body {
|
||||
margin:0; padding:24px 28px; background:#ffffff; color:#1d1d1f;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
|
||||
font-size:15px; line-height:1.55; -webkit-font-smoothing:antialiased;
|
||||
margin:0; padding:${native ? "2px 0 6px" : "26px 30px"}; background:${native ? "transparent" : "#ffffff"}; color:${surface.fg};
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
font-size:${native ? "15.5px" : "15px"}; line-height:${native ? "1.68" : "1.55"};
|
||||
-webkit-font-smoothing:antialiased; text-rendering:optimizeLegibility;
|
||||
/* Mail is written against desktop widths. Without these a newsletter
|
||||
pushes a horizontal scrollbar across its own content. */
|
||||
overflow-wrap:anywhere; word-break:break-word;
|
||||
@@ -68,12 +121,24 @@ export function buildReaderDocument(bodyHtml: string): string {
|
||||
/* A layout table keeps the width its author gave it - forcing "auto" is what
|
||||
collapsed designed messages into a narrow column. max-width above is what
|
||||
stops one from overflowing a small screen. */
|
||||
p { margin:0 0 .9em; }
|
||||
${native ? "img { border-radius:8px; }" : ""}
|
||||
a { color:${surface.link}; text-decoration-thickness:1px; text-underline-offset:2px; }
|
||||
pre, code { font-family: ui-monospace, "SF Mono", Menlo, Consolas, monospace; font-size:.9em; }
|
||||
pre { white-space:pre-wrap; background:${surface.wash}; padding:12px 14px; border-radius:8px; }
|
||||
blockquote {
|
||||
margin:0 0 0 4px; padding-left:12px;
|
||||
border-left:3px solid #cbd5e1; color:#475569;
|
||||
margin:.4em 0 .9em; padding:2px 0 2px 14px;
|
||||
border-left:3px solid ${surface.rule}; color:${surface.muted};
|
||||
}
|
||||
a { color:#4f46e5; }
|
||||
pre { white-space:pre-wrap; }
|
||||
hr { border:0; border-top:1px solid ${surface.rule}; margin:1.2em 0; }
|
||||
/* The button that stands in for a reply's quoted history. */
|
||||
.lm-quote-toggle {
|
||||
display:inline-flex; align-items:center; justify-content:center; height:20px; padding:0 9px; margin:2px 0 8px;
|
||||
border:0; border-radius:10px; background:${surface.wash}; color:${surface.muted};
|
||||
font:600 13px/1 -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; letter-spacing:.08em; cursor:pointer;
|
||||
}
|
||||
.lm-quote-toggle:hover { color:${surface.fg}; }
|
||||
.lm-quote-toggle:focus-visible { outline:2px solid ${surface.link}; outline-offset:2px; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
@@ -85,6 +150,47 @@ ${bodyHtml}
|
||||
link.setAttribute("rel", "noopener noreferrer");
|
||||
}
|
||||
|
||||
${native ? `// A message written plainly is drawn in the app's colours, but it may still
|
||||
// carry its own: black text it was given by the program that wrote it, and a
|
||||
// white backdrop behind every block. Dark text on a dark ground is the one
|
||||
// thing this must never produce, so any colour too close to the ground goes.
|
||||
(function () {
|
||||
const dark = ${surface.scheme === "dark"};
|
||||
const channels = (value) => (value.match(/[0-9.]+/g) || []).map(Number);
|
||||
const luminance = ([r, g, b]) => (0.2126 * r + 0.7152 * g + 0.0722 * b) / 255;
|
||||
for (const el of document.body.querySelectorAll("*")) {
|
||||
const style = getComputedStyle(el);
|
||||
const [r, g, b, a = 1] = channels(style.backgroundColor);
|
||||
if (a > 0 && r > 244 && g > 244 && b > 244) el.style.setProperty("background-color", "transparent", "important");
|
||||
const [fr, fg, fb] = channels(style.color);
|
||||
const lum = luminance([fr, fg, fb]);
|
||||
if ((dark && lum < 0.42) || (!dark && lum > 0.62)) el.style.removeProperty("color"), el.removeAttribute("color"), el.style.setProperty("color", "inherit", "important");
|
||||
}
|
||||
})();` : ""}
|
||||
|
||||
// A reply carries the whole conversation under it. The history is one click
|
||||
// away rather than a screenful in the way - and in a conversation, where each
|
||||
// message is shown anyway, it is a duplicate.
|
||||
(function () {
|
||||
const quotes = [...document.querySelectorAll("blockquote")].filter((q) => !q.parentElement.closest("blockquote"));
|
||||
for (const quote of quotes) {
|
||||
if ((quote.textContent || "").trim().length < 60) continue;
|
||||
const toggle = document.createElement("button");
|
||||
toggle.type = "button";
|
||||
toggle.className = "lm-quote-toggle";
|
||||
toggle.textContent = "\u00b7\u00b7\u00b7";
|
||||
toggle.setAttribute("aria-expanded", "false");
|
||||
toggle.title = "...";
|
||||
quote.style.display = "none";
|
||||
toggle.addEventListener("click", () => {
|
||||
const open = quote.style.display === "none";
|
||||
quote.style.display = open ? "" : "none";
|
||||
toggle.setAttribute("aria-expanded", String(open));
|
||||
});
|
||||
quote.parentNode.insertBefore(toggle, quote);
|
||||
}
|
||||
})();
|
||||
|
||||
// The host sizes the frame from this rather than guessing: a fixed height
|
||||
// either clips a long message or leaves a slab of empty white under a short
|
||||
// one, and a sandboxed frame cannot be measured from outside.
|
||||
|
||||
Reference in new issue
Block a user