fix: federation peer-joined updates empty onion addresses
Some checks failed
Build Archipelago ISO (dev) / build-iso (push) Failing after 28m27s
Build Archipelago ISO / build-iso (push) Has been cancelled
Container Orchestration Tests / smoke-tests (push) Has been cancelled
Container Orchestration Tests / unit-tests (push) Has been cancelled

When a node was already known (via link-node) but had an empty onion
address, the peer-joined handler returned early without updating the
onion. Now it patches missing onion/pubkey fields on existing nodes.

Also adds update_node() to federation storage and updates the
architecture comparison doc with system resources, StartOS/umbrelOS
tabs, Web5 section, and comparison view.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dorian
2026-04-01 16:25:27 +01:00
parent ce3e64e2d5
commit 6656fed9d6
4 changed files with 344 additions and 26 deletions

View File

@@ -119,6 +119,9 @@
.proto-card { background: rgba(255,255,255,0.03); border: 1px solid rgba(255,255,255,0.06); border-radius: 10px; padding: 14px 16px; cursor: pointer; transition: all 0.2s; overflow-wrap: break-word; word-break: break-word; }
.proto-card:hover { background: rgba(255,255,255,0.06); border-color: rgba(255,255,255,0.12); }
.proto-card.expanded .proto-details { display: block; }
.proto-grid.all-expanded { align-items: stretch; }
.proto-grid.all-expanded .proto-card { display: flex; flex-direction: column; }
.proto-grid.all-expanded .proto-details { flex: 1; }
.proto-card-name { font-size: 14px; font-weight: 600; color: #fff; }
.proto-card-desc { font-size: 12px; color: #8b8fa3; margin-top: 4px; }
.proto-card-layman { font-size: 11px; color: #6b7280; margin-top: 2px; font-style: italic; }
@@ -315,6 +318,76 @@
body.light .l-cnt { background: rgba(16,185,129,0.05); border-color: rgba(16,185,129,0.15); }
body.light .l-ui { background: rgba(236,72,153,0.05); border-color: rgba(236,72,153,0.15); }
body.light .l-kiosk { background: rgba(251,146,60,0.05); border-color: rgba(251,146,60,0.15); }
/* ═══ MOBILE ═══ */
@media (max-width: 768px) {
/* Header: compact, hide subtitle, move controls */
.header { padding: 12px 16px; }
.header h1 { font-size: 18px; }
.header p { display: none; }
.header-controls { position: static; display: flex; margin-top: 8px; }
/* System selector: scroll horizontally, smaller text */
.sys-selector { padding: 0 8px; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
.sys-selector::-webkit-scrollbar { display: none; }
.sys-btn { padding: 8px 14px; font-size: 13px; white-space: nowrap; flex-shrink: 0; }
.sys-version { display: none; }
/* Nav: scroll horizontally, smaller */
.nav { padding: 0; overflow-x: auto; -webkit-overflow-scrolling: touch; scrollbar-width: none; }
.nav::-webkit-scrollbar { display: none; }
.nav-btn { padding: 10px 14px; font-size: 12px; flex-shrink: 0; }
/* Section content: reduce padding */
.section { padding: 16px; }
/* Stats: 2 columns on mobile */
.stats { grid-template-columns: repeat(3, 1fr); gap: 8px; }
.stat { padding: 10px 8px; }
.stat-value { font-size: 18px; }
.stat-label { font-size: 10px; }
/* Cards: single column */
.cards { grid-template-columns: 1fr; }
.proto-grid { grid-template-columns: 1fr; }
.sec-grid { grid-template-columns: 1fr; }
/* Layers: tighter padding */
.layer { padding: 12px 14px; }
/* Dep chain: smaller font */
.dep-chain { font-size: 11px; padding: 12px 14px; overflow-x: auto; }
/* Path tree: smaller font */
.path-tree { font-size: 10px; padding: 12px 14px; }
/* Comparison table: horizontal scroll */
.cmp-table { display: block; overflow-x: auto; }
/* Glossary: single column */
.glossary { column-count: 1; }
/* Resource grid: single column */
.section > div[style*="grid-template-columns: 1fr 1fr"] { display: flex !important; flex-direction: column !important; }
/* Popover: full width on mobile */
.popover { width: calc(100vw - 32px); left: 16px !important; }
/* Boot steps: tighter */
.boot-step { gap: 12px; }
.boot-num { width: 24px; height: 24px; font-size: 11px; }
/* Toggle buttons */
.toggle-btn { padding: 6px 10px; font-size: 11px; }
}
/* Small phones */
@media (max-width: 400px) {
.stats { grid-template-columns: repeat(2, 1fr); }
.sys-btn { padding: 8px 10px; font-size: 12px; }
.nav-btn { padding: 8px 10px; font-size: 11px; }
.header h1 { font-size: 16px; }
}
</style>
<body>
@@ -355,11 +428,11 @@
<div class="stats">
<div class="stat"><div class="stat-value">34</div><div class="stat-label">Containers</div></div>
<div class="stat"><div class="stat-value">8</div><div class="stat-label">System Layers</div></div>
<div class="stat"><div class="stat-value">260+</div><div class="stat-label">RPC Methods</div></div>
<div class="stat"><div class="stat-value">9</div><div class="stat-label">Protocols</div></div>
<div class="stat"><div class="stat-value">LUKS2</div><div class="stat-label">Encryption</div></div>
<div class="stat"><div class="stat-value">Rootless</div><div class="stat-label">Containers</div></div>
<div class="stat"><div class="stat-value">Tor</div><div class="stat-label">Privacy Layer</div></div>
<div class="stat"><div class="stat-value">Rootless</div><div class="stat-label">Podman</div></div>
<div class="stat"><div class="stat-value">8 GB+</div><div class="stat-label">Recommended RAM</div></div>
</div>
<div class="layer-stack">
@@ -461,6 +534,85 @@
<span class="hl" onclick="showContainers('vaultwarden')">vaultwarden</span> <span class="hl" onclick="showContainers('nextcloud')">nextcloud</span> <span class="hl" onclick="showContainers('searxng')">searxng</span> <span class="hl" onclick="showContainers('uptime-kuma')">uptime-kuma</span> <span class="hl" onclick="showContainers('ollama')">ollama</span>
<span class="hl" onclick="showContainers('onlyoffice')">onlyoffice</span> <span class="hl" onclick="showContainers('nginx-proxy-manager')">nginx-pm</span> <span class="hl" onclick="showContainers('portainer')">portainer</span></div>
</div>
<h3 class="subsection-title">System Resources</h3>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px;">
<div style="background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.06); border-radius:10px; padding:16px;">
<div style="font-size:13px; font-weight:600; color:#fff; margin-bottom:10px; border-bottom:1px solid rgba(255,255,255,0.06); padding-bottom:8px;">Hardware Requirements</div>
<table class="cmp-table" style="margin:0;">
<tr><td class="cmp-label">Minimum RAM</td><td>4 GB</td></tr>
<tr><td class="cmp-label">Recommended RAM</td><td>8 GB+ (core stack uses ~8&ndash;10 GB)</td></tr>
<tr><td class="cmp-label">Minimum Disk</td><td>32 GB SSD</td></tr>
<tr><td class="cmp-label">Recommended Disk</td><td>1 TB+ NVMe SSD</td></tr>
<tr><td class="cmp-label">CPU</td><td>x86_64 or ARM64, 4+ cores recommended</td></tr>
<tr><td class="cmp-label">Network</td><td>Ethernet recommended (WiFi supported)</td></tr>
<tr><td class="cmp-label">Targets</td><td>HP ProDesk, Intel NUC, any standard PC</td></tr>
</table>
</div>
<div style="background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.06); border-radius:10px; padding:16px;">
<div style="font-size:13px; font-weight:600; color:#fff; margin-bottom:10px; border-bottom:1px solid rgba(255,255,255,0.06); padding-bottom:8px;">Memory Budget (all containers)</div>
<table class="cmp-table" style="margin:0;">
<tr><td class="cmp-label">Bitcoin Knots</td><td>2 GB (1 GB low-memory mode)</td></tr>
<tr><td class="cmp-label">ElectrumX</td><td>1 GB</td></tr>
<tr><td class="cmp-label">LND</td><td>512 MB</td></tr>
<tr><td class="cmp-label">BTCPay + DB</td><td>1.5 GB (1 GB + 512 MB)</td></tr>
<tr><td class="cmp-label">Mempool stack</td><td>1.3 GB (512+256+512 MB)</td></tr>
<tr><td class="cmp-label">Fedimint + GW</td><td>1 GB (512+512 MB)</td></tr>
<tr><td class="cmp-label">Ollama (AI)</td><td>4 GB (1 GB low-memory)</td></tr>
<tr><td class="cmp-label">All other apps</td><td>128&ndash;1024 MB each</td></tr>
<tr><td class="cmp-label" style="color:#fff;"><b>Total allocated</b></td><td style="color:#fff;"><b>~20 GB</b> (not all run simultaneously)</td></tr>
</table>
</div>
</div>
<div style="display:grid; grid-template-columns: 1fr 1fr; gap: 12px; margin-top: 12px;">
<div style="background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.06); border-radius:10px; padding:16px;">
<div style="font-size:13px; font-weight:600; color:#fff; margin-bottom:10px; border-bottom:1px solid rgba(255,255,255,0.06); padding-bottom:8px;">Disk Usage by Component</div>
<table class="cmp-table" style="margin:0;">
<tr><td class="cmp-label">Bitcoin blockchain (full)</td><td>~600 GB</td></tr>
<tr><td class="cmp-label">Bitcoin (pruned)</td><td>~550 MB</td></tr>
<tr><td class="cmp-label">ElectrumX index</td><td>~50 GB</td></tr>
<tr><td class="cmp-label">LND channels + wallet</td><td>~1 GB</td></tr>
<tr><td class="cmp-label">Databases (all)</td><td>~2&ndash;10 GB</td></tr>
<tr><td class="cmp-label">Container images</td><td>~15 GB</td></tr>
<tr><td class="cmp-label">Ollama models</td><td>1&ndash;50 GB (varies)</td></tr>
<tr><td class="cmp-label">Media (Jellyfin/Photos)</td><td>User-determined</td></tr>
</table>
</div>
<div style="background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.06); border-radius:10px; padding:16px;">
<div style="font-size:13px; font-weight:600; color:#fff; margin-bottom:10px; border-bottom:1px solid rgba(255,255,255,0.06); padding-bottom:8px;">Network Ports (External)</div>
<table class="cmp-table" style="margin:0;">
<tr><td class="cmp-label">80 / 443</td><td>Nginx &rarr; Web UI, app proxies</td></tr>
<tr><td class="cmp-label">8333</td><td>Bitcoin P2P (node discovery)</td></tr>
<tr><td class="cmp-label">9735</td><td>Lightning P2P (payment routing)</td></tr>
<tr><td class="cmp-label">50001</td><td>Electrum protocol (wallet queries)</td></tr>
<tr><td class="cmp-label">22</td><td>SSH (admin access)</td></tr>
<tr style="border-top:1px solid rgba(255,255,255,0.06);"><td class="cmp-label" style="color:#6b7280; font-style:italic;">Internal only</td><td style="color:#6b7280; font-style:italic;">8332 (RPC), 10009 (gRPC), 8080 (REST), 8999, 4080, 3000, 3001, 8082&ndash;8096, 9000&hellip;</td></tr>
</table>
</div>
</div>
<div style="display:grid; grid-template-columns: 1fr; gap: 12px; margin-top: 12px;">
<div style="background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.06); border-radius:10px; padding:16px;">
<div style="font-size:13px; font-weight:600; color:#fff; margin-bottom:10px; border-bottom:1px solid rgba(255,255,255,0.06); padding-bottom:8px;">Container Security Defaults</div>
<table class="cmp-table" style="margin:0;">
<tr><td class="cmp-label">Capabilities</td><td><code>--cap-drop=ALL</code> then add only needed: CHOWN, FOWNER, SETUID, SETGID, DAC_OVERRIDE. Some get NET_RAW (LND), NET_BIND_SERVICE (Vaultwarden, nginx-pm, LND-UI).</td></tr>
<tr><td class="cmp-label">Privileges</td><td><code>--security-opt=no-new-privileges</code> on all containers</td></tr>
<tr><td class="cmp-label">Health checks</td><td>All containers: <code>--health-interval=120s --health-timeout=5s --health-retries=3</code></td></tr>
<tr><td class="cmp-label">Low-memory mode</td><td>Auto-detected: Bitcoin 2G&rarr;1G, PhotoPrism 1G&rarr;512M, OnlyOffice 2G&rarr;1G, Ollama 4G&rarr;1G</td></tr>
<tr><td class="cmp-label">Disk mode</td><td>Auto: if disk &lt;1TB &rarr; Bitcoin prune=550, dbcache=512M. If &ge;1TB &rarr; full txindex, dbcache=4G</td></tr>
<tr><td class="cmp-label">RPC methods</td><td>260+ registered across 20+ namespaces (auth, seed, package, bitcoin, lnd, identity, tor, nostr, mesh, federation, dwn, system, monitoring&hellip;)</td></tr>
</table>
</div>
</div>
</div>
<!-- ============================================================ -->
@@ -1402,7 +1554,7 @@
<div class="proto-grid">
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">JSON-RPC 2.0</div>
<div class="proto-card-desc">Primary protocol between the web UI and the Rust backend. All commands are RPC calls.</div>
<div class="proto-card-layman">Like texting the backend: you send a message ("please start this app"), it texts back ("done" or "error").</div>
@@ -1417,7 +1569,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">WebSocket</div>
<div class="proto-card-desc">Real-time bidirectional channel for live updates &mdash; container status changes, logs, events.</div>
<div class="proto-card-layman">An open phone line between your browser and the server. Instead of asking "any updates?" every second, the server just tells you when something changes.</div>
@@ -1430,7 +1582,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">Bitcoin RPC (JSON-RPC 1.0)</div>
<div class="proto-card-desc">How apps talk to the Bitcoin node. Authenticated with username + HMAC-hashed password.</div>
<div class="proto-card-layman">The language apps use to ask Bitcoin questions: "what's the current block?" or "send this transaction."</div>
@@ -1443,7 +1595,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">gRPC</div>
<div class="proto-card-desc">High-performance RPC protocol used by LND for admin operations. Binary format, strongly typed.</div>
<div class="proto-card-layman">A fast, structured way for apps to control the Lightning node. More efficient than regular HTTP for complex operations.</div>
@@ -1456,7 +1608,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">Electrum Protocol</div>
<div class="proto-card-desc">Lightweight protocol for wallet address lookups. JSON-RPC over raw TCP sockets.</div>
<div class="proto-card-layman">How Bitcoin wallets check their balance without downloading the entire blockchain. Ask "what transactions touched this address?" and get an instant answer.</div>
@@ -1468,7 +1620,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">ZMQ (ZeroMQ)</div>
<div class="proto-card-desc">Publish-subscribe messaging from Bitcoin node. Instant notifications for new blocks and transactions.</div>
<div class="proto-card-layman">A broadcasting system. When a new Bitcoin block is found, Bitcoin instantly shouts it out and everyone listening hears immediately.</div>
@@ -1481,7 +1633,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">Tor (SOCKS5 + Hidden Services)</div>
<div class="proto-card-desc">Privacy layer. Routes Bitcoin P2P through onion routing, exposes services as .onion addresses.</div>
<div class="proto-card-layman">Like sending a letter through 3 random post offices so nobody knows where it came from. Also lets people reach your node without knowing your real IP.</div>
@@ -1494,7 +1646,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">Nostr (NIP-01)</div>
<div class="proto-card-desc">Decentralized social protocol. WebSocket-based relay communication for events (posts, follows, messages).</div>
<div class="proto-card-layman">A social media protocol where no company controls the network. Your posts live on relays, and you own your identity with a cryptographic key.</div>
@@ -1507,7 +1659,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">DWN (Decentralized Web Node)</div>
<div class="proto-card-desc">W3C protocol for storing encrypted data and messages in a decentralized way. Identity-linked storage.</div>
<div class="proto-card-layman">A personal data vault. Apps can store data here that only you control. Like a safety deposit box that follows you across the internet.</div>
@@ -2413,7 +2565,7 @@
<div class="section" id="sec-start9-protocols">
<div class="proto-grid">
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">JSON-RPC (Host &harr; Service)</div>
<div class="proto-card-desc">All communication between the Rust backend and services uses JSON-RPC over Unix domain sockets.</div>
<div class="proto-card-layman">Apps and the system talk through a structured messaging format over local socket files &mdash; fast and secure.</div>
@@ -2426,7 +2578,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">JSON-RPC (UI &harr; Backend)</div>
<div class="proto-card-desc">The Angular frontend communicates with startd via JSON-RPC over HTTP. 100+ API methods. State sync via Patch-DB WebSocket.</div>
<div class="proto-card-layman">The dashboard sends commands and gets responses in JSON format. Live updates stream automatically through a WebSocket.</div>
@@ -2438,7 +2590,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">Patch-DB (CBOR Reactive Sync)</div>
<div class="proto-card-desc">Custom reactive database using CBOR encoding. Backend pushes diffs over WebSocket &mdash; UI updates automatically without polling.</div>
<div class="proto-card-layman">Instead of the UI constantly asking "what changed?", the backend pushes only what changed, in a compact binary format.</div>
@@ -2450,7 +2602,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">HTTPS / TLS (Built-in)</div>
<div class="proto-card-desc">TLS termination handled directly by the Rust backend (tokio-rustls). Self-signed root CA per server + ACME for public domains.</div>
<div class="proto-card-layman">The backend IS the web server &mdash; no nginx or caddy needed. It handles encryption directly.</div>
@@ -2462,7 +2614,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">WireGuard (VPN Tunnels)</div>
<div class="proto-card-desc">First-class WireGuard support for remote access. Users add WireGuard configs as "gateways." Managed by tunnelbox daemon.</div>
<div class="proto-card-layman">Built-in VPN for accessing your node from anywhere. Add a WireGuard config and get a secure tunnel.</div>
@@ -2473,7 +2625,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">DNS (hickory-server)</div>
<div class="proto-card-desc">Built-in DNS server for service discovery and resolution. Also uses mDNS (avahi) for .local domain access on LAN.</div>
<div class="proto-card-layman">The system runs its own DNS so containers can find each other by name. Your phone finds the node via .local address.</div>
@@ -2879,7 +3031,7 @@
<div class="section" id="sec-umbrelos-protocols">
<div class="proto-grid">
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">tRPC (UI &harr; Backend)</div>
<div class="proto-card-desc">TypeScript-first RPC framework with end-to-end type safety. Runs over both HTTP and WebSocket on port 80.</div>
<div class="proto-card-layman">A typed communication channel between the dashboard and the backend. If the API changes, TypeScript catches errors automatically.</div>
@@ -2892,7 +3044,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">Docker Compose (App Lifecycle)</div>
<div class="proto-card-desc">Each app managed via Docker Compose v2. Install/start/stop/update handled by a bash script (app-script) calling docker compose.</div>
<div class="proto-card-layman">Apps are defined as Docker Compose projects. A bash script handles the lifecycle by calling docker compose commands.</div>
@@ -2904,7 +3056,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">exports.sh (Dependency Resolution)</div>
<div class="proto-card-desc">Shell scripts that export environment variables (IPs, ports, RPC credentials). When app B depends on app A, A's exports.sh is sourced first.</div>
<div class="proto-card-layman">Apps share their connection details through environment variables set by shell scripts.</div>
@@ -2916,7 +3068,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">Tor (Optional, Containerized)</div>
<div class="proto-card-desc">Toggle per-system. tor_proxy container provides SOCKS5 at 10.21.21.11. Per-app tor_server containers create hidden services.</div>
<div class="proto-card-layman">Tor is optional and runs in its own container. When enabled, each app gets its own .onion address for remote access.</div>
@@ -2928,7 +3080,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">JWT + Proxy Tokens (Auth)</div>
<div class="proto-card-desc">JWT for API authentication. Separate "proxy tokens" validate iframe requests to app_proxy containers. bcrypt password hashing.</div>
<div class="proto-card-layman">Login tokens that prove who you are. Separate tokens for the dashboard API and for accessing individual apps.</div>
@@ -2940,7 +3092,7 @@
</div>
</div>
<div class="proto-card" onclick="this.classList.toggle('expanded')">
<div class="proto-card" onclick="toggleProto(this)">
<div class="proto-card-name">Git (App Store)</div>
<div class="proto-card-desc">App store is a Git repository cloned locally via isomorphic-git. Pulled every 5 minutes for updates.</div>
<div class="proto-card-layman">The app catalog is just a Git repo. Umbrel checks for new apps and updates by pulling the latest commits every 5 minutes.</div>
@@ -3533,6 +3685,138 @@ function syncRowHeights(cards) {
}
}
function toggleProto(el) {
const grid = el.closest('.proto-grid')
if (grid) {
const isExpanding = !el.classList.contains('expanded')
const cards = Array.from(grid.querySelectorAll('.proto-card'))
if (isExpanding) {
// Convert free-form <b>Label:</b> HTML into structured detail-rows
cards.forEach(structureProtoDetails)
// Normalize so all cards have the same rows in same order
normalizeProtoRows(cards)
cards.forEach(c => c.classList.add('expanded'))
grid.classList.add('all-expanded')
requestAnimationFrame(() => requestAnimationFrame(() => syncProtoHeights(cards)))
} else {
cards.forEach(c => c.classList.remove('expanded'))
grid.classList.remove('all-expanded')
grid.querySelectorAll('.proto-card-name, .proto-card-desc, .proto-card-layman, .detail-row').forEach(e => e.style.minHeight = '')
}
} else {
el.classList.toggle('expanded')
}
}
// Convert <b>Label:</b> value<br> into structured detail-rows
function structureProtoDetails(card) {
const details = card.querySelector('.proto-details')
if (!details || details.dataset.structured) return
details.dataset.structured = '1'
const html = details.innerHTML
// Split on <br> or <br/> or <br />
const lines = html.split(/<br\s*\/?>/).map(l => l.trim()).filter(Boolean)
const rows = []
let currentLabel = null
let currentValue = ''
lines.forEach(line => {
// Check if line starts with <b>Label:</b>
const match = line.match(/^<b>([^<]+?):?<\/b>\s*(.*)/)
if (match) {
if (currentLabel) rows.push({ label: currentLabel, value: currentValue.trim() })
currentLabel = match[1].replace(/:$/, '').trim()
currentValue = match[2] || ''
} else if (currentLabel) {
// Continuation line (like bullet points)
currentValue += (currentValue ? '<br>' : '') + line
} else {
// Standalone line without label
currentLabel = line.replace(/<[^>]+>/g, '').substring(0, 20).trim()
currentValue = line
}
})
if (currentLabel) rows.push({ label: currentLabel, value: currentValue.trim() })
if (rows.length > 0) {
details.innerHTML = rows.map(r =>
'<div class="detail-row"><span class="detail-label">' + r.label + '</span><span class="detail-value">' + (r.value || '\u2014') + '</span></div>'
).join('')
}
}
// Normalize proto detail rows across cards (same as normalizeDetailRows)
function normalizeProtoRows(cards) {
const allLabels = []
const seen = new Set()
cards.forEach(card => {
const details = card.querySelector('.proto-details')
if (!details) return
details.querySelectorAll('.detail-row .detail-label').forEach(lbl => {
const t = lbl.textContent.trim()
if (!seen.has(t)) { seen.add(t); allLabels.push(t) }
})
})
if (allLabels.length === 0) return
cards.forEach(card => {
const details = card.querySelector('.proto-details')
if (!details || details.dataset.normalized) return
details.dataset.normalized = '1'
const existing = {}
details.querySelectorAll('.detail-row').forEach(row => {
const lbl = row.querySelector('.detail-label')
if (lbl) existing[lbl.textContent.trim()] = row
})
const frag = document.createDocumentFragment()
allLabels.forEach(label => {
if (existing[label]) {
frag.appendChild(existing[label])
} else {
const row = document.createElement('div')
row.className = 'detail-row'
row.innerHTML = '<span class="detail-label">' + label + '</span><span class="detail-value" style="color:#3b3f50;">\u2014</span>'
frag.appendChild(row)
}
})
details.appendChild(frag)
})
}
function syncProtoHeights(cards) {
if (cards.length < 2) return
function syncByClass(cls) {
const els = cards.map(c => c.querySelector('.' + cls)).filter(Boolean)
if (els.length < 2) return
els.forEach(e => e.style.minHeight = '')
let max = 0
els.forEach(e => { max = Math.max(max, e.offsetHeight) })
if (max > 0) els.forEach(e => e.style.minHeight = max + 'px')
}
syncByClass('proto-card-name')
syncByClass('proto-card-desc')
syncByClass('proto-card-layman')
// Sync detail rows by index
const maxRows = Math.max(...cards.map(c => c.querySelectorAll('.detail-row').length))
for (let i = 0; i < maxRows; i++) {
const rows = cards.map(c => {
const all = c.querySelectorAll('.detail-row')
return all[i] || null
}).filter(Boolean)
rows.forEach(r => r.style.minHeight = '')
let maxH = 0
rows.forEach(r => { maxH = Math.max(maxH, r.offsetHeight) })
if (maxH > 0) rows.forEach(r => r.style.minHeight = maxH + 'px')
}
}
function find(name) {
event.stopPropagation()
const card = document.querySelector(`[data-name="${name}"]`)