search for standard sites pub-search.waow.tech
search zig blog atproto
0

Configure Feed

Select the types of activity you want to include in your feed.

feat(atlas): orbital billboards + real publication theme colors

the info text lifts off the planet surface onto a shell 8% above it —
billboards in orbit: they float over the terrain and hang past the limb
into space. meta band moved toward the equator and upsized for
readability at a distance.

build-atlas now resolves each leaflet publication's actual theme
(pub.leaflet.publication backgroundColor/accentBackground, fetched from
its PDS, cached in atlas-theme-cache.json with the avatar-cache
deployment-roundtrip pattern) and bakes themeBg/themeAccent into
atlas.json. the frontend prefers the author's palette for planet
surfaces and card accents; avatar sampling remains the fallback.
307 of 738 leaflet pubs currently carry themes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

+166 -18
+1
.gitignore
··· 14 14 .claude/ 15 15 .codex/ 16 16 __pycache__/ 17 + site/atlas-theme-cache.json
+103
scripts/build-atlas
··· 39 39 AVATAR_CACHE_REMOTE = "https://pub-search.waow.tech/atlas-avatar-cache.json" 40 40 AVATAR_CACHE_TTL_SECONDS = 7 * 86400 # refresh entries weekly so changed avatars trickle in 41 41 42 + # theme cache: persists basePath → leaflet publication theme colors. 43 + # same deployment-roundtrip pattern as the avatar cache above. 44 + THEME_CACHE_PATH = Path(__file__).resolve().parent.parent / "site" / "atlas-theme-cache.json" 45 + THEME_CACHE_REMOTE = "https://pub-search.waow.tech/atlas-theme-cache.json" 46 + 42 47 43 48 def log(msg: str) -> None: 44 49 print(msg, flush=True) ··· 390 395 return fresh 391 396 392 397 398 + def _theme_color_hex(color: dict | None) -> str: 399 + """pub.leaflet.theme.color#rgb{a} object → #rrggbb, or '' if absent.""" 400 + if not isinstance(color, dict): 401 + return "" 402 + try: 403 + return "#%02x%02x%02x" % (int(color["r"]), int(color["g"]), int(color["b"])) 404 + except (KeyError, ValueError, TypeError): 405 + return "" 406 + 407 + 408 + async def resolve_themes_async( 409 + pubs: list[dict], cache: dict[str, dict], concurrency: int = 16 410 + ) -> dict[str, dict]: 411 + """Resolve leaflet publication theme colors from their PDS records. 412 + 413 + pub.leaflet.publication records carry the author's actual palette 414 + (backgroundColor / accentBackground); the atlas uses it to paint that 415 + pub's planets. One listRecords per DID covers all of its publications. 416 + Empty results are cached too. Returns {basePath: {bg, accent, ts}} for 417 + exactly the leaflet pubs passed in.""" 418 + now = time.time() 419 + cutoff = now - AVATAR_CACHE_TTL_SECONDS 420 + 421 + targets = [p for p in pubs if p.get("platform") == "leaflet" and str(p.get("did", "")).startswith("did:plc:")] 422 + fresh: dict[str, dict] = {} 423 + by_did: dict[str, list[str]] = {} 424 + for p in targets: 425 + entry = cache.get(p["basePath"]) 426 + if isinstance(entry, dict) and float(entry.get("ts", 0)) > cutoff: 427 + fresh[p["basePath"]] = entry 428 + else: 429 + by_did.setdefault(p["did"], []).append(p["basePath"]) 430 + 431 + log(f" theme cache: {len(fresh)} hits, {len(by_did)} DIDs to fetch") 432 + if not by_did: 433 + return fresh 434 + 435 + sem = asyncio.Semaphore(concurrency) 436 + async with httpx.AsyncClient( 437 + timeout=httpx.Timeout(10.0), 438 + limits=httpx.Limits( 439 + max_connections=concurrency, 440 + max_keepalive_connections=concurrency, 441 + ), 442 + ) as client: 443 + async def fetch_did(did: str, base_paths: list[str]) -> dict[str, dict]: 444 + out = {bp: {"bg": "", "accent": "", "ts": now} for bp in base_paths} 445 + async with sem: 446 + try: 447 + doc = (await client.get(f"https://plc.directory/{did}")).json() 448 + pds = next( 449 + s["serviceEndpoint"] for s in doc.get("service", []) 450 + if s.get("id") == "#atproto_pds" 451 + ) 452 + r = await client.get( 453 + f"{pds}/xrpc/com.atproto.repo.listRecords", 454 + params={"repo": did, "collection": "pub.leaflet.publication", "limit": 50}, 455 + ) 456 + if r.status_code != 200: 457 + return out 458 + for rec in r.json().get("records", []): 459 + value = rec.get("value", {}) 460 + bp = value.get("base_path", "") 461 + if bp not in out: 462 + continue 463 + theme = value.get("theme") or {} 464 + out[bp] = { 465 + "bg": _theme_color_hex(theme.get("backgroundColor") or theme.get("pageBackground")), 466 + "accent": _theme_color_hex(theme.get("accentBackground") or theme.get("primary")), 467 + "ts": now, 468 + } 469 + except Exception: 470 + pass 471 + return out 472 + 473 + results = await asyncio.gather(*(fetch_did(d, bps) for d, bps in by_did.items())) 474 + 475 + for chunk in results: 476 + fresh.update(chunk) 477 + return fresh 478 + 479 + 393 480 def main(): 394 481 parser = argparse.ArgumentParser(description="Build atlas.json") 395 482 parser.add_argument( ··· 620 707 # attach avatar URLs to publications 621 708 for pub in publications: 622 709 pub["avatar"] = did_avatars.get(pub["did"], "") 710 + 711 + # fetch leaflet publication theme colors — the author's actual palette, 712 + # used by the atlas to paint that publication's planets 713 + log(" resolving leaflet publication themes...") 714 + theme_cache = load_avatar_cache(THEME_CACHE_PATH, THEME_CACHE_REMOTE) 715 + theme_entries = asyncio.run(resolve_themes_async(publications, theme_cache)) 716 + save_avatar_cache(THEME_CACHE_PATH, theme_entries) 717 + themed = 0 718 + for pub in publications: 719 + t = theme_entries.get(pub["basePath"]) or {} 720 + if t.get("accent"): 721 + pub["themeAccent"] = t["accent"] 722 + if t.get("bg"): 723 + pub["themeBg"] = t["bg"] 724 + themed += 1 725 + log(f" {themed} publications have theme colors") 623 726 624 727 # --- step 6: build output --- 625 728 log("building output...")
+35 -7
site/atlas.js
··· 197 197 return 'rgb(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ')'; 198 198 } 199 199 200 + // prefer the publication's own theme colors (baked into atlas.json from 201 + // pub.leaflet.publication records) — that's the author's actual palette; 202 + // avatar sampling is the fallback for pubs without one. 203 + function resolvePubAccent(pub) { 204 + var key = pub && pub.basePath; 205 + if (!key || pubAccents[key] !== undefined) return; 206 + if (pub.themeAccent) { 207 + var rgb = parseHex(pub.themeAccent); 208 + var hsl = rgbToHsl(rgb[0], rgb[1], rgb[2]); 209 + var bg = pub.themeBg ? parseHex(pub.themeBg) : null; 210 + pubAccents[key] = { 211 + h: hsl[0], 212 + s: Math.max(0.08, Math.min(0.85, hsl[1])), 213 + key: 'theme' + pub.themeAccent, 214 + rgb: [rgb[0] / 255, rgb[1] / 255, rgb[2] / 255], 215 + bgRgb: bg ? [bg[0] / 255, bg[1] / 255, bg[2] / 255] : null, 216 + }; 217 + return; 218 + } 219 + extractPubAccent(pub); 220 + } 221 + 200 222 function extractPubAccent(pub) { 201 223 var key = pub && pub.basePath; 202 224 if (!key || pubAccents[key] !== undefined || pubAccentLoading[key]) return; ··· 484 506 var theme = frameDark ? 'dark' : 'light'; 485 507 var p = data.points[i]; 486 508 var pub = pubByBasePath && p.basePath ? pubByBasePath.get(p.basePath) : null; 487 - if (pub) extractPubAccent(pub); 509 + if (pub) resolvePubAccent(pub); 488 510 var accent = p.basePath ? pubAccents[p.basePath] : null; 489 511 var accentKey = accent ? accent.key : 'none'; 490 512 var e = planetTex.get(i); ··· 502 524 // base surface: the publication's accent hue when we have it, 503 525 // otherwise a tint of the platform color 504 526 var baseRGB, accentRGB; 505 - if (accent) { 527 + if (accent && accent.rgb) { 528 + // literal author theme colors, exactly as they styled their publication 529 + baseRGB = accent.bgRgb 530 + ? [Math.round(accent.bgRgb[0] * 255), Math.round(accent.bgRgb[1] * 255), Math.round(accent.bgRgb[2] * 255)] 531 + : hslToRgb(accent.h, accent.s, frameDark ? 0.30 : 0.62); 532 + accentRGB = [Math.round(accent.rgb[0] * 255), Math.round(accent.rgb[1] * 255), Math.round(accent.rgb[2] * 255)]; 533 + } else if (accent) { 506 534 baseRGB = hslToRgb(accent.h, accent.s, frameDark ? 0.30 : 0.62); 507 535 accentRGB = hslToRgb(accent.h, accent.s, frameDark ? 0.55 : 0.45); 508 536 } else { ··· 539 567 var meta = p.basePath || (p.uri.split('/')[2] || ''); 540 568 if (meta) { 541 569 if (meta.length > 42) meta = meta.slice(0, 41) + '…'; 542 - g.font = '22px monospace'; 570 + g.font = '24px monospace'; 543 571 var mw = g.measureText(meta).width; 544 572 var m2 = Math.max(1, Math.floor(PLANET_TEX_W / (mw + 80))); 545 573 var period2 = PLANET_TEX_W / m2; 546 - if (accent) g.fillStyle = accentCss(accent, frameDark ? 0.68 : 0.30); 547 - else g.fillStyle = frameDark ? hexToRgba(c.core, 0.85) : 'rgba(0,0,0,0.6)'; 574 + if (accent) g.fillStyle = accentCss(accent, frameDark ? 0.70 : 0.30); 575 + else g.fillStyle = frameDark ? hexToRgba(c.core, 0.9) : 'rgba(0,0,0,0.6)'; 548 576 for (var k2 = 0; k2 * period2 < cv.width; k2++) { 549 - g.fillText(meta, k2 * period2, 91); 577 + g.fillText(meta, k2 * period2, 86); 550 578 } 551 579 } 552 580 e = { ··· 1185 1213 var lines = wrapText(title, innerW, maxTitleLines); 1186 1214 1187 1215 var pub = pubByBasePath ? pubByBasePath.get(p.basePath) : null; 1188 - if (pub) extractPubAccent(pub); 1216 + if (pub) resolvePubAccent(pub); 1189 1217 var cardAccent = p.basePath ? pubAccents[p.basePath] : null; 1190 1218 var showAvatar = !!pub; 1191 1219 var headH = showAvatar ? Math.max(logoS, avatarS) : logoS;
+27 -11
site/planet-gl.js
··· 37 37 '#endif', 38 38 'varying vec2 vP;', 39 39 'uniform sampler2D uTex;', 40 - 'uniform float uRot, uTilt, uAlpha, uSeed, uTexSpan, uHover, uDark, uPx, uMargin;', 40 + 'uniform float uRot, uTilt, uAlpha, uSeed, uTexSpan, uHover, uDark, uPx, uMargin, uLift;', 41 41 'uniform vec3 uBase, uAccent;', 42 42 43 43 'float hash3(vec3 p) {', ··· 65 65 'void main() {', 66 66 ' float r = length(vP);', 67 67 ' vec4 outc = vec4(0.0);', 68 + ' float ct = cos(uTilt), st = sin(uTilt);', 69 + // the info shell: billboards in orbit, a few percent above the surface — 70 + // they float over the terrain and hang past the limb into space 71 + ' vec4 em = vec4(0.0);', 72 + ' if (r < uLift) {', 73 + ' float zt = sqrt(uLift * uLift - r * r);', 74 + ' vec3 Nb = vec3(vP, zt) / uLift;', 75 + ' vec3 Nbt = vec3(Nb.x, Nb.y * ct + Nb.z * st, -Nb.y * st + Nb.z * ct);', 76 + ' float latB = asin(clamp(Nbt.y, -1.0, 1.0));', 77 + ' float lonB = atan(Nbt.x, Nbt.z) + uRot;', 78 + ' em = texture2D(uTex, vec2(fract(lonB * 0.15915494) * uTexSpan, clamp(0.5 - latB * 0.31830988, 0.0, 1.0)));', 79 + ' em.a *= smoothstep(uLift, uLift - 6.0 * uPx, r);', 80 + ' }', 68 81 ' if (r < 1.0) {', 69 82 ' float z = sqrt(1.0 - r * r);', 70 83 ' vec3 N = vec3(vP, z);', 71 84 // tilt: we orbit slightly north of the equator, so the equator dips 72 - ' float ct = cos(uTilt), st = sin(uTilt);', 73 85 ' vec3 Nt = vec3(N.x, N.y * ct + N.z * st, -N.y * st + N.z * ct);', 74 86 ' float lat = asin(clamp(Nt.y, -1.0, 1.0));', 75 87 ' float lon = atan(Nt.x, Nt.z) + uRot;', 76 - ' vec2 tuv = vec2(fract(lon * 0.15915494) * uTexSpan, clamp(0.5 - lat * 0.31830988, 0.0, 1.0));', 77 - ' vec4 em = texture2D(uTex, tuv);', 78 88 // terrain in the planet-fixed frame so it spins with the surface 79 89 ' vec3 P = vec3(cos(lat) * sin(lon), sin(lat), cos(lat) * cos(lon));', 80 90 ' float terr = fbm(P * 2.8);', ··· 91 101 ' vec3 surf = land * (ambient + (1.0 - ambient) * max(ndl, 0.0));', 92 102 ' vec3 H = normalize(L + vec3(0.0, 0.0, 1.0));', 93 103 ' surf += uAccent * pow(max(dot(N, H), 0.0), 70.0) * 0.4 * day;', 94 - // the document's info: emissive city lights, brightest after dark 95 - ' float emBoost = mix(1.0, mix(1.65, 1.05, day), uDark);', 96 - ' surf = mix(surf, em.rgb * emBoost, em.a * 0.95);', 97 - ' surf += em.rgb * em.a * 0.4 * (1.0 - day) * uDark;', 98 104 // atmosphere hugging the limb 99 105 ' float fres = pow(1.0 - z, 2.2);', 100 106 ' surf += uAccent * fres * (0.55 + uHover * 0.5);', 107 + // the orbital billboards over the surface: emissive, brightest at night 108 + ' float emBoost = mix(1.0, mix(1.7, 1.1, day), uDark);', 109 + ' surf = mix(surf, em.rgb * emBoost, em.a * 0.95);', 110 + ' surf += em.rgb * em.a * 0.4 * (1.0 - day) * uDark;', 101 111 ' float edge = smoothstep(1.0, 1.0 - 3.0 * uPx, r);', 102 112 ' outc = vec4(surf, edge);', 103 113 ' } else {', 104 - // atmosphere halo past the limb 114 + // past the limb: atmosphere halo, with billboards hanging into space 105 115 ' float d = (r - 1.0) / (uMargin - 1.0);', 106 116 ' float glow = pow(max(1.0 - d, 0.0), 2.6);', 107 - ' outc = vec4(uAccent * (0.8 + 0.4 * glow), glow * (0.32 + uHover * 0.28));', 117 + ' vec3 col = uAccent * (0.8 + 0.4 * glow);', 118 + ' float a = glow * (0.32 + uHover * 0.28);', 119 + ' float billBoost = mix(1.0, 1.45, uDark);', 120 + ' col = mix(col, em.rgb * billBoost, em.a);', 121 + ' a = max(a, em.a * 0.95);', 122 + ' outc = vec4(col, a);', 108 123 ' }', 109 124 ' gl_FragColor = vec4(outc.rgb, outc.a * uAlpha);', 110 125 '}', ··· 142 157 143 158 var U = {}; 144 159 ['uRes', 'uCenter', 'uRadius', 'uMargin', 'uTex', 'uRot', 'uTilt', 'uAlpha', 145 - 'uSeed', 'uTexSpan', 'uHover', 'uDark', 'uPx', 'uBase', 'uAccent'].forEach(function(n) { 160 + 'uSeed', 'uTexSpan', 'uHover', 'uDark', 'uPx', 'uBase', 'uAccent', 'uLift'].forEach(function(n) { 146 161 U[n] = gl.getUniformLocation(prog, n); 147 162 }); 148 163 var aPos = gl.getAttribLocation(prog, 'aPos'); ··· 190 205 gl.uniform1f(U.uDark, dark ? 1 : 0); 191 206 gl.uniform1f(U.uMargin, MARGIN); 192 207 gl.uniform1f(U.uTilt, 0.35); 208 + gl.uniform1f(U.uLift, 1.08); 193 209 }, 194 210 // opts: {base:[r,g,b 0-1], accent:[r,g,b 0-1], seed, texSpan, hover, dpr} 195 211 draw: function(texCanvas, sx, sy, R, alpha, rot, opts) {