diff --git a/assets/logo/README.md b/assets/logo/README.md
new file mode 100644
index 0000000..5401556
--- /dev/null
+++ b/assets/logo/README.md
@@ -0,0 +1,41 @@
+# GoSentry logo
+
+Recommended wordmark variant from [`../gosentry-logo.html`](../gosentry-logo.html):
+**Space Grotesk SemiBold** with an amber **G**, a **clock-dial "o"** (ring + hands),
+and a petrol **"Sentry"**. The dial sits exactly in the `o` slot, so the schedule /
+"watch" theme lives inside the letters instead of a bolt-on icon.
+
+## Colors
+
+| token | hex | use |
+|--------|-----------|-----------------------------|
+| amber | `#F7A80C` | `G`, dial ring + hands |
+| petrol | `#0A4A58` | `Sentry` (light background) |
+| white | `#FFFFFF` | `Sentry` (dark background) |
+
+## Files
+
+Vector (self-contained — glyphs are outlined to paths, no font required):
+
+- `gosentry-logo.svg` — transparent, petrol `Sentry` (for light backgrounds)
+- `gosentry-logo-dark.svg` — transparent, white `Sentry` (for dark backgrounds)
+- `gosentry-logo-mono.svg` — single-color petrol
+
+Raster (transparent PNG, aspect ≈ 4326×1034 ≈ 4.18:1):
+
+- `gosentry-logo-{256,512,1024,2048}.png` — petrol `Sentry`
+- `gosentry-logo-dark-{256,512,1024,2048}.png` — white `Sentry`
+
+## Regenerating
+
+Requires `fonttools` and `matplotlib`, plus the Space Grotesk variable font
+(SIL OFL) instanced to weight 600 as `SpaceGrotesk-600.ttf`:
+
+```sh
+python gen_logo.py # writes SVGs into ./out
+python raster.py # writes PNGs into ./out
+```
+
+`gen_logo.py` (SVG) and `raster.py` (PNG) share the same layout + dial geometry,
+so both outputs stay identical. Space Grotesk is licensed under the SIL Open Font
+License; outlining its glyphs into a logo is permitted.
diff --git a/assets/logo/gen_logo.py b/assets/logo/gen_logo.py
new file mode 100644
index 0000000..1ad66e9
--- /dev/null
+++ b/assets/logo/gen_logo.py
@@ -0,0 +1,120 @@
+#!/usr/bin/env python3
+"""Generate GoSentry wordmark logo assets (recommended variant:
+Space Grotesk SemiBold + amber 'G' + clock-dial 'o' with hands + 'Sentry')."""
+import os
+from fontTools.ttLib import TTFont
+from fontTools.pens.svgPathPen import SVGPathPen
+from fontTools.pens.transformPen import TransformPen
+from fontTools.pens.boundsPen import BoundsPen
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(HERE, "out")
+os.makedirs(OUT, exist_ok=True)
+
+AMBER = "#F7A80C"
+PETROL = "#0A4A58"
+WHITE = "#FFFFFF"
+
+LS = -30 # letter-spacing -0.03em at 1000 upm
+
+f = TTFont(os.path.join(HERE, "SpaceGrotesk-600.ttf"))
+cmap = f.getBestCmap()
+hmtx = f["hmtx"]
+gs = f.getGlyphSet()
+
+def glyph_path(ch, dx):
+ """Return SVG path 'd' for ch, shifted by dx in font units (y still up)."""
+ g = cmap[ord(ch)]
+ pen = SVGPathPen(gs)
+ tpen = TransformPen(pen, (1, 0, 0, 1, dx, 0))
+ gs[g].draw(tpen)
+ return pen.getCommands(), hmtx[g][0]
+
+def o_metrics():
+ g = cmap[ord("o")]
+ bp = BoundsPen(gs); gs[g].draw(bp)
+ xmin,ymin,xmax,ymax = bp.bounds
+ return hmtx[g][0], xmin, ymin, xmax, ymax
+
+# ---- layout the wordmark ---------------------------------------------------
+x = 0.0
+G_d, adv = glyph_path("G", x); x += adv + LS
+
+# dial occupies the 'o' advance slot
+o_adv, oxmin, oymin, oxmax, oymax = o_metrics()
+o_left = x
+cx = o_left + (oxmin + oxmax) / 2.0
+cy = (oymin + oymax) / 2.0
+R = ((oxmax - oxmin) + (oymax - oymin)) / 4.0 # avg radius, matches the 'o'
+x += o_adv + LS
+
+sentry_d = []
+for ch in "Sentry":
+ d, adv = glyph_path(ch, x)
+ sentry_d.append(d)
+ x += adv + LS
+x -= LS # no trailing letter-spacing
+SENTRY_D = " ".join(sentry_d)
+
+# ---- dial geometry (matched to the 'o', proportions from the HTML mock) ----
+SW = R * 0.30 # ring stroke width
+Rmid = R - SW / 2.0 # centreline radius of ring
+HW = R * 0.21 # hand width
+hour_len = R * 0.54 # 12 o'clock hand
+min_len = R * 0.46 # ~4 o'clock hand
+min_ang = 62 # degrees clockwise from 12
+import math
+mx = cx + min_len * math.sin(math.radians(min_ang))
+my = cy + min_len * math.cos(math.radians(min_ang)) # font-up: +y is up
+cap = R * 0.14
+
+dial = f'''
+
+
+ '''
+
+# ---- overall bounds (font units, y up) -------------------------------------
+bp = BoundsPen(gs)
+xall = 0.0
+gG = cmap[ord("G")]; gs[gG].draw(TransformPen(bp,(1,0,0,1,0,0)))
+xall += hmtx[gG][0] + LS + o_adv + LS
+for ch in "Sentry":
+ g = cmap[ord(ch)]
+ gs[g].draw(TransformPen(bp,(1,0,0,1,xall,0)))
+ xall += hmtx[g][0] + LS
+bx0,by0,bx1,by1 = bp.bounds
+# include the dial extents
+bx0 = min(bx0, cx-R-SW/2); bx1 = max(bx1, cx+R+SW/2)
+by0 = min(by0, cy-R-SW/2); by1 = max(by1, cy+R+SW/2)
+
+PAD = 60
+W = (bx1 - bx0) + 2*PAD
+H = (by1 - by0) + 2*PAD
+# transform: font(x,y up) -> screen: translate then flip y
+tx = PAD - bx0
+ty = PAD + by1
+transform = f"matrix(1 0 0 -1 {tx:.2f} {ty:.2f})"
+
+def svg(sentry_color, bg=None, name=""):
+ bgrect = f'\n' if bg else ""
+ return f'''
+'''
+
+variants = {
+ "gosentry-logo.svg": svg(PETROL), # light bg, transparent
+ "gosentry-logo-dark.svg": svg(WHITE), # dark bg, transparent
+ "gosentry-logo-onlight.svg": svg(PETROL, bg="#FFFFFF"),
+ "gosentry-logo-ondark.svg": svg(WHITE, bg="#04262E"),
+ "gosentry-logo-mono.svg": svg(PETROL).replace(AMBER, PETROL), # single-colour petrol
+}
+for fn, data in variants.items():
+ with open(os.path.join(OUT, fn), "w", encoding="utf-8") as fh:
+ fh.write(data)
+ print("wrote", fn)
+print("viewBox %.1f x %.1f" % (W, H))
diff --git a/assets/logo/gosentry-logo-1024.png b/assets/logo/gosentry-logo-1024.png
new file mode 100644
index 0000000..e9d699c
Binary files /dev/null and b/assets/logo/gosentry-logo-1024.png differ
diff --git a/assets/logo/gosentry-logo-2048.png b/assets/logo/gosentry-logo-2048.png
new file mode 100644
index 0000000..54a40bb
Binary files /dev/null and b/assets/logo/gosentry-logo-2048.png differ
diff --git a/assets/logo/gosentry-logo-256.png b/assets/logo/gosentry-logo-256.png
new file mode 100644
index 0000000..689ba42
Binary files /dev/null and b/assets/logo/gosentry-logo-256.png differ
diff --git a/assets/logo/gosentry-logo-512.png b/assets/logo/gosentry-logo-512.png
new file mode 100644
index 0000000..7feff60
Binary files /dev/null and b/assets/logo/gosentry-logo-512.png differ
diff --git a/assets/logo/gosentry-logo-dark-1024.png b/assets/logo/gosentry-logo-dark-1024.png
new file mode 100644
index 0000000..b326057
Binary files /dev/null and b/assets/logo/gosentry-logo-dark-1024.png differ
diff --git a/assets/logo/gosentry-logo-dark-2048.png b/assets/logo/gosentry-logo-dark-2048.png
new file mode 100644
index 0000000..527700b
Binary files /dev/null and b/assets/logo/gosentry-logo-dark-2048.png differ
diff --git a/assets/logo/gosentry-logo-dark-256.png b/assets/logo/gosentry-logo-dark-256.png
new file mode 100644
index 0000000..b27a65a
Binary files /dev/null and b/assets/logo/gosentry-logo-dark-256.png differ
diff --git a/assets/logo/gosentry-logo-dark-512.png b/assets/logo/gosentry-logo-dark-512.png
new file mode 100644
index 0000000..4b9f24e
Binary files /dev/null and b/assets/logo/gosentry-logo-dark-512.png differ
diff --git a/assets/logo/gosentry-logo-dark.svg b/assets/logo/gosentry-logo-dark.svg
new file mode 100644
index 0000000..334a6ea
--- /dev/null
+++ b/assets/logo/gosentry-logo-dark.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/logo/gosentry-logo-mono.svg b/assets/logo/gosentry-logo-mono.svg
new file mode 100644
index 0000000..0daf8cb
--- /dev/null
+++ b/assets/logo/gosentry-logo-mono.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/logo/gosentry-logo.svg b/assets/logo/gosentry-logo.svg
new file mode 100644
index 0000000..5a07ed9
--- /dev/null
+++ b/assets/logo/gosentry-logo.svg
@@ -0,0 +1,10 @@
+
diff --git a/assets/logo/raster.py b/assets/logo/raster.py
new file mode 100644
index 0000000..174a5c2
--- /dev/null
+++ b/assets/logo/raster.py
@@ -0,0 +1,97 @@
+#!/usr/bin/env python3
+"""Rasterize the GoSentry wordmark to PNG at several widths, reusing the
+same font outlines + dial geometry as gen_logo.py (no SVG rasterizer needed)."""
+import os, math
+import matplotlib
+matplotlib.use("Agg")
+import matplotlib.pyplot as plt
+from matplotlib.path import Path
+from matplotlib.patches import PathPatch, Circle
+from matplotlib.lines import Line2D
+from fontTools.ttLib import TTFont
+from fontTools.pens.basePen import BasePen
+from fontTools.pens.boundsPen import BoundsPen
+from fontTools.pens.transformPen import TransformPen
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT = os.path.join(HERE, "out"); os.makedirs(OUT, exist_ok=True)
+AMBER, PETROL, WHITE = "#F7A80C", "#0A4A58", "#FFFFFF"
+LS = -30
+
+f = TTFont(os.path.join(HERE, "SpaceGrotesk-600.ttf"))
+cmap, hmtx, gs = f.getBestCmap(), f["hmtx"], f.getGlyphSet()
+
+class MplPen(BasePen):
+ def __init__(self, glyphSet):
+ super().__init__(glyphSet); self.v=[]; self.c=[]
+ def _moveTo(self,p): self.v.append(p); self.c.append(Path.MOVETO)
+ def _lineTo(self,p): self.v.append(p); self.c.append(Path.LINETO)
+ def _curveToOne(self,p1,p2,p3):
+ self.v += [p1,p2,p3]; self.c += [Path.CURVE4]*3
+ def _qCurveToOne(self,p1,p2):
+ self.v += [p1,p2]; self.c += [Path.CURVE3]*2
+ def _closePath(self):
+ self.v.append((0,0)); self.c.append(Path.CLOSEPOLY)
+
+def glyph_mplpath(ch, dx):
+ pen = MplPen(gs)
+ gs[cmap[ord(ch)]].draw(TransformPen(pen,(1,0,0,1,dx,0)))
+ return Path(pen.v, pen.c), hmtx[cmap[ord(ch)]][0]
+
+# layout
+x=0.0
+paths_amber=[]; paths_petrol=[]
+p,adv = glyph_mplpath("G",x); paths_amber.append(p); x+=adv+LS
+og=cmap[ord("o")]; bp=BoundsPen(gs); gs[og].draw(bp)
+oxmin,oymin,oxmax,oymax=bp.bounds; o_adv=hmtx[og][0]
+cx=x+(oxmin+oxmax)/2; cy=(oymin+oymax)/2
+R=((oxmax-oxmin)+(oymax-oymin))/4
+x+=o_adv+LS
+for ch in "Sentry":
+ p,adv=glyph_mplpath(ch,x); paths_petrol.append(p); x+=adv+LS
+x-=LS
+
+SW=R*0.30; Rmid=R-SW/2; HW=R*0.21
+hour_len=R*0.54; min_len=R*0.46; ang=math.radians(62)
+mx=cx+min_len*math.sin(ang); my=cy+min_len*math.cos(ang); cap=R*0.14
+
+# bounds
+allb=BoundsPen(gs); gs[cmap[ord('G')]].draw(allb)
+xx=hmtx[cmap[ord('G')]][0]+LS+o_adv+LS
+for ch in "Sentry":
+ gs[cmap[ord(ch)]].draw(TransformPen(allb,(1,0,0,1,xx,0))); xx+=hmtx[cmap[ord(ch)]][0]+LS
+bx0,by0,bx1,by1=allb.bounds
+bx0=min(bx0,cx-R-SW/2); bx1=max(bx1,cx+R+SW/2)
+by0=min(by0,cy-R-SW/2); by1=max(by1,cy+R+SW/2)
+PAD=60
+X0,X1=bx0-PAD,bx1+PAD; Y0,Y1=by0-PAD,by1+PAD
+W=X1-X0; H=Y1-Y0
+
+def render(path_png, width_px, sentry_color, bg=None, mono=False):
+ dpi=100
+ fw=width_px/dpi; fh=fw*H/W
+ fig=plt.figure(figsize=(fw,fh),dpi=dpi)
+ ax=fig.add_axes([0,0,1,1]); ax.set_xlim(X0,X1); ax.set_ylim(Y0,Y1)
+ ax.set_aspect('equal'); ax.axis('off')
+ if bg: fig.patch.set_facecolor(bg); ax.set_facecolor(bg)
+ else: fig.patch.set_alpha(0)
+ amberc = sentry_color if mono else AMBER
+ for p in paths_amber: ax.add_patch(PathPatch(p,facecolor=amberc,edgecolor='none',antialiased=True))
+ for p in paths_petrol: ax.add_patch(PathPatch(p,facecolor=sentry_color,edgecolor='none',antialiased=True))
+ pt_per_unit = fw/W*72
+ ax.add_patch(Circle((cx,cy),Rmid,fill=False,edgecolor=amberc,linewidth=SW*pt_per_unit))
+ for (ex,ey) in [(cx,cy+hour_len),(mx,my)]:
+ ax.add_line(Line2D([cx,ex],[cy,ey],color=amberc,linewidth=HW*pt_per_unit,
+ solid_capstyle='round'))
+ ax.add_patch(Circle((cx,cy),cap,facecolor=amberc,edgecolor='none'))
+ fig.savefig(path_png,dpi=dpi,transparent=(bg is None))
+ plt.close(fig)
+ print("wrote",os.path.basename(path_png))
+
+for w in (256,512,1024,2048):
+ render(os.path.join(OUT,f"gosentry-logo-{w}.png"),w,PETROL)
+ render(os.path.join(OUT,f"gosentry-logo-dark-{w}.png"),w,WHITE)
+render(os.path.join(OUT,"gosentry-logo-onlight-1024.png"),1024,PETROL,bg="#FFFFFF")
+render(os.path.join(OUT,"gosentry-logo-ondark-1024.png"),1024,WHITE,bg="#04262E")
+render(os.path.join(OUT,"gosentry-logo-mono-1024.png"),1024,PETROL,mono=True)
+print(f"aspect {W:.0f}x{H:.0f}")