chore: keep the logo generator in the repository
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
8db69c5af9
commit
c00134f2eb
7 files changed
+130
No files matched your search
@@ -30,6 +30,7 @@ budget_tokens: 2000
|
||||
- [2026-10-22] `services/protocols` e2e tests apply only migration 0001. Delivery code must not call SQL functions or columns from later migrations.
|
||||
- [2026-10-22] A `<Button>` inside a `<form>` submits it unless `type="button"` (Undo send used to resend the message).
|
||||
- [2026-10-22] Never run side effects inside a React state updater (it can run twice).
|
||||
- [2026-10-23] Inside a JS template literal `\\d` silently becomes `d`. For scripts embedded in template strings write `[0-9]` instead of `\\d`, and test the script's behaviour, not only that it is present.
|
||||
|
||||
## Decision Log
|
||||
|
||||
@@ -38,3 +39,5 @@ budget_tokens: 2000
|
||||
- 2026-10-22 Trusted senders live in table `trusted_senders`; delivery rescues a Junk verdict only when DMARC passed, or SPF passed for the From domain (a forged From must not be rescued).
|
||||
- 2026-10-22 Conversations: `thread_id` is written at delivery (Message-ID, then In-Reply-To/References, then Re:-subject); NULL means "its own conversation" via COALESCE(thread_id, id). Subject key is computed in Go at delivery and in SQL (`lm_subject_key`) for the backfill; a test holds them equal.
|
||||
- 2026-10-22 Themes: every colour is a CSS variable per theme (`globals.css`), Tailwind scales (slate, indigo, amber, rose, emerald) read them, `ink` is the on-ground colour. Dark is default; the theme is chosen by a script in `<head>` before first paint. Font Geist is self-hosted (@fontsource-variable/geist).
|
||||
- 2026-10-23 Logo: a lambda of two straight strokes of one weight, ends cut square, joined without a seam, flat ultramarine tile with continuous corners. The first version (round caps, gradient, envelope flap) read as childish; do not go back to round-capped strokes. Generator: `scripts/brand/` (mark.py, icons.mjs); `Logo.tsx` carries the same path.
|
||||
- 2026-10-23 Reader: a message written plainly is drawn in the app's colours (transparent frame, text lifted if too dark, white backdrops cleared); a designed one (backgrounds, bgcolor, 3+ tables) keeps a white sheet. Decided by `looksDesigned` in `lib/emailBody.ts`; a reply's quoted history folds behind a button.
|
||||
@@ -0,0 +1,15 @@
|
||||
# The LambdaMail mark
|
||||
|
||||
The logo is a lambda drawn as two straight strokes of one weight, each end cut
|
||||
square to the baseline, joined without a seam, on a flat ultramarine tile with
|
||||
continuous (superelliptic) corners. It is deliberately flat and has no rounded
|
||||
strokes: it has to hold at 16 pixels and on both themes.
|
||||
|
||||
`mark.py` holds the geometry. Change a number there, never the SVG by hand.
|
||||
|
||||
python3 scripts/brand/mark.py # writes icon.svg and the logo-*.svg files
|
||||
CHROME_PATH=/path/to/chrome node scripts/brand/icons.mjs # renders the PNGs and favicon.ico
|
||||
|
||||
`icons.mjs` needs `puppeteer-core` and a Chrome. The inline copy of the mark in
|
||||
`services/webmail/src/components/Logo.tsx` carries the same two paths: after a
|
||||
change, paste the `glyph:` line `mark.py` prints into `LAMBDA`.
|
||||
@@ -0,0 +1,26 @@
|
||||
import puppeteer from "puppeteer-core";
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
const here = path.dirname(fileURLToPath(import.meta.url));
|
||||
const out = path.join(here, "..", "..", "services", "webmail");
|
||||
const tile = fs.readFileSync(path.join(here, "logo.svg"), "utf8"), full = fs.readFileSync(path.join(here, "logo-full.svg"), "utf8"), safe = fs.readFileSync(path.join(here, "logo-safe.svg"), "utf8");
|
||||
const browser = await puppeteer.launch({ executablePath: process.env.CHROME_PATH, headless: true, args: ["--no-sandbox"] });
|
||||
const page = await browser.newPage();
|
||||
async function png(svg, size, file, transparent) {
|
||||
await page.setViewport({ width: size, height: size, deviceScaleFactor: 1 });
|
||||
await page.setContent(`<body style="margin:0;width:${size}px;height:${size}px;background:${transparent ? "transparent" : "#1B2BD0"}">${svg.replace('width="64" height="64"', `width="${size}" height="${size}"`)}</body>`);
|
||||
await page.screenshot({ path: file, omitBackground: transparent });
|
||||
}
|
||||
await png(full, 180, `${out}/src/app/apple-icon.png`, false);
|
||||
await png(tile, 192, `${out}/public/icons/icon-192.png`, true);
|
||||
await png(tile, 512, `${out}/public/icons/icon-512.png`, true);
|
||||
await png(safe, 512, `${out}/public/icons/maskable-512.png`, false);
|
||||
for (const s of [16, 32, 48]) await png(tile, s, path.join(here, `fav${s}.png`), true);
|
||||
await browser.close();
|
||||
const imgs = [16, 32, 48].map((s) => ({ s, data: fs.readFileSync(path.join(here, `fav${s}.png`)) }));
|
||||
const head = Buffer.alloc(6); head.writeUInt16LE(1, 2); head.writeUInt16LE(imgs.length, 4);
|
||||
let offset = 6 + 16 * imgs.length;
|
||||
const dir = Buffer.concat(imgs.map(({ s, data }) => { const e = Buffer.alloc(16); e[0] = s; e[1] = s; e.writeUInt16LE(1, 4); e.writeUInt16LE(32, 6); e.writeUInt32LE(data.length, 8); e.writeUInt32LE(offset, 12); offset += data.length; return e; }));
|
||||
fs.writeFileSync(`${out}/src/app/favicon.ico`, Buffer.concat([head, dir, ...imgs.map((i) => i.data)]));
|
||||
console.log("icons done");
|
||||
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#1B2BD0"/><path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 293 B |
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64"><rect width="64" height="64" fill="#1B2BD0"/><g transform="translate(32 32) scale(0.78) translate(-32 -32)"><path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/></g></svg>
|
||||
|
After Width: | Height: | Size: 360 B |
@@ -0,0 +1,5 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">
|
||||
<!-- a lambda built as two square-cut strokes, on a tile with continuous corners -->
|
||||
<path d="M64.00 32.00 L64.00 38.34 L63.99 40.36 L63.98 41.83 L63.97 43.03 L63.95 44.06 L63.93 44.97 L63.90 45.79 L63.88 46.54 L63.84 47.24 L63.80 47.89 L63.76 48.50 L63.72 49.07 L63.67 49.62 L63.62 50.14 L63.56 50.64 L63.50 51.11 L63.43 51.57 L63.36 52.01 L63.29 52.43 L63.21 52.83 L63.13 53.23 L63.05 53.61 L62.96 53.97 L62.86 54.33 L62.77 54.67 L62.66 55.01 L62.56 55.33 L62.45 55.65 L62.33 55.95 L62.21 56.25 L62.09 56.54 L61.96 56.82 L61.83 57.10 L61.69 57.36 L61.55 57.62 L61.40 57.87 L61.25 58.12 L61.09 58.36 L60.93 58.59 L60.76 58.81 L60.59 59.04 L60.42 59.25 L60.24 59.46 L60.05 59.66 L59.86 59.86 L59.66 60.05 L59.46 60.24 L59.25 60.42 L59.04 60.59 L58.81 60.76 L58.59 60.93 L58.36 61.09 L58.12 61.25 L57.87 61.40 L57.62 61.55 L57.36 61.69 L57.10 61.83 L56.82 61.96 L56.54 62.09 L56.25 62.21 L55.95 62.33 L55.65 62.45 L55.33 62.56 L55.01 62.66 L54.67 62.77 L54.33 62.86 L53.97 62.96 L53.61 63.05 L53.23 63.13 L52.83 63.21 L52.43 63.29 L52.01 63.36 L51.57 63.43 L51.11 63.50 L50.64 63.56 L50.14 63.62 L49.62 63.67 L49.07 63.72 L48.50 63.76 L47.89 63.80 L47.24 63.84 L46.54 63.88 L45.79 63.90 L44.97 63.93 L44.06 63.95 L43.03 63.97 L41.83 63.98 L40.36 63.99 L38.34 64.00 L32.00 64.00 L25.66 64.00 L23.64 63.99 L22.17 63.98 L20.97 63.97 L19.94 63.95 L19.03 63.93 L18.21 63.90 L17.46 63.88 L16.76 63.84 L16.11 63.80 L15.50 63.76 L14.93 63.72 L14.38 63.67 L13.86 63.62 L13.36 63.56 L12.89 63.50 L12.43 63.43 L11.99 63.36 L11.57 63.29 L11.17 63.21 L10.77 63.13 L10.39 63.05 L10.03 62.96 L9.67 62.86 L9.33 62.77 L8.99 62.66 L8.67 62.56 L8.35 62.45 L8.05 62.33 L7.75 62.21 L7.46 62.09 L7.18 61.96 L6.90 61.83 L6.64 61.69 L6.38 61.55 L6.13 61.40 L5.88 61.25 L5.64 61.09 L5.41 60.93 L5.19 60.76 L4.96 60.59 L4.75 60.42 L4.54 60.24 L4.34 60.05 L4.14 59.86 L3.95 59.66 L3.76 59.46 L3.58 59.25 L3.41 59.04 L3.24 58.81 L3.07 58.59 L2.91 58.36 L2.75 58.12 L2.60 57.87 L2.45 57.62 L2.31 57.36 L2.17 57.10 L2.04 56.82 L1.91 56.54 L1.79 56.25 L1.67 55.95 L1.55 55.65 L1.44 55.33 L1.34 55.01 L1.23 54.67 L1.14 54.33 L1.04 53.97 L0.95 53.61 L0.87 53.23 L0.79 52.83 L0.71 52.43 L0.64 52.01 L0.57 51.57 L0.50 51.11 L0.44 50.64 L0.38 50.14 L0.33 49.62 L0.28 49.07 L0.24 48.50 L0.20 47.89 L0.16 47.24 L0.12 46.54 L0.10 45.79 L0.07 44.97 L0.05 44.06 L0.03 43.03 L0.02 41.83 L0.01 40.36 L0.00 38.34 L0.00 32.00 L0.00 25.66 L0.01 23.64 L0.02 22.17 L0.03 20.97 L0.05 19.94 L0.07 19.03 L0.10 18.21 L0.12 17.46 L0.16 16.76 L0.20 16.11 L0.24 15.50 L0.28 14.93 L0.33 14.38 L0.38 13.86 L0.44 13.36 L0.50 12.89 L0.57 12.43 L0.64 11.99 L0.71 11.57 L0.79 11.17 L0.87 10.77 L0.95 10.39 L1.04 10.03 L1.14 9.67 L1.23 9.33 L1.34 8.99 L1.44 8.67 L1.55 8.35 L1.67 8.05 L1.79 7.75 L1.91 7.46 L2.04 7.18 L2.17 6.90 L2.31 6.64 L2.45 6.38 L2.60 6.13 L2.75 5.88 L2.91 5.64 L3.07 5.41 L3.24 5.19 L3.41 4.96 L3.58 4.75 L3.76 4.54 L3.95 4.34 L4.14 4.14 L4.34 3.95 L4.54 3.76 L4.75 3.58 L4.96 3.41 L5.19 3.24 L5.41 3.07 L5.64 2.91 L5.88 2.75 L6.13 2.60 L6.38 2.45 L6.64 2.31 L6.90 2.17 L7.18 2.04 L7.46 1.91 L7.75 1.79 L8.05 1.67 L8.35 1.55 L8.67 1.44 L8.99 1.34 L9.33 1.23 L9.67 1.14 L10.03 1.04 L10.39 0.95 L10.77 0.87 L11.17 0.79 L11.57 0.71 L11.99 0.64 L12.43 0.57 L12.89 0.50 L13.36 0.44 L13.86 0.38 L14.38 0.33 L14.93 0.28 L15.50 0.24 L16.11 0.20 L16.76 0.16 L17.46 0.12 L18.21 0.10 L19.03 0.07 L19.94 0.05 L20.97 0.03 L22.17 0.02 L23.64 0.01 L25.66 0.00 L32.00 0.00 L38.34 0.00 L40.36 0.01 L41.83 0.02 L43.03 0.03 L44.06 0.05 L44.97 0.07 L45.79 0.10 L46.54 0.12 L47.24 0.16 L47.89 0.20 L48.50 0.24 L49.07 0.28 L49.62 0.33 L50.14 0.38 L50.64 0.44 L51.11 0.50 L51.57 0.57 L52.01 0.64 L52.43 0.71 L52.83 0.79 L53.23 0.87 L53.61 0.95 L53.97 1.04 L54.33 1.14 L54.67 1.23 L55.01 1.34 L55.33 1.44 L55.65 1.55 L55.95 1.67 L56.25 1.79 L56.54 1.91 L56.82 2.04 L57.10 2.17 L57.36 2.31 L57.62 2.45 L57.87 2.60 L58.12 2.75 L58.36 2.91 L58.59 3.07 L58.81 3.24 L59.04 3.41 L59.25 3.58 L59.46 3.76 L59.66 3.95 L59.86 4.14 L60.05 4.34 L60.24 4.54 L60.42 4.75 L60.59 4.96 L60.76 5.19 L60.93 5.41 L61.09 5.64 L61.25 5.88 L61.40 6.13 L61.55 6.38 L61.69 6.64 L61.83 6.90 L61.96 7.18 L62.09 7.46 L62.21 7.75 L62.33 8.05 L62.45 8.35 L62.56 8.67 L62.66 8.99 L62.77 9.33 L62.86 9.67 L62.96 10.03 L63.05 10.39 L63.13 10.77 L63.21 11.17 L63.29 11.57 L63.36 11.99 L63.43 12.43 L63.50 12.89 L63.56 13.36 L63.62 13.86 L63.67 14.38 L63.72 14.93 L63.76 15.50 L63.80 16.11 L63.84 16.76 L63.88 17.46 L63.90 18.21 L63.93 19.03 L63.95 19.94 L63.97 20.97 L63.98 22.17 L63.99 23.64 L64.00 25.66Z" fill="#1B2BD0"/>
|
||||
<path transform="translate(-0.9 0)" d="M15.27 10.00 L23.13 10.00 L48.73 54.00 L40.87 54.00Z M29.96 28.49 L34.04 35.51 L24.80 54.00 L17.20 54.00Z" fill="#fff"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 4.7 KiB |
@@ -0,0 +1,79 @@
|
||||
import math
|
||||
import os
|
||||
|
||||
def squircle(size=64, n=5.0, steps=90):
|
||||
r = size/2
|
||||
pts=[]
|
||||
for i in range(steps*4):
|
||||
t = 2*math.pi*i/(steps*4)
|
||||
c, s = math.cos(t), math.sin(t)
|
||||
pts.append((r*math.copysign(abs(c)**(2/n), c)+r, r*math.copysign(abs(s)**(2/n), s)+r))
|
||||
return "M"+" L".join(f"{x:.2f} {y:.2f}" for x,y in pts)+"Z"
|
||||
|
||||
def norm(v):
|
||||
l=math.hypot(*v); return (v[0]/l, v[1]/l)
|
||||
|
||||
def lam(top=(19.2,10.0), bot=(44.8,54.0), foot=(21.0,54.0), join_t=0.50, w=6.8):
|
||||
"""A lambda drawn the way a type designer builds one: two straight strokes of
|
||||
one weight, the long one running top-left to bottom-right and the short one
|
||||
leaving it part way down and landing at the bottom-left, every end cut
|
||||
square to the baseline, and no part of one stroke poking out of the other."""
|
||||
(tx,ty),(bx,by)=top,bot
|
||||
# --- the long stroke: a parallelogram cut horizontally at both ends
|
||||
d=norm((bx-tx,by-ty)); phi_h = w/abs(d[1]) # horizontal thickness
|
||||
def long_x(y): return tx+(bx-tx)*(y-ty)/(by-ty)
|
||||
long_poly=[(tx-phi_h/2,ty),(tx+phi_h/2,ty),(bx+phi_h/2,by),(bx-phi_h/2,by)]
|
||||
# --- the short stroke starts on the long one's centre line
|
||||
jy=ty+(by-ty)*join_t; jx=long_x(jy)
|
||||
sd=norm((foot[0]-jx,foot[1]-jy)) # direction of the short stroke
|
||||
sh = w/abs(sd[1]) # its horizontal thickness
|
||||
nrm=(-sd[1], sd[0]) # unit normal
|
||||
# the two edges of the short stroke, as lines through (jx,jy)+-nrm*w/2
|
||||
def edge_pt(sign, y):
|
||||
ox,oy=jx+sign*nrm[0]*w/2, jy+sign*nrm[1]*w/2
|
||||
t=(y-oy)/sd[1]; return (ox+sd[0]*t, y)
|
||||
# its top end follows the long stroke's own direction, through the join point,
|
||||
# so it ends inside the long stroke instead of sticking out of it
|
||||
def on_long_dir(sign):
|
||||
ox,oy=jx+sign*nrm[0]*w/2, jy+sign*nrm[1]*w/2
|
||||
# intersect edge line (o + sd*t) with the line (j + d*s)
|
||||
a=sd[0]*(-d[1]) - sd[1]*(-d[0]);
|
||||
t=((jx-ox)*(-d[1]) - (jy-oy)*(-d[0]))/a
|
||||
return (ox+sd[0]*t, oy+sd[1]*t)
|
||||
p1,p2=on_long_dir(+1),on_long_dir(-1)
|
||||
q1,q2=edge_pt(+1,foot[1]),edge_pt(-1,foot[1])
|
||||
short_poly=[p1,p2,q2,q1]
|
||||
return long_poly, short_poly
|
||||
|
||||
def path(*polys):
|
||||
return " ".join("M"+" L".join(f"{x:.2f} {y:.2f}" for x,y in p)+"Z" for p in polys)
|
||||
|
||||
BLUE="#1B2BD0"
|
||||
def svg(bg=BLUE, fg="#FFFFFF", size=64):
|
||||
l,s=lam()
|
||||
return (f'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="{size}" height="{size}">'
|
||||
f'<path d="{squircle()}" fill="{bg}"/><path d="{path(l,s)}" fill="{fg}"/></svg>')
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Writes services/webmail/src/app/icon.svg and the SVGs the PNG step renders.
|
||||
# Run: python3 scripts/brand/mark.py then node scripts/brand/icons.mjs
|
||||
# ---------------------------------------------------------------------------
|
||||
HERE = os.path.dirname(os.path.abspath(__file__))
|
||||
WEBMAIL = os.path.join(HERE, "..", "..", "services", "webmail")
|
||||
|
||||
def write_all():
|
||||
l, s = lam()
|
||||
glyph = path(l, s)
|
||||
shift = 'transform="translate(-0.9 0)"' # centres the glyph, not its bounding box
|
||||
tile = f'<path d="{squircle()}" fill="{BLUE}"/>'
|
||||
head = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" width="64" height="64">'
|
||||
icon = f'{head}\n <!-- a lambda built as two square-cut strokes, on a tile with continuous corners -->\n {tile}\n <path {shift} d="{glyph}" fill="#fff"/>\n</svg>\n'
|
||||
full = f'{head}<rect width="64" height="64" fill="{BLUE}"/><path {shift} d="{glyph}" fill="#fff"/></svg>'
|
||||
safe = f'{head}<rect width="64" height="64" fill="{BLUE}"/><g transform="translate(32 32) scale(0.78) translate(-32 -32)"><path {shift} d="{glyph}" fill="#fff"/></g></svg>'
|
||||
open(os.path.join(WEBMAIL, "src", "app", "icon.svg"), "w").write(icon)
|
||||
for name, text in (("logo.svg", icon), ("logo-full.svg", full), ("logo-safe.svg", safe)):
|
||||
open(os.path.join(HERE, name), "w").write(text)
|
||||
print("glyph:", glyph)
|
||||
|
||||
if __name__ == "__main__":
|
||||
write_all()
|
||||
Reference in new issue
Block a user