diff --git a/examples/app_hub/README.md b/examples/app_hub/README.md
index f7a9beb..0615c26 100644
--- a/examples/app_hub/README.md
+++ b/examples/app_hub/README.md
@@ -2,7 +2,16 @@
**Infra overview:** [docs/INFRA.md](../../docs/INFRA.md)
-Static landing: `index.html`, `hub.css`, `assets/hub-logo.svg` (globe + rocket; palette matches crash console).
+Static landing: `index.html`, `hub.css`, `hub-logo-layers.css`.
+
+Logo assets in `assets/` (editable fragments, loaded on demand):
+- `hub-logo-fragment-space.svg` — cosmic background (#space-bg, #stars-layer, #nebula-*)
+- `hub-logo-fragment-globe.svg` — Earth (round border + Mercator grid 3x3, no continents)
+- `hub-logo-transparent.svg` — header mark (globe only)
+
+Review one layer: `?logoPart=space|globe|full`
+
+Compare Earth grids: `?logoPart=globe&grid=3` (default) or `?logoPart=full&grid=4`
**BE only.** FE `apps.f0xx.org` sends `location /` → `http://artc0.intra.raptor.org:80` (not :8089).
diff --git a/examples/app_hub/assets/_snapshot-globe-grid3.png b/examples/app_hub/assets/_snapshot-globe-grid3.png
new file mode 100644
index 0000000..95584d7
Binary files /dev/null and b/examples/app_hub/assets/_snapshot-globe-grid3.png differ
diff --git a/examples/app_hub/assets/extract_red_polygons.py b/examples/app_hub/assets/extract_red_polygons.py
new file mode 100644
index 0000000..bf3029e
--- /dev/null
+++ b/examples/app_hub/assets/extract_red_polygons.py
@@ -0,0 +1,240 @@
+#!/usr/bin/env python3
+"""Extract red land polygons from Mercator reference image -> SVG paths."""
+
+from __future__ import annotations
+
+import json
+import math
+from collections import deque
+from pathlib import Path
+
+import numpy as np
+from PIL import Image
+
+ROOT = Path(__file__).resolve().parent
+SRC = ROOT / "mercator-source.jpg"
+OUT_FLAT = ROOT / "hub-logo-continents-mercator-flat.svg"
+OUT_META = ROOT / "continents-meta.json"
+
+# Source image is 800x519 Mercator-style plate.
+W, H = 800, 519
+
+
+def red_mask(rgb: np.ndarray) -> np.ndarray:
+ r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
+ return (r.astype(np.int16) > 150) & (g.astype(np.int16) < 120) & (b.astype(np.int16) < 120)
+
+
+def dilate(mask: np.ndarray, radius: int = 3) -> np.ndarray:
+ """Bridge thin white grid lines between red land cells."""
+ h, w = mask.shape
+ out = mask.copy()
+ ys, xs = np.where(mask)
+ for y, x in zip(ys, xs):
+ y0 = max(0, y - radius)
+ y1 = min(h, y + radius + 1)
+ x0 = max(0, x - radius)
+ x1 = min(w, x + radius + 1)
+ if mask[y0:y1, x0:x1].any():
+ out[y0:y1, x0:x1] = True
+ return out
+
+
+def label_components(mask: np.ndarray) -> tuple[np.ndarray, int]:
+ h, w = mask.shape
+ labels = np.zeros((h, w), dtype=np.int32)
+ current = 0
+ for y in range(h):
+ for x in range(w):
+ if not mask[y, x] or labels[y, x]:
+ continue
+ current += 1
+ q = deque([(x, y)])
+ labels[y, x] = current
+ while q:
+ cx, cy = q.popleft()
+ for nx, ny in ((cx + 1, cy), (cx - 1, cy), (cx, cy + 1), (cx, cy - 1)):
+ if 0 <= nx < w and 0 <= ny < h and mask[ny, nx] and labels[ny, nx] == 0:
+ labels[ny, nx] = current
+ q.append((nx, ny))
+ return labels, current
+
+
+def trace_boundary(labels: np.ndarray, label_id: int) -> list[tuple[int, int]]:
+ """Moore-neighbor contour for one connected component."""
+ h, w = labels.shape
+ comp = labels == label_id
+ if not comp.any():
+ return []
+
+ # Start at topmost-leftmost pixel of component.
+ ys, xs = np.where(comp)
+ start = (int(xs[0]), int(ys[0]))
+ x, y = start
+ path = [start]
+ # Directions: E, SE, S, SW, W, NW, N, NE
+ dirs = [(1, 0), (1, 1), (0, 1), (-1, 1), (-1, 0), (-1, -1), (0, -1), (1, -1)]
+ dir_idx = 0
+
+ def is_land(px: int, py: int) -> bool:
+ return 0 <= px < w and 0 <= py < h and labels[py, px] == label_id
+
+ for _ in range(w * h * 4):
+ found = False
+ for i in range(8):
+ nd = (dir_idx + i) % 8
+ dx, dy = dirs[nd]
+ nx, ny = x + dx, y + dy
+ if is_land(nx, ny):
+ x, y = nx, ny
+ dir_idx = (nd + 6) % 8
+ path.append((x, y))
+ found = True
+ break
+ if not found:
+ break
+ if (x, y) == start and len(path) > 8:
+ break
+ return path
+
+
+def simplify(points: list[tuple[int, int]], min_dist: float = 2.0) -> list[tuple[int, int]]:
+ if len(points) < 3:
+ return points
+ out = [points[0]]
+ for p in points[1:]:
+ ox, oy = out[-1]
+ if math.hypot(p[0] - ox, p[1] - oy) >= min_dist:
+ out.append(p)
+ if out[0] != out[-1]:
+ out.append(out[0])
+ return out
+
+
+def path_d(points: list[tuple[float, float]]) -> str:
+ if len(points) < 3:
+ return ""
+ x0, y0 = points[0]
+ parts = [f"M {x0:.1f} {y0:.1f}"]
+ for x, y in points[1:]:
+ parts.append(f"L {x:.1f} {y:.1f}")
+ parts.append("Z")
+ return " ".join(parts)
+
+
+def mercator_y_to_lat(y: float, h: float) -> float:
+ # Map top->north, bottom->south; clamp to avoid infinities at poles.
+ t = 1.0 - (y / h)
+ t = min(max(t, 0.02), 0.98)
+ return math.degrees(math.atan(math.sinh(math.pi * (2 * t - 1))))
+
+
+def lat_lon_to_globe(lat: float, lon: float, cx: float, cy: float, r: float) -> tuple[float, float]:
+ """Simple orthographic-ish placement on visible globe disk."""
+ lat_r = math.radians(lat)
+ lon_r = math.radians(lon)
+ # Visible hemisphere centered around lon 20, lat 20 for this logo framing.
+ lon0 = math.radians(20.0)
+ lat0 = math.radians(15.0)
+ # Rotate lon relative to center.
+ dlon = lon_r - lon0
+ x = cx + r * math.cos(lat_r) * math.sin(dlon)
+ y = cy - r * math.sin(lat_r)
+ return x, y
+
+
+def map_to_globe(points: list[tuple[int, int]], w: int, h: int) -> list[tuple[float, float]]:
+ out: list[tuple[float, float]] = []
+ cx, cy, r = 720.0, 560.0, 318.0
+ for x, y in points:
+ lon = (x / w) * 360.0 - 180.0
+ lat = mercator_y_to_lat(float(y), float(h))
+ gx, gy = lat_lon_to_globe(lat, lon, cx, cy, r)
+ out.append((gx, gy))
+ return out
+
+
+def main() -> None:
+ img = Image.open(SRC).convert("RGB")
+ rgb = np.array(img)
+ w, h = rgb.shape[1], rgb.shape[0]
+ mask = dilate(red_mask(rgb), radius=3)
+ labels, n = label_components(mask)
+
+ components: list[dict] = []
+ for label_id in range(1, n + 1):
+ area = int((labels == label_id).sum())
+ if area < 500: # drop tiny noise / watermark specks
+ continue
+ boundary = trace_boundary(labels, label_id)
+ if len(boundary) < 20:
+ continue
+ boundary = simplify(boundary, min_dist=3.5)
+ ys, xs = np.where(labels == label_id)
+ bbox = [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())]
+ components.append(
+ {
+ "id": label_id,
+ "area": area,
+ "bbox": bbox,
+ "flat": path_d([(float(x), float(y)) for x, y in boundary]),
+ "globe": path_d(map_to_globe(boundary, w, h)),
+ }
+ )
+
+ components.sort(key=lambda c: c["area"], reverse=True)
+
+ flat_paths = "\n ".join(
+ f''
+ for c in components
+ )
+ globe_paths = "\n ".join(
+ f''
+ for c in components
+ )
+
+ OUT_FLAT.write_text(
+ f"""
+""",
+ encoding="utf-8",
+ )
+
+ globe_svg = ROOT.parent.parent / "examples" / "app_hub" / "assets" / "hub-logo-continents-mercator-globe.svg"
+ globe_svg.write_text(
+ f"""
+""",
+ encoding="utf-8",
+ )
+
+ OUT_META.write_text(
+ json.dumps(
+ {
+ "source": str(SRC.name),
+ "size": [w, h],
+ "components": len(components),
+ "areas_top10": [c["area"] for c in components[:10]],
+ },
+ indent=2,
+ ),
+ encoding="utf-8",
+ )
+
+ print(f"components={len(components)}")
+ print(f"wrote {OUT_FLAT}")
+ print(f"wrote {globe_svg}")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/examples/app_hub/assets/hub-logo-continents-mercator-flat.svg b/examples/app_hub/assets/hub-logo-continents-mercator-flat.svg
new file mode 100644
index 0000000..eecc1f6
--- /dev/null
+++ b/examples/app_hub/assets/hub-logo-continents-mercator-flat.svg
@@ -0,0 +1,13 @@
+
diff --git a/examples/app_hub/assets/hub-logo-continents-mercator-globe.svg b/examples/app_hub/assets/hub-logo-continents-mercator-globe.svg
new file mode 100644
index 0000000..e09b66c
--- /dev/null
+++ b/examples/app_hub/assets/hub-logo-continents-mercator-globe.svg
@@ -0,0 +1,15 @@
+
diff --git a/examples/app_hub/assets/hub-logo-fragment-globe-4x4.svg b/examples/app_hub/assets/hub-logo-fragment-globe-4x4.svg
new file mode 100644
index 0000000..2a39497
--- /dev/null
+++ b/examples/app_hub/assets/hub-logo-fragment-globe-4x4.svg
@@ -0,0 +1,24 @@
+
diff --git a/examples/app_hub/assets/hub-logo-fragment-globe-grid-overlay.svg b/examples/app_hub/assets/hub-logo-fragment-globe-grid-overlay.svg
new file mode 100644
index 0000000..d380efe
--- /dev/null
+++ b/examples/app_hub/assets/hub-logo-fragment-globe-grid-overlay.svg
@@ -0,0 +1,48 @@
+
diff --git a/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260526e.svg b/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260526e.svg
new file mode 100644
index 0000000..bec4c73
--- /dev/null
+++ b/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260526e.svg
@@ -0,0 +1,4 @@
+
diff --git a/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527g.svg b/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527g.svg
new file mode 100644
index 0000000..c61188d
--- /dev/null
+++ b/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527g.svg
@@ -0,0 +1,4 @@
+
diff --git a/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527i.svg b/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527i.svg
new file mode 100644
index 0000000..9878e2f
--- /dev/null
+++ b/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527i.svg
@@ -0,0 +1,4 @@
+
diff --git a/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527j.svg b/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527j.svg
new file mode 100644
index 0000000..83778c7
--- /dev/null
+++ b/examples/app_hub/assets/hub-logo-fragment-globe-snapshot-20260527j.svg
@@ -0,0 +1,4 @@
+
diff --git a/examples/app_hub/assets/hub-logo-fragment-globe.svg b/examples/app_hub/assets/hub-logo-fragment-globe.svg
index a40e620..80a644f 100644
--- a/examples/app_hub/assets/hub-logo-fragment-globe.svg
+++ b/examples/app_hub/assets/hub-logo-fragment-globe.svg
@@ -1,38 +1,62 @@
-