diff --git a/public/animations/layer-stack.svg b/public/animations/layer-stack.svg
new file mode 100644
index 00000000..8970f86e
--- /dev/null
+++ b/public/animations/layer-stack.svg
@@ -0,0 +1,57 @@
+
+
+
+
+
+ Your application
+ iroh — embedded library
+
+
+
+ Protocols
+ blobs · gossip · yours
+
+ Router
+ dispatches connections by ALPN
+
+ Endpoint
+ identity · address lookup · NAT · relay
+
+ QUIC + TLS 1.3
+ encryption · auth · stream mux
+
+ Transport
+ UDP and relay by default · swappable
+
+
+
+
+
+
+
+
+
+
+
+ Ethernet
+ LAN · UDP
+
+
+
+
+
+
+
+
+
+ Wi-Fi
+ UDP
+
+
+
+
+
+
+ Tor
+ onion routing
+
diff --git a/public/animations/mdns-address-lookup.svg b/public/animations/mdns-address-lookup.svg
new file mode 100644
index 00000000..90d882c5
--- /dev/null
+++ b/public/animations/mdns-address-lookup.svg
@@ -0,0 +1,144 @@
+
+
+
+
+ mDNS multicast
+ 224.0.0.251:5353
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ iroh
+ a4f7…
+ 192.168.0.3
+
+
+
+
+
+ iroh
+ 8e2b…
+ 192.168.0.5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ iroh
+ c1d9…
+ 192.168.0.7
+
+
+
+
+
+ local knowledge
+ 8e2b… 192.168.0.5
+ c1d9… 192.168.0.7
+
+
+
+ local knowledge
+ a4f7… 192.168.0.3
+ c1d9… 192.168.0.7
+
+
+
+ local knowledge
+ a4f7… 192.168.0.3
+ 8e2b… 192.168.0.5
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ mDNS announce
+ a4f7… @ 192.168.0.3
+
+
+
+
+ mDNS announce
+ 8e2b… @ 192.168.0.5
+
+
+
+
+ mDNS announce
+ c1d9… @ 192.168.0.7
+
+
+
+
+
+
+
diff --git a/public/animations/publish-relay-dht.svg b/public/animations/publish-relay-dht.svg
index cdf67db1..7deb08e3 100644
--- a/public/animations/publish-relay-dht.svg
+++ b/public/animations/publish-relay-dht.svg
@@ -64,7 +64,12 @@
1a9c…
Alice
- 8e2b… is at relay us-east
+
+
+
+
+ 8e2b… is at relay us-east
+
diff --git a/public/animations/publish-relay.svg b/public/animations/publish-relay.svg
index ca079a79..c2c57a64 100644
--- a/public/animations/publish-relay.svg
+++ b/public/animations/publish-relay.svg
@@ -79,8 +79,12 @@
1a9c…
Alice
-
- 8e2b… is at relay us-east
+
+
+
+
+ 8e2b… is at relay us-east
+
diff --git a/scripts/layer-stack.gen.py b/scripts/layer-stack.gen.py
new file mode 100644
index 00000000..1cda6315
--- /dev/null
+++ b/scripts/layer-stack.gen.py
@@ -0,0 +1,160 @@
+#!/usr/bin/env python3
+"""Generator for layer-stack.svg (the "How the pieces stack together" figure).
+
+A *static* architecture diagram in the same style as the animated how-iroh-works
+SVGs (Space Mono, light boxes on a transparent canvas, mid-tone accents so it
+reads in light and dark mode).
+
+The framing: iroh is a library *embedded in your application*. So the app is one
+big container box, and the iroh components (Protocols, Router, Endpoint, QUIC,
+Transport) are borderless boxes nested inside it. Below the app, the physical
+media the transport runs over fan out: an RJ45/ethernet plug and a Wi-Fi router
+(the two default UDP paths) plus a Tor onion (a swappable alternative).
+
+No animation: a layer stack reads best held still. All text is either dark ink on
+a light box or a mid-tone accent, so nothing vanishes on a dark background.
+
+To change it, edit this script and run: python3 layer-stack.gen.py
+It writes ../public/animations/layer-stack.svg.
+The page embeds this via / ; the
+intrinsic aspect ratio comes from the SVG viewBox (VB_W x VB_H).
+"""
+import os
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT_PATH = os.path.normpath(os.path.join(
+ HERE, "..", "public", "animations", "layer-stack.svg"))
+
+MONO = "'Space Mono', monospace"
+INDIGO, AMBER, GRAY, INK, GOLD = "#6366f1", "#d97706", "#888", "#111", "#eab308"
+BLUE = "#2563eb" # connection-blue, matching the other diagrams
+TOR = "#7e4798"
+BOX_FILL, BOX_STROKE, CABLE = "#e5e7eb", "#9ca3af", "#cbd0d8"
+
+VB_W, VB_H = 680, 465
+
+# ---- app container ----
+APP_X, APP_Y, APP_W = 70, 24, 540
+APP_CX = APP_X + APP_W / 2 # 340, the spine
+
+# ---- iroh box: one rounded box, layers touch with square internal dividers ----
+IX, IW = APP_X + 18, APP_W - 36 # 88, 504
+ITOP, IBH = APP_Y + 48, 46
+IROH = [
+ ("Protocols", "blobs · gossip · yours"),
+ ("Router", "dispatches connections by ALPN"),
+ ("Endpoint", "identity · address lookup · NAT · relay"),
+ ("QUIC + TLS 1.3", "encryption · auth · stream mux"),
+ ("Transport", "UDP and relay by default · swappable"),
+]
+IROH_H = len(IROH) * IBH
+TRANSPORT_BOTTOM = ITOP + IROH_H # bottom edge of the Transport layer (= iroh box bottom)
+APP_BOTTOM = ITOP + IROH_H + 16
+APP_H = APP_BOTTOM - APP_Y
+
+# ---- transport media row ----
+LAN_CX, WIFI_CX, TOR_CX = 200, 340, 480
+EXIT_XS = (APP_CX - 28, APP_CX, APP_CX + 28) # 3 separate wire exits, close together at the Transport box
+ICON_TOP = APP_BOTTOM + 52
+LABEL_Y = ICON_TOP + 64
+
+
+def iroh_box():
+ """One rounded box; the layers touch, separated by thin square dividers."""
+ parts = [f' ']
+ for i, (title, subtitle) in enumerate(IROH):
+ y = ITOP + i * IBH
+ if i: # divider above every layer but the first
+ parts.append(f' ')
+ parts.append(f' {title} ')
+ parts.append(f' {subtitle} ')
+ return "\n".join(parts)
+
+
+# ============================ transport media icons ============================
+
+def rj45(cx, top):
+ """RJ45 / CAT6 ethernet plug — 8 gold contacts, latch, boot, cable."""
+ pins = "".join(
+ f' '
+ for j in range(8))
+ return (
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' {pins}\n'
+ f' Ethernet \n'
+ f' LAN · UDP '
+ )
+
+
+def router(cx, cy):
+ """Wi-Fi router — body with two L-shaped side antennas, matching the
+ home-router design used in the other how-iroh-works diagrams."""
+ bw, bh = 46, 26
+ bx, by = cx - bw / 2, cy - bh / 2
+ return (
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' Wi-Fi \n'
+ f' UDP '
+ )
+
+
+def tor(cx, cy):
+ """Tor onion: concentric layers + a little sprout."""
+ return (
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' \n'
+ f' Tor \n'
+ f' onion routing '
+ )
+
+
+# ============================ assemble ============================
+# Three separate wires: vertical out of the Transport box, rounded across, vertical into each device.
+_sy, _ay = TRANSPORT_BOTTOM, ICON_TOP - 2
+_k = (_ay - _sy) * 0.55 # control-handle length → vertical tangents at both ends
+fan = "\n".join(
+ f' '
+ for ex, dx in zip(EXIT_XS, (LAN_CX, WIFI_CX, TOR_CX))
+)
+
+svg = f'''
+
+
+
+
+ Your application
+ iroh — embedded library
+
+
+{iroh_box()}
+
+
+{fan}
+{rj45(LAN_CX, ICON_TOP)}
+{router(WIFI_CX, ICON_TOP + 13)}
+{tor(TOR_CX, ICON_TOP + 13)}
+
+'''
+
+open(OUT_PATH, "w").write(svg)
+print(f"wrote {len(svg)} bytes -> {OUT_PATH}")
diff --git a/scripts/mdns-address-lookup.gen.py b/scripts/mdns-address-lookup.gen.py
new file mode 100644
index 00000000..c221b479
--- /dev/null
+++ b/scripts/mdns-address-lookup.gen.py
@@ -0,0 +1,353 @@
+#!/usr/bin/env python3
+"""Generator for mdns-address-lookup.svg ("Local address lookup over mDNS").
+
+The purely-local discovery method: three devices on the *same* network (an
+iPhone, an Android phone and an embedded device) behind one router. There is no
+server and no relay — each device periodically multicasts an mDNS announcement
+(its EndpointId + local address) to 224.0.0.251:5353. The router fans the packet
+out to the other devices, and each device's "knows" panel grows as the
+announcements arrive. After every device has announced once, all three know each
+other and a direct LAN connection is possible.
+
+The devices are deliberately unnamed: they are identified only by their short
+key id (gold key glyph) and their local address, the same things mDNS carries.
+
+One master SMIL loop (CYCLE seconds), constant packet speed (SPEED px/s) so the
+multicast dots reach the nearer device first and the farther device a moment
+later. SMIL only (no CSS keyframes) so the animation survives the production CSP
+(`default-src 'none'`) and Mintlify's embedding on docs.iroh.computer.
+
+Edit this script and run: python3 mdns-address-lookup.gen.py
+Writes ../public/animations/mdns-address-lookup.svg.
+The page embeds this via / ; the
+intrinsic aspect ratio comes from the SVG viewBox (VB_W x VB_H).
+"""
+
+import math
+import os
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+OUT_PATH = os.path.normpath(os.path.join(
+ HERE, "..", "public", "animations", "mdns-address-lookup.svg"))
+
+MONO = "'Space Mono', monospace"
+INDIGO, AMBER, GRAY, INK, BLUE = "#6366f1", "#d97706", "#888", "#111", "#2563eb"
+GOLD = "#eab308"
+
+# ============================ geometry ============================
+CX = [150, 410, 670] # device centers (iPhone, Android, embedded)
+KIND = ["iphone", "android", "embedded"]
+IDS = ["a4f7…", "8e2b…", "c1d9…"]
+ADDRS = ["192.168.0.3", "192.168.0.5", "192.168.0.7"]
+DEV_TOP = 250 # device body top
+PH = 92 # device body height
+JX, JY = 410, 115 # router box bottom center
+SPEED = 200.0 # px/second — every multicast dot travels at this speed
+
+ENTRY = [JX - 16, JX, JX + 16] # distinct router entry x per device (3 separate wires)
+DV, DV2 = 60, 72 # bezier control offsets: vertical at the device / at the router
+JH = (JX, JY - 10) # hidden hand-off point inside the router box (where dots split)
+
+
+def cubic_len(p0, p1, p2, p3, n=64):
+ """Numeric arc length of a cubic bezier (for constant-speed packet timing)."""
+ total, prev = 0.0, p0
+ for i in range(1, n + 1):
+ t = i / n
+ u = 1 - t
+ x = u*u*u*p0[0] + 3*u*u*t*p1[0] + 3*u*t*t*p2[0] + t*t*t*p3[0]
+ y = u*u*u*p0[1] + 3*u*u*t*p1[1] + 3*u*t*t*p2[1] + t*t*t*p3[1]
+ total += math.hypot(x - prev[0], y - prev[1])
+ prev = (x, y)
+ return total
+
+
+def leg_pts(i):
+ """Control points for device i's curved leg: leaves the device vertically,
+ enters the router vertically at the device's own offset entry point."""
+ cx, ex = CX[i], ENTRY[i]
+ return ((cx, DEV_TOP), (cx, DEV_TOP - DV), (ex, JY + DV2), (ex, JY))
+
+
+def leg_d(i):
+ p0, p1, p2, p3 = leg_pts(i)
+ return f"M {p0[0]} {p0[1]} C {p1[0]} {p1[1]} {p2[0]} {p2[1]} {p3[0]} {p3[1]}"
+
+
+def packet_d(a, r):
+ """Multicast dot path: up the announcer's leg to its entry, across to the
+ hidden hand-off point, then down the receiver's leg."""
+ pa, pr = leg_pts(a), leg_pts(r)
+ return (f"M {pa[0][0]} {pa[0][1]} C {pa[1][0]} {pa[1][1]} {pa[2][0]} {pa[2][1]} {pa[3][0]} {pa[3][1]} "
+ f"L {JH[0]} {JH[1]} L {pr[3][0]} {pr[3][1]} "
+ f"C {pr[2][0]} {pr[2][1]} {pr[1][0]} {pr[1][1]} {pr[0][0]} {pr[0][1]}")
+
+
+def leglen(i):
+ return cubic_len(*leg_pts(i))
+
+
+def total_len(a, r):
+ return (leglen(a)
+ + math.hypot(ENTRY[a] - JH[0], JY - JH[1])
+ + math.hypot(JH[0] - ENTRY[r], JH[1] - JY)
+ + leglen(r))
+
+
+# ============================ timeline ============================
+# Round-robin: each device announces once; the other two learn it.
+rounds = [] # (announcer, launch, receivers, {receiver: arrival})
+launch = 1.8
+for ai in range(3):
+ recs = [i for i in range(3) if i != ai]
+ arrs = {ri: launch + total_len(ai, ri) / SPEED for ri in recs}
+ rounds.append((ai, launch, recs, arrs))
+ launch = max(arrs.values()) + 1.0
+
+LAST_ARR = max(max(a.values()) for *_, a in rounds)
+HOLD = 4.0
+OUT0 = LAST_ARR + HOLD # persistent rows fade out before the loop restarts
+OUT1 = OUT0 + 0.7
+CYCLE = OUT1 + 0.6
+
+# What each device learns and when (sorted so its panel fills top-down).
+learned = {i: [] for i in range(3)}
+for ai, _, recs, arrs in rounds:
+ for ri in recs:
+ learned[ri].append((ai, arrs[ri] + 0.15))
+for i in learned:
+ learned[i].sort(key=lambda t: t[1])
+
+
+# ============================ SMIL helpers ============================
+def anim_opacity(points):
+ times = ";".join(f"{t/CYCLE:.4f}" for t, _ in points)
+ vals = ";".join(str(v) for _, v in points)
+ return (f' ')
+
+
+def anim_motion(wire_id, points):
+ times = ";".join(f"{t/CYCLE:.4f}" for t, _ in points)
+ kp = ";".join(str(k) for _, k in points)
+ return (f' ')
+
+
+# ============================ key glyphs ============================
+def _key_paths(xl, cy, s):
+ """The little gold key glyph (bow + shaft + teeth) starting at left edge xl."""
+ rb, rh = s * 0.34, s * 0.15
+ bx = xl + rb
+ hs = s * 0.18
+ xr = xl + s * 1.3
+ top, bot = cy - hs / 2, cy + hs / 2
+ tw = s * 0.13
+ return (
+ f' '
+ f' '
+ f' '
+ f' '
+ )
+
+
+def key_label(cx, y, text, size, color):
+ """Centered: gold key glyph + monospace label (the iroh identity on a device)."""
+ s = size
+ w, gap = s * 1.3, s * 0.3
+ xl = cx - (w + gap + s * 0.6 * len(text)) / 2
+ return (_key_paths(xl, y - s * 0.30, s) +
+ f'{text} ')
+
+
+def key_row(x, y, text, size, color=INK):
+ """Left-aligned: gold key glyph at x + monospace label (a learned-peer row)."""
+ s = size
+ w, gap = s * 1.3, s * 0.3
+ return (_key_paths(x, y - s * 0.30, s) +
+ f'{text} ')
+
+
+# ============================ device drawings ============================
+def iphone_screen(px0, py0, pw, ph):
+ sx0, sx1 = px0 + 3, px0 + pw - 3
+ sy0, sy1 = py0 + 3, py0 + ph - 3
+ cx = px0 + pw / 2
+ no, ni, nd, r = 7, 4, 5, 6
+ ny = sy0 + nd
+ return (
+ f' '
+ )
+
+
+def phone(cx, keytext, iphone=False):
+ pw, ph = 50, PH
+ px0 = cx - pw / 2
+ if iphone:
+ screen = iphone_screen(px0, DEV_TOP, pw, ph)
+ else:
+ screen = (f' '
+ f' ')
+ return f'''
+ {screen}
+
+ iroh
+ {key_label(cx, DEV_TOP+60, keytext, 8, AMBER)}'''
+
+
+def embedded(cx, keytext):
+ bw, bh = 60, PH
+ bx0 = cx - bw / 2
+ n = 6
+ pins = []
+ for i in range(n):
+ yy = DEV_TOP + 14 + i * (bh - 28) / (n - 1)
+ pins.append(f' ')
+ pins.append(f' ')
+ pins_s = "\n ".join(pins)
+ return f''' {pins_s}
+
+
+
+ iroh
+ {key_label(cx, DEV_TOP+60, keytext, 8, AMBER)}'''
+
+
+def device(i):
+ if KIND[i] == "iphone":
+ body = phone(CX[i], IDS[i], iphone=True)
+ elif KIND[i] == "android":
+ body = phone(CX[i], IDS[i], iphone=False)
+ else:
+ body = embedded(CX[i], IDS[i])
+ return f'''
+{body}
+ {ADDRS[i]}
+ '''
+
+
+def router(cx, cy):
+ """Home router with two side antennas — the exact design used in
+ hole-punching-lan.svg (router_lan), minus its gateway-IP label (the network
+ is already named by the multicast caption above)."""
+ bx, by, bw, bh = cx - 30, cy - 20, 60, 40
+ return f'''
+
+
+
+
+
+
+ '''
+
+
+# ============================ knowledge panels ============================
+def panel(i):
+ cx = CX[i]
+ w, x, y, h = 168, CX[i] - 84, 364, 64
+ rows = [f' local knowledge ']
+ for j, (ai, lt) in enumerate(learned[i]):
+ rowy = y + 38 + j * 20
+ pts = [(0, 0), (lt, 0), (lt + 0.4, 1), (OUT0, 1), (OUT1, 0), (CYCLE, 0)]
+ rows.append(
+ f' {anim_opacity(pts)}'
+ f'{key_row(x + 14, rowy, f"{IDS[ai]} {ADDRS[ai]}", 10)} ')
+ body = "\n".join(rows)
+ return f'''
+
+{body}
+ '''
+
+
+# ============================ multicast packets ============================
+wires, packets = [], []
+for ai, lnch, recs, arrs in rounds:
+ for ri in recs:
+ wid = f"mw{ai}{ri}"
+ wires.append(f' ')
+ arr = arrs[ri]
+ opa = [(0, 0), (lnch, 0), (lnch + 0.12, 1), (arr - 0.1, 1), (arr + 0.05, 0), (CYCLE, 0)]
+ mot = [(0, 0), (lnch, 0), (arr, 1), (CYCLE, 1)]
+ packets.append(f'''
+
+ {anim_opacity(opa)}
+ {anim_motion(wid, mot)}
+ ''')
+wires = "\n".join(wires)
+packets = "\n".join(packets)
+
+# ============================ announce callouts ============================
+callouts = []
+for ai, lnch, recs, arrs in rounds:
+ t0, t1 = lnch - 0.3, max(arrs.values()) + 0.4
+ pts = [(0, 0), (t0 - 0.3, 0), (t0, 1), (t1, 1), (t1 + 0.3, 0), (CYCLE, 0)]
+ w, h = 176, 44
+ x, y = CX[ai] - w / 2, 198
+ callouts.append(f'''
+ {anim_opacity(pts)}
+
+ mDNS announce
+ {IDS[ai]} @ {ADDRS[ai]}
+ ''')
+callouts = "\n".join(callouts)
+
+# ============================ static backdrop ============================
+legs = "\n".join(
+ f' '
+ for i in range(3))
+devices = "\n".join(device(i) for i in range(3))
+panels = "\n".join(panel(i) for i in range(3))
+
+VB_X, VB_Y, VB_W, VB_H = 0, 26, 820, 439
+svg = f'''
+
+
+
+ mDNS multicast
+ 224.0.0.251:5353
+
+
+{legs}
+
+
+{wires}
+
+{router(JX, 95)}
+
+
+{devices}
+
+
+{panels}
+
+
+{packets}
+
+
+{callouts}
+
+
+
+
+
+
+'''
+
+open(OUT_PATH, "w").write(svg)
+print(f"wrote {len(svg)} bytes -> {OUT_PATH} (CYCLE={CYCLE:.1f}s, last arrival at {LAST_ARR:.1f}s)")
diff --git a/scripts/publish-relay-dht.gen.py b/scripts/publish-relay-dht.gen.py
index 8ae2c082..7998114b 100644
--- a/scripts/publish-relay-dht.gen.py
+++ b/scripts/publish-relay-dht.gen.py
@@ -222,13 +222,21 @@ def answer_dot(wire_id, color_down, launch, reach, back):
alice_pts = [(0, 0), (ALICE_IN, 0), (ALICE_IN+0.6, 1), (OUT0, 1), (OUT1, 0), (CYCLE, 0)]
result_pts = [(0, 0), (FIRST_VALID, 0), (FIRST_VALID+0.5, 1), (OUT0, 1), (OUT1, 0), (CYCLE, 0)]
+RES_LABEL = "8e2b… is at relay us-east"
+res_w = len(RES_LABEL) * 11 * 0.6 # Space Mono advance ≈ 0.6em at 11px
+res_xr, res_yb = ax0 - 14, ay0 + 46 # right edge / baseline of the result text
alice = f'''
{anim_opacity(alice_pts)}
{phone(ax0, ay0, "1a9c…", iphone=True)}
Alice
- 8e2b… is at relay us-east{anim_opacity(result_pts)} '''
+
+
+ {anim_opacity(result_pts)}
+
+ {RES_LABEL}
+ '''
# ============================ wires + dots ============================
pub_wire_pts = [(0, 0), (0.6, 0), (1.0, 1), (PUB_DONE, 1), (PUB_DONE+0.5, 0), (CYCLE, 0)]
diff --git a/scripts/publish-relay.gen.py b/scripts/publish-relay.gen.py
index af52b000..01656fda 100644
--- a/scripts/publish-relay.gen.py
+++ b/scripts/publish-relay.gen.py
@@ -186,14 +186,21 @@ def doc_packet(wire_id, color, opa_pts, mot_pts):
acx = ax0+24
alice_pts = [(0, 0), (3.8, 0), (4.4, 1), (13.8, 1), (14.2, 0), (CYCLE, 0)]
result_pts = [(0, 0), (8.0, 0), (8.5, 1), (13.8, 1), (14.2, 0), (CYCLE, 0)]
+RES_LABEL = "8e2b… is at relay us-east"
+res_w = len(RES_LABEL) * 11 * 0.6 # Space Mono advance ≈ 0.6em at 11px
+res_xr, res_yb = ax0 - 14, ay0 + 46 # right edge / baseline of the result text
alice = f'''
{anim_opacity(alice_pts)}
{phone(ax0, ay0, "1a9c…", iphone=True)}
Alice
-
- 8e2b… is at relay us-east{anim_opacity(result_pts)} '''
+
+
+ {anim_opacity(result_pts)}
+
+ {RES_LABEL}
+ '''
# ============================ wires ============================
put_d = arc(bcx, by0, sx+18, sy+sh, k=0.18) # Bob -> server