diff --git a/deno.json b/deno.json
index b27481a..1dfaa29 100644
--- a/deno.json
+++ b/deno.json
@@ -1,12 +1,12 @@
{
- "version": "0.2.10",
+ "version": "0.2.12",
"license": "MIT",
"tasks": {
"dev": "deno run --allow-all --watch src/server.ts",
"build": "deno run --allow-all src/build.ts",
"serve": "deno run --allow-all src/server.ts",
"test": "deno test --allow-all src/",
- "check": "deno check src/server.ts src/build.ts src/app.ts src/lib/*.ts src/views/*.ts src/components/*.ts",
+ "check": "deno check src/server.ts src/build.ts src/app.ts src/lib/*.ts src/views/*.ts",
"lint": "deno lint src/",
"fmt": "deno fmt src/",
"fmt:check": "deno fmt --check src/"
@@ -18,7 +18,8 @@
"imports": {
"@std/path": "jsr:@std/path@^1.0.0",
"@std/assert": "jsr:@std/assert@^1.0.0",
- "stellar-sdk": "npm:@stellar/stellar-sdk@^15.0.1",
- "@moonlight/ui/nav": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts"
+ "@moonlight/ui/nav": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/nav/mod.ts",
+ "@moonlight/ui/layout": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/layout/mod.ts",
+ "@moonlight/ui/world-map": "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/world-map/mod.ts"
}
}
diff --git a/deno.lock b/deno.lock
index 696d64f..da79b4e 100644
--- a/deno.lock
+++ b/deno.lock
@@ -9,8 +9,7 @@
"jsr:@std/json@~0.213.1": "0.213.1",
"jsr:@std/jsonc@0.213": "0.213.1",
"jsr:@std/path@0.213": "0.213.1",
- "jsr:@std/path@1": "1.1.4",
- "npm:@stellar/stellar-sdk@^15.0.1": "15.0.1"
+ "jsr:@std/path@1": "1.1.4"
},
"jsr": {
"@luca/esbuild-deno-loader@0.10.3": {
@@ -59,342 +58,22 @@
]
}
},
- "npm": {
- "@noble/curves@1.9.7": {
- "integrity": "sha512-gbKGcRUYIjA3/zCCNaWDciTMFI0dCkvou3TL8Zmy5Nc7sJ47a0jtOeZoTaMxkuqRo9cRhjOdZJXegxYE5FN/xw==",
- "dependencies": [
- "@noble/hashes"
- ]
- },
- "@noble/hashes@1.8.0": {
- "integrity": "sha512-jCs9ldd7NwzpgXDIf6P3+NrHh9/sD6CQdxHyjQI+h/6rDNo88ypBxxz45UDuZHz9r3tNz7N/VInSVoVdtXEI4A=="
- },
- "@stellar/js-xdr@4.0.0": {
- "integrity": "sha512-+NmNa7Tk5BI5XFdy/6xGTqAN4J9a9KgCrCGhj2uEUTCBhLkch0M+QbKzNH8zEnejWe0p8w+0q5hUVX6L3OzoVA=="
- },
- "@stellar/stellar-base@15.0.0": {
- "integrity": "sha512-XQhxUr9BYiEcFcgc4oWcCMR9QJCny/GmmGsuwPKf/ieIcOeb5149KLHYx9mJCA0ea8QbucR2/GzV58QbXOTxQA==",
- "dependencies": [
- "@noble/curves",
- "@stellar/js-xdr",
- "base32.js",
- "bignumber.js",
- "buffer",
- "sha.js"
- ]
- },
- "@stellar/stellar-sdk@15.0.1": {
- "integrity": "sha512-iZjWKXtfohsPh+CX9wRyQNIlXLeA9VyuQB6UMC7AFBD9XnR92eOjnlfeONzk/Bsrkk6+UPlpzSy2MuF+ydHP1A==",
- "dependencies": [
- "@stellar/stellar-base",
- "axios",
- "bignumber.js",
- "commander",
- "eventsource",
- "feaxios",
- "randombytes",
- "toml",
- "urijs"
- ],
- "bin": true
- },
- "asynckit@0.4.0": {
- "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q=="
- },
- "available-typed-arrays@1.0.7": {
- "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==",
- "dependencies": [
- "possible-typed-array-names"
- ]
- },
- "axios@1.14.0": {
- "integrity": "sha512-3Y8yrqLSwjuzpXuZ0oIYZ/XGgLwUIBU3uLvbcpb0pidD9ctpShJd43KSlEEkVQg6DS0G9NKyzOvBfUtDKEyHvQ==",
- "dependencies": [
- "follow-redirects",
- "form-data",
- "proxy-from-env"
- ]
- },
- "base32.js@0.1.0": {
- "integrity": "sha512-n3TkB02ixgBOhTvANakDb4xaMXnYUVkNoRFJjQflcqMQhyEKxEHdj3E6N8t8sUQ0mjH/3/JxzlXuz3ul/J90pQ=="
- },
- "base64-js@1.5.1": {
- "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
- },
- "bignumber.js@9.3.1": {
- "integrity": "sha512-Ko0uX15oIUS7wJ3Rb30Fs6SkVbLmPBAKdlm7q9+ak9bbIeFf0MwuBsQV6z7+X768/cHsfg+WlysDWJcmthjsjQ=="
- },
- "buffer@6.0.3": {
- "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==",
- "dependencies": [
- "base64-js",
- "ieee754"
- ]
- },
- "call-bind-apply-helpers@1.0.2": {
- "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
- "dependencies": [
- "es-errors",
- "function-bind"
- ]
- },
- "call-bind@1.0.9": {
- "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==",
- "dependencies": [
- "call-bind-apply-helpers",
- "es-define-property",
- "get-intrinsic",
- "set-function-length"
- ]
- },
- "call-bound@1.0.4": {
- "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
- "dependencies": [
- "call-bind-apply-helpers",
- "get-intrinsic"
- ]
- },
- "combined-stream@1.0.8": {
- "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==",
- "dependencies": [
- "delayed-stream"
- ]
- },
- "commander@14.0.3": {
- "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw=="
- },
- "define-data-property@1.1.4": {
- "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
- "dependencies": [
- "es-define-property",
- "es-errors",
- "gopd"
- ]
- },
- "delayed-stream@1.0.0": {
- "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ=="
- },
- "dunder-proto@1.0.1": {
- "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
- "dependencies": [
- "call-bind-apply-helpers",
- "es-errors",
- "gopd"
- ]
- },
- "es-define-property@1.0.1": {
- "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g=="
- },
- "es-errors@1.3.0": {
- "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw=="
- },
- "es-object-atoms@1.1.1": {
- "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
- "dependencies": [
- "es-errors"
- ]
- },
- "es-set-tostringtag@2.1.0": {
- "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==",
- "dependencies": [
- "es-errors",
- "get-intrinsic",
- "has-tostringtag",
- "hasown"
- ]
- },
- "eventsource@2.0.2": {
- "integrity": "sha512-IzUmBGPR3+oUG9dUeXynyNmf91/3zUSJg1lCktzKw47OXuhco54U3r9B7O4XX+Rb1Itm9OZ2b0RkTs10bICOxA=="
- },
- "feaxios@0.0.23": {
- "integrity": "sha512-eghR0A21fvbkcQBgZuMfQhrXxJzC0GNUGC9fXhBge33D+mFDTwl0aJ35zoQQn575BhyjQitRc5N4f+L4cP708g==",
- "dependencies": [
- "is-retry-allowed"
- ]
- },
- "follow-redirects@1.16.0": {
- "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="
- },
- "for-each@0.3.5": {
- "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==",
- "dependencies": [
- "is-callable"
- ]
- },
- "form-data@4.0.5": {
- "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==",
- "dependencies": [
- "asynckit",
- "combined-stream",
- "es-set-tostringtag",
- "hasown",
- "mime-types"
- ]
- },
- "function-bind@1.1.2": {
- "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="
- },
- "get-intrinsic@1.3.0": {
- "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
- "dependencies": [
- "call-bind-apply-helpers",
- "es-define-property",
- "es-errors",
- "es-object-atoms",
- "function-bind",
- "get-proto",
- "gopd",
- "has-symbols",
- "hasown",
- "math-intrinsics"
- ]
- },
- "get-proto@1.0.1": {
- "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
- "dependencies": [
- "dunder-proto",
- "es-object-atoms"
- ]
- },
- "gopd@1.2.0": {
- "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg=="
- },
- "has-property-descriptors@1.0.2": {
- "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
- "dependencies": [
- "es-define-property"
- ]
- },
- "has-symbols@1.1.0": {
- "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ=="
- },
- "has-tostringtag@1.0.2": {
- "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
- "dependencies": [
- "has-symbols"
- ]
- },
- "hasown@2.0.3": {
- "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==",
- "dependencies": [
- "function-bind"
- ]
- },
- "ieee754@1.2.1": {
- "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
- },
- "inherits@2.0.4": {
- "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ=="
- },
- "is-callable@1.2.7": {
- "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA=="
- },
- "is-retry-allowed@3.0.0": {
- "integrity": "sha512-9xH0xvoggby+u0uGF7cZXdrutWiBiaFG8ZT4YFPXL8NzkyAwX3AKGLeFQLvzDpM430+nDFBZ1LHkie/8ocL06A=="
- },
- "is-typed-array@1.1.15": {
- "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==",
- "dependencies": [
- "which-typed-array"
- ]
- },
- "isarray@2.0.5": {
- "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw=="
- },
- "math-intrinsics@1.1.0": {
- "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g=="
- },
- "mime-db@1.52.0": {
- "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="
- },
- "mime-types@2.1.35": {
- "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==",
- "dependencies": [
- "mime-db"
- ]
- },
- "possible-typed-array-names@1.1.0": {
- "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="
- },
- "proxy-from-env@2.1.0": {
- "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA=="
- },
- "randombytes@2.1.0": {
- "integrity": "sha512-vYl3iOX+4CKUWuxGi9Ukhie6fsqXqS9FE2Zaic4tNFD2N2QQaXOMFbuKK4QmDHC0JO6B1Zp41J0LpT0oR68amQ==",
- "dependencies": [
- "safe-buffer"
- ]
- },
- "safe-buffer@5.2.1": {
- "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="
- },
- "set-function-length@1.2.2": {
- "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
- "dependencies": [
- "define-data-property",
- "es-errors",
- "function-bind",
- "get-intrinsic",
- "gopd",
- "has-property-descriptors"
- ]
- },
- "sha.js@2.4.12": {
- "integrity": "sha512-8LzC5+bvI45BjpfXU8V5fdU2mfeKiQe1D1gIMn7XUlF3OTUrpdJpPPH4EMAnF0DsHHdSZqCdSss5qCmJKuiO3w==",
- "dependencies": [
- "inherits",
- "safe-buffer",
- "to-buffer"
- ],
- "bin": true
- },
- "to-buffer@1.2.2": {
- "integrity": "sha512-db0E3UJjcFhpDhAF4tLo03oli3pwl3dbnzXOUIlRKrp+ldk/VUxzpWYZENsw2SZiuBjHAk7DfB0VU7NKdpb6sw==",
- "dependencies": [
- "isarray",
- "safe-buffer",
- "typed-array-buffer"
- ]
- },
- "toml@3.0.0": {
- "integrity": "sha512-y/mWCZinnvxjTKYhJ+pYxwD0mRLVvOtdS2Awbgxln6iEnt4rk0yBxeSBHkGJcPucRiG0e55mwWp+g/05rsrd6w=="
- },
- "typed-array-buffer@1.0.3": {
- "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==",
- "dependencies": [
- "call-bound",
- "es-errors",
- "is-typed-array"
- ]
- },
- "urijs@1.19.11": {
- "integrity": "sha512-HXgFDgDommxn5/bIv0cnQZsPhHDA90NPHD6+c/v21U5+Sx5hoP8+dP9IZXBU1gIfvdRfhG8cel9QNPeionfcCQ=="
- },
- "which-typed-array@1.1.20": {
- "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==",
- "dependencies": [
- "available-typed-arrays",
- "call-bind",
- "call-bound",
- "for-each",
- "get-proto",
- "gopd",
- "has-tostringtag"
- ]
- }
- },
"remote": {
"https://deno.land/x/denoflate@1.2.1/mod.ts": "f5628e44b80b3d80ed525afa2ba0f12408e3849db817d47a883b801f9ce69dd6",
"https://deno.land/x/denoflate@1.2.1/pkg/denoflate.js": "b9f9ad9457d3f12f28b1fb35c555f57443427f74decb403113d67364e4f2caf4",
"https://deno.land/x/denoflate@1.2.1/pkg/denoflate_bg.wasm.js": "d581956245407a2115a3d7e8d85a9641c032940a8e810acbd59ca86afd34d44d",
"https://deno.land/x/esbuild@v0.20.1/mod.js": "d50e500b53ce67e31116beba3916b0f9275c0e1cc20bc5cadc0fc1b7a3b06fd9",
- "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts": "4bce92a8e347e12953f2fc7e4329e0b58e3dcb8d1c872054e2d30beeba4373e0"
+ "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/layout/mod.ts": "064e49573a35de0124de1e7ab2cdfc4f661a3e2415da69f70b0a607c5224f64f",
+ "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.1/src/nav/mod.ts": "4bce92a8e347e12953f2fc7e4329e0b58e3dcb8d1c872054e2d30beeba4373e0",
+ "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/layout/mod.ts": "064e49573a35de0124de1e7ab2cdfc4f661a3e2415da69f70b0a607c5224f64f",
+ "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/nav/mod.ts": "4bce92a8e347e12953f2fc7e4329e0b58e3dcb8d1c872054e2d30beeba4373e0",
+ "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/world-map/countries.ts": "0f403290178dd4040295ad34fe3277a403928fabea314f8fd16d9c51721cb612",
+ "https://raw.githubusercontent.com/Moonlight-Protocol/ui/v0.3.2/src/world-map/mod.ts": "26b2b745c0df3a9396d47b69c77350c0632f01ccbf059f8da443d737961a6145"
},
"workspace": {
"dependencies": [
"jsr:@std/assert@1",
- "jsr:@std/path@1",
- "npm:@stellar/stellar-sdk@^15.0.1"
+ "jsr:@std/path@1"
]
}
}
diff --git a/public/config.js b/public/config.js
index da784ba..6d2b35f 100644
--- a/public/config.js
+++ b/public/config.js
@@ -1,6 +1,5 @@
globalThis.__DASHBOARD_CONFIG__ = {
environment: "development",
stellarNetwork: "testnet",
- rpcUrl: "https://soroban-testnet.stellar.org",
- councilPlatformUrl: "http://localhost:3115",
+ networkDashboardPlatformUrl: "ws://localhost:3035",
};
diff --git a/src/app-styles.css b/src/app-styles.css
index 9b09f21..e922311 100644
--- a/src/app-styles.css
+++ b/src/app-styles.css
@@ -1,179 +1,917 @@
/*
* App-specific styles for network-dashboard.
*
- * Tokens + base-styles + nav now come from @moonlight/ui at the pinned
- * tag — fetched and concatenated into public/styles.css by src/build.ts.
- * This file contains ONLY selectors the lib does not ship.
- * (network-dashboard does not consume the lib's stepper or invite-waitlist.)
+ * Tokens + base-styles + nav come from @moonlight/ui at build time
+ * (concatenated by build.ts). This file only defines the 5-section
+ * layout + the SVG-driven topology / sparkline / world-map visual
+ * vocabulary on top of the upstream dark theme.
+ *
+ * All colours are sourced from the @moonlight/ui tokens.css palette
+ * (--bg / --surface / --border / --text / --text-muted / --primary /
+ * --active / --pending / --inactive). Event-kind accents below live in
+ * a small palette here because the sketch maps each kind to a specific
+ * hue that doesn't have a single token equivalent.
*/
-/* Nav-brand hover (lib's nav.css doesn't ship this variant) */
-.nav-brand:hover {
- opacity: 0.9;
+:root {
+ /* Per-event-kind accents — dark-mode tuned (saturated, mid-lightness). */
+ --kind-council: #a78bfa; /* purple */
+ --kind-pp-add: #4ade80; /* green */
+ --kind-pp-remove: #6b7280; /* gray */
+ --kind-asset: #fbbf24; /* yellow */
+ --kind-deposit: #fb923c; /* orange */
+ --kind-settlement: #60a5fa; /* blue */
+ --kind-bundle: #22d3ee; /* teal */
+ --kind-default: #f87171; /* red, fallback pulses */
+
+ /* Topology + center node. */
+ --moonlight-yellow: #fde68a;
+ --moonlight-yellow-stroke: #f59e0b;
+ --council-fill: rgba(74, 222, 128, 0.12);
+ --council-stroke: #4ade80;
+ --pp-fill: rgba(251, 146, 60, 0.18);
+ --pp-stroke: #fb923c;
+
+ /* Sparkline lines. */
+ --sparkline-throughput: #60a5fa;
+ --sparkline-latency: #a78bfa;
+ --sparkline-volume: #4ade80;
}
-/* Tables */
-table {
- width: 100%;
- border-collapse: collapse;
- margin: 1rem 0;
+/* Override @moonlight/ui's .container width — the dashboard sprawls. */
+main.container {
+ max-width: 1440px;
+ margin: 1.25rem auto 2rem;
+ padding: 0 1.5rem;
}
-th, td {
- padding: 0.75rem 1rem;
- text-align: left;
- border-bottom: 1px solid var(--border);
+.dashboard {
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+}
+
+.row {
+ display: grid;
+ gap: 1rem;
+}
+
+.row.detail-grid {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ grid-template-rows: repeat(3, minmax(200px, 240px));
+ grid-template-areas:
+ "map map countries"
+ "map map councils"
+ "events events providers";
+}
+
+/* `min-height: 0` lets the grid cell win over each section's intrinsic
+ content height, and `overflow: hidden` clips children so the inner
+ scroll regions can take over (set on the content lists below). */
+.row.detail-grid > .section {
+ min-height: 0;
+ overflow: hidden;
+}
+.row.detail-grid > .world-map {
+ grid-area: map;
+}
+.row.detail-grid > .country-details {
+ grid-area: countries;
+}
+.row.detail-grid > .council-details {
+ grid-area: councils;
+}
+.row.detail-grid > .provider-details {
+ grid-area: providers;
+}
+.row.detail-grid > .activity-feed {
+ grid-area: events;
+}
+
+/* Each panel's content area scrolls vertically; the header stays put. */
+.country-details-panel,
+.council-details-panel,
+.provider-details-panel {
+ flex: 1;
+ min-height: 0;
+ overflow-y: auto;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.row.trends {
+ grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
+}
+
+.section {
+ background: var(--surface);
+ border: 1px solid var(--border);
+ border-radius: 10px;
+ padding: 0.875rem 1rem;
+ box-sizing: border-box;
+}
+
+/* §1 — Counter strip ──────────────────────────────────────────────── */
+
+.counter-strip {
+ display: grid;
+ grid-template-columns: repeat(6, minmax(0, 1fr));
+ gap: 0.75rem;
+ padding: 0.75rem;
+}
+
+.counter-tile {
+ background: rgba(99, 102, 241, 0.08);
+ border: 1px solid rgba(99, 102, 241, 0.35);
+ border-radius: 8px;
+ padding: 0.75rem 0.875rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.counter-label {
+ font-size: 0.65rem;
+ font-weight: 600;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+ text-transform: uppercase;
+}
+
+.counter-value {
+ font-size: 1.5rem;
+ font-weight: 700;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+
+/* §3 — Activity feed ──────────────────────────────────────────────── */
+
+.activity-feed,
+.council-details,
+.provider-details {
+ display: flex;
+ flex-direction: column;
+ min-height: 420px;
+ padding: 0.75rem;
+}
+
+.activity-feed {
+ padding: 0.75rem;
+}
+
+.activity-feed-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ margin-bottom: 0.75rem;
+}
+
+.activity-feed-title {
+ font-weight: 700;
+ font-size: 0.75rem;
+ letter-spacing: 0.06em;
+ text-transform: uppercase;
+ color: var(--text-muted);
+}
+
+.activity-feed-status {
+ font-size: 0.7rem;
+ padding: 0.125rem 0.5rem;
+ border-radius: 999px;
+ background: rgba(139, 143, 163, 0.18);
+ color: var(--text-muted);
+}
+
+.activity-feed-status[data-state="open"] {
+ background: rgba(74, 222, 128, 0.18);
+ color: var(--active);
+}
+
+.activity-feed-status[data-state="connecting"] {
+ background: rgba(245, 158, 11, 0.18);
+ color: var(--pending);
+}
+
+.activity-feed-status[data-state="closed"] {
+ background: rgba(239, 68, 68, 0.2);
+ color: var(--inactive);
+}
+
+.activity-feed-list {
+ flex: 1;
+ min-height: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ overflow-y: auto;
+}
+
+.activity-card {
+ display: flex;
+ gap: 0.625rem;
+ align-items: flex-start;
+ border-radius: 8px;
+ border-left: 3px solid var(--border);
+ background: rgba(255, 255, 255, 0.03);
+ padding: 0.5rem 0.625rem;
+ animation: card-in 220ms ease-out;
+ opacity: 1;
+ transition: opacity 600ms ease-in, transform 600ms ease-in;
+}
+
+.activity-card.fading {
+ opacity: 0;
+ transform: translateY(-4px);
+}
+
+@keyframes card-in {
+ from {
+ transform: translateY(-6px);
+ opacity: 0;
+ }
+ to {
+ transform: translateY(0);
+ opacity: 1;
+ }
+}
+
+.activity-card.kind-council_formed {
+ border-left-color: var(--kind-council);
+}
+.activity-card.kind-provider_added {
+ border-left-color: var(--kind-pp-add);
+}
+.activity-card.kind-provider_removed {
+ border-left-color: var(--kind-pp-remove);
+}
+.activity-card.kind-asset_registered {
+ border-left-color: var(--kind-asset);
+}
+.activity-card.kind-channel_settlement {
+ border-left-color: var(--kind-settlement);
+}
+.activity-card.kind-channel_deposit {
+ border-left-color: var(--kind-deposit);
+}
+.activity-card.kind-channel_bundle {
+ border-left-color: var(--kind-bundle);
+}
+
+.activity-glyph {
+ font-size: 1.125rem;
+ line-height: 1.1;
+ font-weight: 700;
+ margin-top: 0.125rem;
+ color: var(--text);
+}
+
+.activity-card.kind-council_formed .activity-glyph {
+ color: var(--kind-council);
+}
+.activity-card.kind-provider_added .activity-glyph {
+ color: var(--kind-pp-add);
+}
+.activity-card.kind-provider_removed .activity-glyph {
+ color: var(--kind-pp-remove);
+}
+.activity-card.kind-asset_registered .activity-glyph {
+ color: var(--kind-asset);
+}
+.activity-card.kind-channel_settlement .activity-glyph {
+ color: var(--kind-settlement);
+}
+.activity-card.kind-channel_deposit .activity-glyph {
+ color: var(--kind-deposit);
+}
+.activity-card.kind-channel_bundle .activity-glyph {
+ color: var(--kind-bundle);
+}
+
+.activity-body {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ min-width: 0;
}
-th {
+.activity-title {
+ font-size: 0.75rem;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.activity-council {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.activity-detail {
+ font-size: 0.7rem;
color: var(--text-muted);
+ font-family: var(--font-mono);
+}
+
+/* §3 — Council details ────────────────────────────────────────────── */
+
+.council-details {
+ padding: 0.875rem 1.125rem;
+}
+
+.council-details-hint {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.council-details-hint.hidden,
+.council-details-panel.hidden {
+ display: none;
+}
+
+.council-details-header {
+ display: flex;
+ gap: 1rem;
+ align-items: baseline;
+ margin-bottom: 0.625rem;
+}
+
+.council-details-name {
+ margin: 0;
+ font-size: 1.125rem;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.council-details-jurisdiction {
font-size: 0.75rem;
+ color: var(--text-muted);
+ background: rgba(139, 143, 163, 0.18);
+ padding: 0.125rem 0.5rem;
+ border-radius: 999px;
+}
+
+.council-details-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
+ gap: 0.625rem;
+ margin-bottom: 0.75rem;
+}
+
+.kv-block {
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 8px;
+ padding: 0.5rem 0.625rem;
+ border: 1px solid var(--border);
+}
+
+.kv-label {
+ font-size: 0.65rem;
+ font-weight: 600;
text-transform: uppercase;
- letter-spacing: 0.05em;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+}
+
+.kv-value {
+ font-size: 1.0625rem;
+ font-weight: 700;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
}
-/* Utility */
-.text-muted {
+.kv-sub {
+ font-size: 0.65rem;
color: var(--text-muted);
}
-/* Loading + clickable row */
-.loading {
+.council-details-subheader {
+ margin: 0.75rem 0 0.375rem;
+ font-size: 0.75rem;
+ font-weight: 700;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
color: var(--text-muted);
- padding: 2rem 0;
}
-.clickable-row {
+.council-details-pp-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.council-details-pp-button {
+ width: 100%;
+ background: rgba(255, 255, 255, 0.03);
+ border: 1px solid transparent;
+ border-radius: 6px;
+ padding: 0.25rem 0.5rem;
+ color: var(--text);
+ font-family: var(--font-mono);
+ font-size: 0.7rem;
+ text-align: left;
cursor: pointer;
- transition: background 0.15s;
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.clickable-row:hover {
- background: var(--surface);
+.council-details-pp-button:hover {
+ background: rgba(255, 255, 255, 0.06);
+ border-color: var(--border);
}
-/* Map */
-.map-container {
- background: var(--surface);
+/* §4 — Sparklines ─────────────────────────────────────────────────── */
+
+.sparklines {
+ display: grid;
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ gap: 0.75rem;
+}
+
+.sparkline-card {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+ padding: 0.5rem 0.625rem;
border: 1px solid var(--border);
border-radius: 8px;
- padding: 1rem;
- margin: 1rem 0 2rem;
+ background: rgba(255, 255, 255, 0.03);
+}
+
+.sparkline-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.sparkline-title {
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+}
+
+.sparkline-value {
+ font-size: 1rem;
+ font-weight: 700;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
+}
+
+.sparkline-svg {
+ width: 100%;
+ height: 80px;
+}
+
+.sparkline-unit {
+ font-size: 0.625rem;
+ color: var(--text-muted);
+ align-self: flex-end;
+}
+
+/* §4 — Asset breakdown ────────────────────────────────────────────── */
+
+.asset-breakdown {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.asset-breakdown-header {
+ margin-bottom: 0.25rem;
+}
+
+.asset-breakdown-title {
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+}
+
+.asset-breakdown-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.375rem;
+}
+
+.asset-breakdown-empty {
+ font-size: 0.75rem;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.asset-row {
+ display: grid;
+ grid-template-columns: 60px minmax(0, 1fr) 56px 90px;
+ gap: 0.5rem;
+ align-items: center;
+ font-size: 0.75rem;
+}
+
+.asset-code {
+ font-weight: 700;
+ color: var(--text);
+}
+
+.asset-bar-box {
+ background: rgba(255, 255, 255, 0.05);
+ border-radius: 4px;
+ height: 12px;
overflow: hidden;
}
+.asset-bar {
+ height: 100%;
+ background: linear-gradient(
+ 90deg,
+ var(--primary) 0%,
+ var(--primary-hover) 100%
+ );
+ border-radius: 4px;
+ transition: width 220ms ease-out;
+}
+
+.asset-percent {
+ font-variant-numeric: tabular-nums;
+ color: var(--text-muted);
+ text-align: right;
+}
+
+.asset-amount {
+ font-variant-numeric: tabular-nums;
+ text-align: right;
+ font-family: var(--font-mono);
+ color: var(--text);
+}
+
+/* §5 — World map ──────────────────────────────────────────────────── */
+
.world-map {
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+}
+
+.world-map-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: baseline;
+}
+
+.world-map-title {
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
+}
+
+.world-map-status {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+}
+
+/* Section-level wrapper that sizes the shared @moonlight/ui world-map
+ to the dashboard's §5 slot. The internal .world-map-host / .world-map-svg
+ styling comes from @moonlight/ui/world-map/world-map.css. */
+.world-map-section-host {
width: 100%;
- height: auto;
+ height: 320px;
+ position: relative;
}
-.council-dot {
+/* Hover-driven slot classes. The ui/world-map module applies these via
+ setSlot(); the colors / opacities below are this dashboard's choice. */
+.world-map-svg .world-map-country.hovered,
+.world-map-svg .world-map-country.hovered path {
+ fill: var(--primary-hover);
+}
+
+.world-map-svg .world-map-country.reachable,
+.world-map-svg .world-map-country.reachable path {
+ fill: #86efac;
+}
+
+/* Dim by replacing the fill instead of dropping opacity — the base land
+ color is already dark (#2a2d3a), so opacity 0.25 over the page bg made
+ un-focused countries read as black. A flat muted fill keeps every
+ dimmed country to the same quiet tone regardless of whether it was
+ `selected` before. */
+.world-map-svg .world-map-country.dimmed,
+.world-map-svg .world-map-country.dimmed path {
+ fill: rgba(139, 143, 163, 0.18);
+}
+
+/* Persistent "you clicked this" highlight — kept after the pointer leaves
+ so the country-details side panel has a visible anchor. */
+.world-map-svg .world-map-country.active-country,
+.world-map-svg .world-map-country.active-country path {
fill: var(--primary);
- opacity: 0.8;
- cursor: pointer;
- transition: opacity 0.15s, r 0.15s;
+ stroke: var(--primary-hover);
+ stroke-width: 1;
}
-.council-dot:hover {
- opacity: 1;
- fill: var(--primary-hover);
+/* §5 — Country details side panel ────────────────────────────────── */
+
+.country-details {
+ padding: 0.875rem 1.125rem;
+ display: flex;
+ flex-direction: column;
}
-.council-label {
- fill: var(--text-muted);
- font-size: 10px;
- text-anchor: middle;
- pointer-events: none;
+.country-details-hint {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ font-style: italic;
}
-/* Council grid */
-.council-grid {
- display: grid;
- grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
- gap: 1rem;
- margin-top: 1rem;
+.country-details-hint.hidden,
+.country-details-panel.hidden {
+ display: none;
}
-.council-card {
- background: var(--surface);
+.country-details-header {
+ display: flex;
+ gap: 0.625rem;
+ align-items: baseline;
+ margin-bottom: 0.625rem;
+}
+
+.country-details-title {
+ margin: 0;
+ font-size: 1.0625rem;
+ font-weight: 700;
+ color: var(--text);
+}
+
+.country-details-code {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ background: rgba(139, 143, 163, 0.18);
+ padding: 0.125rem 0.5rem;
+ border-radius: 999px;
+ font-family: var(--font-mono);
+}
+
+.country-details-empty {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.country-details-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
+ display: flex;
+ flex-direction: column;
+ gap: 0.5rem;
+ overflow-y: auto;
+}
+
+.country-details-council {
+ width: 100%;
+ background: rgba(255, 255, 255, 0.03);
border: 1px solid var(--border);
- border-radius: 8px;
- padding: 1.25rem;
- text-decoration: none;
+ border-radius: 6px;
+ padding: 0.5rem 0.625rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.125rem;
+ text-align: left;
color: var(--text);
- transition: border-color 0.15s;
+ cursor: pointer;
}
-.council-card:hover {
+.country-details-council:hover {
+ background: rgba(255, 255, 255, 0.07);
border-color: var(--primary);
}
-.council-card-header {
+.country-details-council-name {
+ font-size: 0.8125rem;
+ font-weight: 700;
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.country-details-council-meta {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
+}
+
+.country-details-council-juris {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+/* §3 — Provider details column ────────────────────────────────────── */
+
+.provider-details-hint {
+ font-size: 0.8125rem;
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+.provider-details-hint.hidden,
+.provider-details-panel.hidden {
+ display: none;
+}
+
+.provider-details-header {
+ margin-bottom: 0.625rem;
+}
+
+.provider-details-title {
+ margin: 0;
+ font-size: 1.0625rem;
+ font-weight: 700;
+ color: var(--text);
+ word-break: break-all;
+}
+
+.provider-details-grid {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 0.625rem;
+ margin-bottom: 0.75rem;
+}
+
+.provider-details-kv {
display: flex;
- justify-content: space-between;
- align-items: center;
- margin-bottom: 0.5rem;
+ flex-direction: column;
+ gap: 0.125rem;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ padding: 0.5rem 0.625rem;
+ border: 1px solid var(--border);
}
-.council-name {
+.provider-details-kv-label {
+ font-size: 0.625rem;
font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
+ color: var(--text-muted);
}
-.council-card-meta {
- font-size: 0.875rem;
- color: var(--text-muted);
- margin-bottom: 0.25rem;
+.provider-details-kv-value {
+ font-size: 1.0625rem;
+ font-weight: 700;
+ color: var(--text);
+ font-variant-numeric: tabular-nums;
}
-.council-card-id {
- font-size: 0.7rem;
+.provider-details-subheader {
+ margin: 0.5rem 0 0.25rem;
+ font-size: 0.65rem;
+ font-weight: 600;
+ text-transform: uppercase;
+ letter-spacing: 0.06em;
color: var(--text-muted);
}
-/* Transaction feed */
-.feed-list {
+.provider-details-pubkey {
+ font-family: var(--font-mono);
+ font-size: 0.6875rem;
+ color: var(--text);
+ word-break: break-all;
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ padding: 0.375rem 0.5rem;
+}
+
+.provider-details-council-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
display: flex;
flex-direction: column;
+ gap: 0.25rem;
+}
+
+.provider-details-council-list li {
+ background: rgba(255, 255, 255, 0.03);
+ border-radius: 6px;
+ padding: 0.375rem 0.5rem;
+ display: flex;
+ justify-content: space-between;
gap: 0.5rem;
- margin-top: 1rem;
+ font-size: 0.75rem;
+ overflow: hidden;
}
-.feed-item {
+.provider-details-council-name {
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
+}
+
+.provider-details-council-juris {
+ color: var(--text-muted);
+ font-family: var(--font-mono);
+ font-size: 0.65rem;
+ flex-shrink: 0;
+}
+
+/* Popover anchored to the cursor inside the §5 host. */
+.world-map-popover {
+ position: absolute;
+ pointer-events: none;
+ z-index: 4;
+ min-width: 180px;
+ max-width: 280px;
background: var(--surface);
border: 1px solid var(--border);
- border-radius: 8px;
- padding: 0.75rem 1rem;
+ border-radius: 6px;
+ padding: 0.5rem 0.625rem;
+ box-shadow: 0 6px 20px rgba(0, 0, 0, 0.35);
+ font-size: 0.75rem;
+ color: var(--text);
+}
+
+.world-map-popover-header {
+ font-weight: 700;
+ margin-bottom: 0.375rem;
+ color: var(--primary);
}
-.feed-item-header {
+.world-map-popover-list {
+ list-style: none;
+ margin: 0;
+ padding: 0;
display: flex;
- align-items: center;
- gap: 0.75rem;
- margin-bottom: 0.25rem;
+ flex-direction: column;
+ gap: 0.25rem;
}
-.feed-event-type {
- font-weight: 500;
- font-size: 0.875rem;
+.world-map-popover-list li {
+ display: flex;
+ justify-content: space-between;
+ gap: 0.5rem;
}
-.feed-item-details {
- font-size: 0.8rem;
- margin-bottom: 0.25rem;
+.world-map-popover-name {
+ color: var(--text);
+ overflow: hidden;
+ text-overflow: ellipsis;
+ white-space: nowrap;
}
-.feed-item-id {
- font-size: 0.7rem;
+.world-map-popover-meta {
color: var(--text-muted);
+ font-variant-numeric: tabular-nums;
}
-/* Error banner */
-.error-banner {
- background: rgba(239, 68, 68, 0.1);
- border: 1px solid var(--inactive);
- border-radius: 8px;
- padding: 0.75rem 1rem;
- font-size: 0.875rem;
- color: var(--inactive);
- margin-bottom: 1rem;
+.world-map-popover-empty {
+ color: var(--text-muted);
+ font-style: italic;
+}
+
+@media (max-width: 960px) {
+ .row.detail-grid {
+ grid-template-columns: 1fr;
+ grid-template-rows: auto;
+ grid-template-areas:
+ "map"
+ "countries"
+ "councils"
+ "events"
+ "providers";
+ }
+ .row.trends {
+ grid-template-columns: 1fr;
+ }
+ .counter-strip {
+ grid-template-columns: repeat(3, minmax(0, 1fr));
+ }
+ .sparklines {
+ grid-template-columns: 1fr;
+ }
+ .activity-feed,
+ .council-details,
+ .provider-details {
+ min-height: 320px;
+ }
}
diff --git a/src/app.ts b/src/app.ts
index e00f669..f9b6ceb 100644
--- a/src/app.ts
+++ b/src/app.ts
@@ -1,34 +1,149 @@
-import { navigate, route, startRouter } from "./lib/router.ts";
-import { mapView } from "./views/map.ts";
-import { councilsView } from "./views/councils.ts";
-import { councilDetailView } from "./views/council-detail.ts";
-import { transactionsView } from "./views/transactions.ts";
-
-route("/map", mapView);
-route("/councils", councilsView);
-route("/council/:id", councilDetailView);
-route("/transactions", transactionsView);
-
-route("/", () => {
- navigate("/map");
- return document.createElement("div");
-});
-
-route("/404", () => {
- const el = document.createElement("div");
- el.className = "login-container";
- el.innerHTML =
- `
`;
- return el;
-});
-
-startRouter();
-
-// Dev-mode version check — __DEV_MODE__ is false in production, esbuild removes the block
-import { checkVersions } from "./lib/version-check.ts";
-declare const __DEV_MODE__: boolean;
-if (__DEV_MODE__) {
- checkVersions().then((banner) => {
- if (banner) document.body.prepend(banner);
+/**
+ * Network-dashboard SPA entry. Single page, no router, no auth.
+ *
+ * Layout (top to bottom):
+ * 1. Counter strip
+ * 2. World map + Country details (2/3 + 1/3)
+ * 3. Detail trio — Events + Council details + Provider details (1/3 each)
+ * 4. Sparklines + Asset breakdown
+ *
+ * Drilldown chain: map country click → CountryDetails lists councils →
+ * council click → CouncilDetails populates → PP click → ProviderDetails
+ * populates.
+ */
+import { renderNav } from "@moonlight/ui/nav";
+import { pageLayout } from "@moonlight/ui/layout";
+import { NETWORK_DASHBOARD_PLATFORM_URL } from "./lib/config.ts";
+import { connectNetworkPlatform } from "./lib/ws-client.ts";
+import { CounterStrip } from "./views/counter-strip.ts";
+import { ActivityFeed } from "./views/activity-feed.ts";
+import { CouncilDetails } from "./views/council-details.ts";
+import { SparklineGroup } from "./views/sparklines.ts";
+import { AssetBreakdown } from "./views/asset-breakdown.ts";
+import { WorldMap } from "./views/world-map.ts";
+import { CountryDetails } from "./views/country-details.ts";
+import { ProviderDetails } from "./views/provider-details.ts";
+
+declare const __APP_VERSION__: string;
+
+function renderShell(): {
+ counters: CounterStrip;
+ feed: ActivityFeed;
+ details: CouncilDetails;
+ sparklines: SparklineGroup;
+ assets: AssetBreakdown;
+ worldMap: WorldMap;
+ countryDetails: CountryDetails;
+ providerDetails: ProviderDetails;
+} {
+ const app = document.getElementById("app");
+ if (!app) throw new Error("#app root not found");
+ app.textContent = "";
+
+ const dashboard = document.createElement("div");
+ dashboard.className = "dashboard";
+
+ const counters = new CounterStrip();
+
+ // 3×3 grid:
+ // row 1: map map country-details
+ // row 2: map map council-details
+ // row 3: feed feed provider-details
+ // Positions are CSS-driven via grid-template-areas; DOM order is just
+ // for accessibility / focus traversal.
+ const grid = document.createElement("div");
+ grid.className = "row detail-grid";
+ const worldMap = new WorldMap();
+ const countryDetails = new CountryDetails();
+ const details = new CouncilDetails();
+ const providerDetails = new ProviderDetails();
+ const feed = new ActivityFeed();
+ grid.append(
+ worldMap.element(),
+ countryDetails.element(),
+ details.element(),
+ feed.element(),
+ providerDetails.element(),
+ );
+
+ const trendsRow = document.createElement("div");
+ trendsRow.className = "row trends";
+ const sparklines = new SparklineGroup();
+ const assets = new AssetBreakdown();
+ trendsRow.append(sparklines.element(), assets.element());
+
+ dashboard.append(
+ counters.element(),
+ grid,
+ trendsRow,
+ );
+
+ const nav = renderNav({
+ brand: "Network Dashboard",
+ version: __APP_VERSION__,
+ });
+ app.appendChild(pageLayout(nav, dashboard));
+ return {
+ counters,
+ feed,
+ details,
+ sparklines,
+ assets,
+ worldMap,
+ countryDetails,
+ providerDetails,
+ };
+}
+
+function bootstrap() {
+ const {
+ counters,
+ feed,
+ details,
+ sparklines,
+ assets,
+ worldMap,
+ countryDetails,
+ providerDetails,
+ } = renderShell();
+
+ // Drilldown wiring: country → council → PP.
+ worldMap.setOnCountryClick((countryCode) => {
+ countryDetails.select(countryCode);
+ worldMap.setSelectedCountry(countryCode);
+ });
+ countryDetails.setOnCouncilClick((councilId) => {
+ details.select(councilId);
+ providerDetails.clear();
+ });
+ details.setOnPpClick((pubKey) => {
+ providerDetails.select(pubKey);
+ });
+
+ if (!NETWORK_DASHBOARD_PLATFORM_URL) {
+ feed.setStatus("closed");
+ return;
+ }
+
+ connectNetworkPlatform(NETWORK_DASHBOARD_PLATFORM_URL, {
+ onStatusChange: (status) => feed.setStatus(status),
+ onSnapshot: (frame) => {
+ counters.render(frame.counters);
+ feed.seed(frame.recent);
+ details.setTopology(frame.topology);
+ details.setRollingMetrics(frame.councilRolling);
+ sparklines.render(frame.sparklines);
+ assets.render(frame.assetBreakdown);
+ worldMap.render(frame.topology);
+ countryDetails.setTopology(frame.topology);
+ providerDetails.setTopology(frame.topology);
+ providerDetails.setRecent(frame.recent);
+ },
+ onEvent: (event, liveCounters) => {
+ feed.append(event);
+ counters.render(liveCounters);
+ },
});
}
+
+bootstrap();
diff --git a/src/build.ts b/src/build.ts
index 0b0183d..4c2f2f7 100644
--- a/src/build.ts
+++ b/src/build.ts
@@ -1,29 +1,34 @@
/**
- * Bundles src/app.ts into public/app.js for the browser.
- * Uses esbuild via Deno with denoPlugins for import map resolution.
+ * Bundles src/app.ts into public/app.js and emits the auxiliary build
+ * artifacts the static server hosts:
+ *
+ * - public/app.js — bundled SPA
+ * - public/styles.css — the single app stylesheet (no upstream concat)
+ * - public/health.json — used by /health probes in deploy-verify
*/
// deno-lint-ignore no-import-prefix -- build script intentionally pins the URL
import * as esbuild from "https://deno.land/x/esbuild@v0.20.1/mod.js";
// deno-lint-ignore no-import-prefix -- build script intentionally pins the version
import { denoPlugins } from "jsr:@luca/esbuild-deno-loader@0.10";
+async function writeHealthJson(version: string): Promise {
+ const health = { status: "ok", service: "network-dashboard", version };
+ await Deno.writeTextFile("public/health.json", JSON.stringify(health) + "\n");
+ console.log(`Built public/health.json (network-dashboard ${version})`);
+}
+
// Pinned @moonlight/ui tag. raw.githubusercontent.com serves CSS as
// text/plain with nosniff so browsers refuse @import of these URLs; we
// fetch + concatenate at build time and write the result to public/styles.css.
-// Do not change without bumping the consumer-side deps explicitly.
-const UI_LIB_TAG = "v0.3.1";
+// Same pattern + tag as provider-console / council-console.
+const UI_LIB_TAG = "v0.3.2";
const UI_LIB_CSS_FILES = [
"tokens/tokens.css",
"base-styles/base-styles.css",
"nav/nav.css",
+ "world-map/world-map.css",
];
-async function writeHealthJson(version: string): Promise {
- const health = { status: "ok", service: "network-dashboard", version };
- await Deno.writeTextFile("public/health.json", JSON.stringify(health) + "\n");
- console.log(`Built public/health.json (network-dashboard ${version})`);
-}
-
async function buildStyles(): Promise {
const parts: string[] = [];
for (const path of UI_LIB_CSS_FILES) {
@@ -35,8 +40,9 @@ async function buildStyles(): Promise {
`Failed to fetch ${url}: ${res.status} ${res.statusText}`,
);
}
- const body = await res.text();
- parts.push(`/* @moonlight/ui ${UI_LIB_TAG} — ${path} */\n${body}`);
+ parts.push(
+ `/* @moonlight/ui ${UI_LIB_TAG} — ${path} */\n${await res.text()}`,
+ );
}
const appStyles = await Deno.readTextFile("src/app-styles.css");
parts.push(`/* network-dashboard app-styles */\n${appStyles}`);
@@ -51,25 +57,8 @@ const denoJson = JSON.parse(await Deno.readTextFile("deno.json"));
const version = denoJson.version ?? "0.0.0";
await writeHealthJson(version);
-
await buildStyles();
-async function resolveSorobanCoreVersion(): Promise {
- try {
- const res = await fetch(
- "https://api.github.com/repos/Moonlight-Protocol/soroban-core/releases/latest",
- );
- if (!res.ok) return "unknown";
- const release = await res.json();
- return ((release.tag_name as string) ?? "unknown").replace(/^v/, "");
- } catch {
- return "unknown";
- }
-}
-
-const sorobanCoreVersion = await resolveSorobanCoreVersion();
-console.log(`Resolved soroban-core version: ${sorobanCoreVersion}`);
-
await esbuild.build({
entryPoints: ["src/app.ts"],
bundle: true,
@@ -81,8 +70,6 @@ await esbuild.build({
sourcemap: !isProduction,
define: {
"__APP_VERSION__": JSON.stringify(version),
- "__SOROBAN_CORE_VERSION__": JSON.stringify(sorobanCoreVersion),
- "__DEV_MODE__": JSON.stringify(!isProduction),
},
plugins: [...denoPlugins({ configPath: `${Deno.cwd()}/deno.json` })],
});
diff --git a/src/lib/config.ts b/src/lib/config.ts
index df36e5b..d46d012 100644
--- a/src/lib/config.ts
+++ b/src/lib/config.ts
@@ -1,26 +1,16 @@
/**
- * Dashboard configuration.
- * Reads from global config object set in index.html or defaults.
+ * Public-config surface for the network-dashboard SPA.
+ *
+ * Values are read from `globalThis.__DASHBOARD_CONFIG__` (set by
+ * `public/config.js`, which the static server includes ahead of the
+ * bundled SPA). Production envs ship a substituted `config.js`; local-dev
+ * uses the committed default that points at the loopback stack.
*/
-export interface ChannelConfig {
- privacyChannelId: string;
- assetCode: string;
-}
-
-export interface CouncilConfig {
- name: string;
- channelAuthId: string;
- channels: ChannelConfig[];
- jurisdictions: string[];
- website?: string;
-}
-
interface DashboardConfig {
environment?: string;
stellarNetwork?: "testnet" | "mainnet" | "standalone";
- rpcUrl?: string;
- councilPlatformUrl?: string;
+ networkDashboardPlatformUrl?: string;
}
declare global {
@@ -29,8 +19,6 @@ declare global {
}
}
-// Read config from globalThis to work in both browser and Deno test environments.
-// window.__DASHBOARD_CONFIG__ is set by config.js which loads before app.js.
const config: DashboardConfig | undefined = "__DASHBOARD_CONFIG__" in globalThis
? (globalThis as Record)
.__DASHBOARD_CONFIG__ as DashboardConfig
@@ -46,85 +34,12 @@ const c = config ?? {};
export const ENVIRONMENT = c.environment ?? "development";
export const IS_PRODUCTION = ENVIRONMENT === "production";
export const STELLAR_NETWORK = c.stellarNetwork ?? "testnet";
-export const RPC_URL = c.rpcUrl ?? "https://soroban-testnet.stellar.org";
-export const COUNCIL_PLATFORM_URL = c.councilPlatformUrl ?? "";
-
-export function getNetworkPassphrase(): string {
- switch (STELLAR_NETWORK) {
- case "mainnet":
- return "Public Global Stellar Network ; September 2015";
- case "standalone":
- return "Standalone Network ; February 2017";
- default:
- return "Test SDF Network ; September 2015";
- }
-}
-
-interface PlatformCouncilEntry {
- council?: { name?: string; channelAuthId?: string } | null;
- jurisdictions?: { countryCode?: string }[];
- channels?: { channelContractId?: string; assetCode?: string }[];
-}
-
-function mapPlatformCouncils(entries: PlatformCouncilEntry[]): CouncilConfig[] {
- return entries
- .filter((
- e,
- ): e is PlatformCouncilEntry & { council: { channelAuthId: string } } =>
- !!e.council?.channelAuthId
- )
- .map((e) => ({
- name: e.council.name ?? "Unnamed council",
- channelAuthId: e.council.channelAuthId,
- jurisdictions: (e.jurisdictions ?? [])
- .map((j) => j.countryCode)
- .filter((code): code is string => !!code),
- channels: (e.channels ?? [])
- .filter((ch): ch is { channelContractId: string; assetCode?: string } =>
- !!ch.channelContractId
- )
- .map((ch) => ({
- privacyChannelId: ch.channelContractId,
- assetCode: ch.assetCode ?? "",
- })),
- }));
-}
-
-let councilsCache: Promise | null = null;
/**
- * Returns the list of councils registered with council-platform.
- * Cached for the lifetime of the page — refresh the tab to repoll.
- * Returns an empty array if COUNCIL_PLATFORM_URL is not configured or the
- * fetch fails; views fall back to their own empty-state UI in that case.
+ * Base URL for the network-dashboard-platform WebSocket. The SPA appends
+ * `/api/v1/network/ws` to form the final URL. Empty means "no WS configured"
+ * — the SPA paints an idle layout with a connection-status banner instead
+ * of throwing.
*/
-export function getCouncils(): Promise {
- if (councilsCache) return councilsCache;
-
- if (!COUNCIL_PLATFORM_URL) {
- console.warn(
- "councilPlatformUrl not configured — council list will be empty.",
- );
- councilsCache = Promise.resolve([]);
- return councilsCache;
- }
-
- const url = `${
- COUNCIL_PLATFORM_URL.replace(/\/+$/, "")
- }/api/v1/public/councils`;
- councilsCache = fetch(url)
- .then(async (res) => {
- if (!res.ok) {
- throw new Error(`council-platform returned HTTP ${res.status}`);
- }
- const body = await res.json();
- const data = Array.isArray(body?.data) ? body.data : [];
- return mapPlatformCouncils(data);
- })
- .catch((err) => {
- console.warn("Failed to load councils from council-platform:", err);
- return [];
- });
-
- return councilsCache;
-}
+export const NETWORK_DASHBOARD_PLATFORM_URL: string =
+ (c.networkDashboardPlatformUrl ?? "").trim();
diff --git a/src/lib/config_deploy_test.ts b/src/lib/config_deploy_test.ts
deleted file mode 100644
index 65f0a64..0000000
--- a/src/lib/config_deploy_test.ts
+++ /dev/null
@@ -1,95 +0,0 @@
-/**
- * Invariant test: every key declared on `DashboardConfig` must be written
- * by the deploy workflow — unless it appears in an explicit "default-only"
- * allowlist below. Catches the orphan-read class of bug, where source reads
- * `config.X` but the deploy workflow never writes X (so the feature is
- * silently dead in production). The bug class that motivated this test
- * (moonlight-pay PR #27): `adminWallets` had been read by `isAdmin()` since
- * the admin route shipped, but the deploy.yml never wrote it, so the admin
- * route was unreachable on every deploy.
- *
- * This repo's deploy.yml uses `deno eval` (not a heredoc) to emit
- * public/config.js, but the regex contract is the same: between the
- * `Generate {production,mainnet} config` marker and the `Build production
- * bundle` marker, every line of the form `:` is treated as a written
- * config key.
- */
-import { assertEquals } from "@std/assert";
-
-const HERE = new URL(".", import.meta.url);
-const CONFIG_TS = await Deno.readTextFile(new URL("./config.ts", HERE));
-const DEPLOY_YML = await Deno.readTextFile(
- new URL("../../.github/workflows/deploy.yml", HERE),
-);
-
-// Keys declared on `DashboardConfig` that may legitimately be omitted from
-// a deploy heredoc because the source-side default is correct for that
-// network. Adding a key here is a deliberate "yes, the default is right"
-// decision — the test will fail if you add a new config key and forget to
-// either wire it through deploy.yml or list it here.
-const DEFAULT_OK: Record<"testnet" | "mainnet", Set> = {
- testnet: new Set(),
- mainnet: new Set(),
-};
-
-function extractConfigKeys(src: string): Set {
- const m = src.match(/interface DashboardConfig\s*\{([\s\S]*?)\}/);
- if (!m) {
- throw new Error(
- "Could not locate `interface DashboardConfig` in config.ts",
- );
- }
- return new Set(
- [...m[1].matchAll(/^\s*(\w+)\??:/gm)].map((x) => x[1]),
- );
-}
-
-function extractHeredocKeys(yml: string, header: string): Set {
- const start = yml.indexOf(header);
- if (start < 0) throw new Error(`Could not find heredoc header: ${header}`);
- const end = yml.indexOf("Build production bundle", start);
- if (end < 0) {
- throw new Error(`Could not find end-of-heredoc after: ${header}`);
- }
- const block = yml.slice(start, end);
- return new Set(
- [...block.matchAll(/^\s*(\w+):/gm)]
- .map((x) => x[1])
- // Strip YAML step keys ("name", "run") that aren't config keys.
- .filter((k) => k !== "name" && k !== "run"),
- );
-}
-
-Deno.test("deploy.yml writes every DashboardConfig key on testnet (or it's in DEFAULT_OK)", () => {
- const declared = extractConfigKeys(CONFIG_TS);
- const written = extractHeredocKeys(DEPLOY_YML, "Generate production config");
- const missing = [...declared].filter(
- (k) => !written.has(k) && !DEFAULT_OK.testnet.has(k),
- );
- assertEquals(
- missing,
- [],
- `DashboardConfig keys read by source but not written by testnet deploy: ${
- missing.join(", ")
- }. ` +
- `Wire them through deploy.yml, or — only if the source-side default is intentionally correct ` +
- `for testnet — add them to DEFAULT_OK.testnet in this file.`,
- );
-});
-
-Deno.test("deploy.yml writes every DashboardConfig key on mainnet (or it's in DEFAULT_OK)", () => {
- const declared = extractConfigKeys(CONFIG_TS);
- const written = extractHeredocKeys(DEPLOY_YML, "Generate mainnet config");
- const missing = [...declared].filter(
- (k) => !written.has(k) && !DEFAULT_OK.mainnet.has(k),
- );
- assertEquals(
- missing,
- [],
- `DashboardConfig keys read by source but not written by mainnet deploy: ${
- missing.join(", ")
- }. ` +
- `Wire them through deploy.yml, or — only if the source-side default is intentionally correct ` +
- `for mainnet — add them to DEFAULT_OK.mainnet in this file.`,
- );
-});
diff --git a/src/lib/config_test.ts b/src/lib/config_test.ts
deleted file mode 100644
index 9799037..0000000
--- a/src/lib/config_test.ts
+++ /dev/null
@@ -1,11 +0,0 @@
-import { assertEquals } from "@std/assert";
-import { getNetworkPassphrase } from "./config.ts";
-
-// getNetworkPassphrase reads module-level STELLAR_NETWORK which defaults to
-// "testnet" when no config is present (Deno test environment).
-Deno.test("getNetworkPassphrase returns testnet passphrase by default", () => {
- assertEquals(
- getNetworkPassphrase(),
- "Test SDF Network ; September 2015",
- );
-});
diff --git a/src/lib/format.ts b/src/lib/format.ts
new file mode 100644
index 0000000..360a7be
--- /dev/null
+++ b/src/lib/format.ts
@@ -0,0 +1,60 @@
+/**
+ * Display formatters shared across the 5-section layout. Kept separate
+ * from `dom.ts` (which holds DOM helpers like truncateAddress) so the
+ * presentation logic stays grouped.
+ */
+
+import { formatAmount } from "./dom.ts";
+
+/**
+ * Compact formatter for the counter strip (1.2K, 38.4M). For sub-thousand
+ * values it returns the plain number; counters are always integers.
+ */
+export function formatCount(n: number): string {
+ if (!Number.isFinite(n)) return "—";
+ if (Math.abs(n) < 1_000) return Math.round(n).toLocaleString();
+ if (Math.abs(n) < 1_000_000) return `${(n / 1_000).toFixed(1)}K`;
+ if (Math.abs(n) < 1_000_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
+ return `${(n / 1_000_000_000).toFixed(1)}B`;
+}
+
+/**
+ * Latency tile copy. Backend reports null while no live samples are in
+ * window — we surface that as "—" rather than a misleading 0.
+ */
+export function formatLatencyMs(ms: number | null): string {
+ if (ms === null || !Number.isFinite(ms)) return "—";
+ if (ms < 1_000) return `${Math.round(ms)} ms`;
+ return `${(ms / 1_000).toFixed(1)} s`;
+}
+
+/**
+ * Whole-asset display from stroops (string-encoded for int64 safety).
+ * Two decimal places — sparkline and asset breakdown both render at this
+ * resolution.
+ */
+export function formatStroops(amountStroops: string): string {
+ return formatAmount(amountStroops);
+}
+
+/**
+ * Format a percentage to one decimal place: 12.3%.
+ */
+export function formatPercent(pct: number): string {
+ if (!Number.isFinite(pct)) return "—";
+ return `${pct.toFixed(1)}%`;
+}
+
+/**
+ * "Deployed Nd ago" / "Nh ago" copy for the council details panel header.
+ * Falls back to the raw ISO if parsing fails.
+ */
+export function deployedAge(iso: string): string {
+ const ms = Date.parse(iso);
+ if (Number.isNaN(ms)) return iso;
+ const diffSec = Math.floor((Date.now() - ms) / 1_000);
+ if (diffSec < 60) return `${diffSec}s ago`;
+ if (diffSec < 3_600) return `${Math.floor(diffSec / 60)}m ago`;
+ if (diffSec < 86_400) return `${Math.floor(diffSec / 3_600)}h ago`;
+ return `${Math.floor(diffSec / 86_400)}d ago`;
+}
diff --git a/src/lib/geo_test.ts b/src/lib/geo_test.ts
deleted file mode 100644
index 4455a06..0000000
--- a/src/lib/geo_test.ts
+++ /dev/null
@@ -1,61 +0,0 @@
-import { assertEquals } from "@std/assert";
-import {
- COUNTRIES,
- getCountryName,
- projectCountry,
- sanitizeSvgPath,
-} from "./world-map.ts";
-
-Deno.test("getCountryName returns full name for known code", () => {
- assertEquals(getCountryName("US"), "United States");
- assertEquals(getCountryName("AR"), "Argentina");
- assertEquals(getCountryName("GB"), "United Kingdom");
-});
-
-Deno.test("getCountryName returns code for unknown country", () => {
- assertEquals(getCountryName("ZZ"), "ZZ");
-});
-
-Deno.test("all country coords have valid lon/lat ranges", () => {
- for (const [code, c] of Object.entries(COUNTRIES)) {
- if (c.lon < -180 || c.lon > 180 || c.lat < -90 || c.lat > 90) {
- throw new Error(`${code} out of bounds: (${c.lon}, ${c.lat})`);
- }
- }
-});
-
-Deno.test("projectCountry returns projected coordinates within SVG viewBox", () => {
- const result = projectCountry("US", 0, 0);
- assertEquals(result !== null, true);
- if (result) {
- // SVG viewBox: 30.767 241.591 784.077 458.627
- assertEquals(result.x > 30 && result.x < 815, true);
- assertEquals(result.y > 241 && result.y < 700, true);
- }
-});
-
-Deno.test("projectCountry returns null for unknown code", () => {
- assertEquals(projectCountry("ZZ", 0, 0), null);
-});
-
-Deno.test("sanitizeSvgPath allows valid path commands", () => {
- const valid = "M100.5,200.3 L300,400 Z";
- assertEquals(sanitizeSvgPath(valid), valid);
-});
-
-Deno.test("sanitizeSvgPath strips script injection", () => {
- const malicious = 'M0,0" />';
- const result = sanitizeSvgPath(malicious);
- assertEquals(result.includes("<"), false);
- assertEquals(result.includes(">"), false);
- assertEquals(result.includes('"'), false);
-});
-
-Deno.test("sanitizeSvgPath strips event handlers", () => {
- const malicious = "M0,0 onmouseover=alert(1)";
- const result = sanitizeSvgPath(malicious);
- // Only valid chars remain: M0,0 omousovelert1
- assertEquals(result.includes("="), false);
- assertEquals(result.includes("("), false);
- assertEquals(result.includes(")"), false);
-});
diff --git a/src/lib/nav.ts b/src/lib/nav.ts
deleted file mode 100644
index 3ac5c4c..0000000
--- a/src/lib/nav.ts
+++ /dev/null
@@ -1,20 +0,0 @@
-import { renderNav } from "@moonlight/ui/nav";
-
-declare const __APP_VERSION__: string;
-
-/**
- * Network-dashboard nav with the fixed brand + top-level links shared
- * across every view. Each view calls this helper instead of the lib's
- * renderNav directly to avoid duplicating the brand/links arrays.
- */
-export function getNav(): HTMLElement {
- return renderNav({
- brand: "Moonlight Network",
- version: __APP_VERSION__,
- links: [
- { href: "#/map", label: "Map" },
- { href: "#/councils", label: "Councils" },
- { href: "#/transactions", label: "Transactions" },
- ],
- });
-}
diff --git a/src/lib/network-events.ts b/src/lib/network-events.ts
new file mode 100644
index 0000000..1cc7a7b
--- /dev/null
+++ b/src/lib/network-events.ts
@@ -0,0 +1,124 @@
+/**
+ * Mirror of the network-dashboard-platform's v2 frame shape. Kept here so
+ * the SPA can type-check incoming WS frames without depending on the
+ * backend's source tree. When the backend bumps NETWORK_WS_SUBPROTOCOL the
+ * SPA must update this file in lockstep.
+ */
+
+export const NETWORK_EVENT_KINDS = [
+ "council_formed",
+ "provider_added",
+ "provider_removed",
+ "asset_registered",
+ "channel_deposit",
+ "channel_settlement",
+ "channel_bundle",
+] as const;
+
+export type NetworkEventKind = typeof NETWORK_EVENT_KINDS[number];
+
+export type NetworkEvent = {
+ id: string;
+ kind: NetworkEventKind;
+ councilId: string;
+ councilName: string | null;
+ ledger: number;
+ occurredAt: string;
+ payload: Record;
+};
+
+export type CouncilTopologyEntry = {
+ id: string;
+ name: string | null;
+ providers: Array<{ publicKey: string; label: string | null }>;
+ channels: Array<{
+ contractId: string;
+ assetCode: string;
+ assetContractId: string | null;
+ }>;
+ jurisdictions: string[];
+};
+
+export type Counters = {
+ councils: number;
+ activePPs: number;
+ eventsLast24h: number;
+ assetsRegistered: number;
+ throughputPerMin: number;
+ latencyMs: number | null;
+};
+
+export type Sparklines = {
+ throughput: number[];
+ latency: Array;
+ volume: number[];
+};
+
+export type AssetBreakdownRow = {
+ assetContractId: string;
+ assetCode: string;
+ amountStroops: string;
+ percent: number;
+};
+
+export type CouncilRollingMetrics = {
+ bundlesLastHour: number;
+ eventsLastHour: number;
+ ratePerMin: number;
+ depositVolumeStroops: string;
+ settlementVolumeStroops: string;
+};
+
+export type SnapshotFrame = {
+ type: "snapshot";
+ counters: Counters;
+ topology: CouncilTopologyEntry[];
+ recent: NetworkEvent[];
+ sparklines: Sparklines;
+ assetBreakdown: AssetBreakdownRow[];
+ councilRolling: Record;
+ generatedAt: string;
+};
+
+export type LiveFrame = {
+ type: "event";
+ event: NetworkEvent;
+ /**
+ * Snapshot of the counters at the moment this event was broadcast.
+ * Lets the SPA refresh the counter strip on every event instead of
+ * waiting for the next snapshot.
+ */
+ counters: Counters;
+};
+
+export type ServerFrame = SnapshotFrame | LiveFrame;
+
+export const NETWORK_WS_SUBPROTOCOL = "moonlight.network.v2";
+
+/**
+ * Defensive narrowing of an untrusted JSON payload to a ServerFrame.
+ * Returns null if the frame doesn't smell right — the WS client treats
+ * that as a protocol-mismatch and logs without raising.
+ */
+export function parseServerFrame(value: unknown): ServerFrame | null {
+ if (typeof value !== "object" || value === null) return null;
+ const v = value as Record;
+ if (v.type === "snapshot") {
+ if (
+ typeof v.counters !== "object" || v.counters === null ||
+ !Array.isArray(v.topology) ||
+ !Array.isArray(v.recent) ||
+ typeof v.sparklines !== "object" || v.sparklines === null ||
+ !Array.isArray(v.assetBreakdown) ||
+ typeof v.councilRolling !== "object" || v.councilRolling === null
+ ) {
+ return null;
+ }
+ return value as SnapshotFrame;
+ }
+ if (v.type === "event") {
+ if (typeof v.event !== "object" || v.event === null) return null;
+ return value as LiveFrame;
+ }
+ return null;
+}
diff --git a/src/lib/router.ts b/src/lib/router.ts
deleted file mode 100644
index b05b0c1..0000000
--- a/src/lib/router.ts
+++ /dev/null
@@ -1,101 +0,0 @@
-/**
- * Minimal hash-based router for SPA navigation.
- * Routes: #/map, #/councils, #/transactions, #/council/:id
- */
-import { renderError } from "./dom.ts";
-
-type RouteHandler = (
- params?: Record,
-) => HTMLElement | Promise;
-
-const routes = new Map();
-let cleanups: (() => void)[] = [];
-
-export function route(path: string, handler: RouteHandler): void {
- routes.set(path, handler);
-}
-
-export function navigate(path: string, opts?: { force?: boolean }): void {
- const current = globalThis.location.hash.replace(/^#/, "");
- if (opts?.force && current === path) {
- render();
- } else {
- globalThis.location.hash = path;
- }
-}
-
-function matchRoute(
- path: string,
-): { handler: RouteHandler; params: Record } | null {
- // Exact match first
- const exact = routes.get(path);
- if (exact) return { handler: exact, params: {} };
-
- // Parameterized match: /council/:id
- for (const [pattern, handler] of routes) {
- const patternParts = pattern.split("/");
- const pathParts = path.split("/");
- if (patternParts.length !== pathParts.length) continue;
-
- const params: Record = {};
- let match = true;
- for (let i = 0; i < patternParts.length; i++) {
- if (patternParts[i].startsWith(":")) {
- params[patternParts[i].slice(1)] = pathParts[i];
- } else if (patternParts[i] !== pathParts[i]) {
- match = false;
- break;
- }
- }
- if (match) return { handler, params };
- }
-
- return null;
-}
-
-async function render(): Promise {
- const hash = globalThis.location.hash || "#/";
- const path = hash.startsWith("#")
- ? hash.slice(1).split("?")[0]
- : hash.split("?")[0];
-
- const matched = matchRoute(path) ||
- (routes.has("/404") ? { handler: routes.get("/404")!, params: {} } : null);
- if (!matched) return;
-
- for (const fn of cleanups) {
- fn();
- }
- cleanups = [];
-
- const app = document.getElementById("app");
- if (!app) return;
-
- try {
- const element = await matched.handler(matched.params);
- app.innerHTML = "";
- app.appendChild(element);
- } catch (error) {
- console.warn("[router] View render failed:", error);
- app.innerHTML = "";
- const container = document.createElement("main");
- container.className = "container";
- renderError(
- container,
- "Something went wrong",
- "Failed to load this page. Please try again.",
- );
- app.appendChild(container);
- }
-
- globalThis.scrollTo(0, 0);
-}
-
-export function startRouter(): void {
- globalThis.addEventListener("hashchange", render);
- render();
-}
-
-export function onCleanup(fn: () => void): void {
- cleanups.push(fn);
-}
diff --git a/src/lib/router_test.ts b/src/lib/router_test.ts
deleted file mode 100644
index c7579ef..0000000
--- a/src/lib/router_test.ts
+++ /dev/null
@@ -1,57 +0,0 @@
-import { assertEquals } from "@std/assert";
-
-// Test the route matching logic (extracted for testability)
-function matchRoute(
- path: string,
- patterns: string[],
-): { pattern: string; params: Record } | null {
- // Exact match first
- if (patterns.includes(path)) return { pattern: path, params: {} };
-
- // Parameterized match
- for (const pattern of patterns) {
- const patternParts = pattern.split("/");
- const pathParts = path.split("/");
- if (patternParts.length !== pathParts.length) continue;
-
- const params: Record = {};
- let match = true;
- for (let i = 0; i < patternParts.length; i++) {
- if (patternParts[i].startsWith(":")) {
- params[patternParts[i].slice(1)] = pathParts[i];
- } else if (patternParts[i] !== pathParts[i]) {
- match = false;
- break;
- }
- }
- if (match) return { pattern, params };
- }
-
- return null;
-}
-
-Deno.test("exact route match", () => {
- const result = matchRoute("/councils", [
- "/map",
- "/councils",
- "/transactions",
- ]);
- assertEquals(result?.pattern, "/councils");
- assertEquals(result?.params, {});
-});
-
-Deno.test("parameterized route match", () => {
- const result = matchRoute("/council/ABC123", ["/map", "/council/:id"]);
- assertEquals(result?.pattern, "/council/:id");
- assertEquals(result?.params, { id: "ABC123" });
-});
-
-Deno.test("no match returns null", () => {
- const result = matchRoute("/unknown", ["/map", "/councils"]);
- assertEquals(result, null);
-});
-
-Deno.test("parameterized route does not match wrong segment count", () => {
- const result = matchRoute("/council/ABC/extra", ["/council/:id"]);
- assertEquals(result, null);
-});
diff --git a/src/lib/stellar.ts b/src/lib/stellar.ts
deleted file mode 100644
index 3b282ad..0000000
--- a/src/lib/stellar.ts
+++ /dev/null
@@ -1,324 +0,0 @@
-/**
- * Read-only Stellar/Soroban helpers for querying contract state.
- * No wallet, no signing — purely read operations.
- */
-import { getNetworkPassphrase, RPC_URL } from "./config.ts";
-
-const REQUEST_TIMEOUT_MS = 15_000;
-
-/** Errors encountered during the current view's queries. Cleared on each navigation. */
-export const queryErrors: { source: string; message: string; time: number }[] =
- [];
-
-/** Generation counter to scope errors to the current view load. */
-let queryGeneration = 0;
-
-/** Clear accumulated query errors. Call at the start of each view load. */
-export function clearQueryErrors(): void {
- queryErrors.length = 0;
- queryGeneration++;
-}
-
-function recordError(source: string, err: unknown, generation: number): void {
- // Discard errors from a previous view's in-flight requests
- if (generation !== queryGeneration) return;
- const message = err instanceof Error ? err.message : String(err);
- queryErrors.push({ source, message, time: Date.now() });
- if (queryErrors.length > 50) queryErrors.shift();
- console.warn(`[stellar:${source}]`, message);
-}
-
-/** Wrap a promise with a timeout. */
-function withTimeout(
- promise: Promise,
- ms: number,
- label: string,
-): Promise {
- return new Promise((resolve, reject) => {
- const timer = setTimeout(
- () => reject(new Error(`${label} timed out after ${ms}ms`)),
- ms,
- );
- promise.then(
- (v) => {
- clearTimeout(timer);
- resolve(v);
- },
- (e) => {
- clearTimeout(timer);
- reject(e);
- },
- );
- });
-}
-
-// --- Stellar SDK types (subset used by this module) ---
-// Note: hand-written to match @stellar/stellar-sdk@14.2.0.
-// If the SDK API changes, these will need updating.
-
-interface StellarSdkSubset {
- Contract: new (id: string) => StellarContract;
- Account: new (publicKey: string, sequence: string) => StellarAccount;
- TransactionBuilder: new (
- account: StellarAccount,
- opts: { fee: string; networkPassphrase: string },
- ) => TxBuilder;
- scValToNative(val: unknown): unknown;
- rpc: {
- Server: new (url: string, opts?: { allowHttp?: boolean }) => RpcServer;
- };
-}
-
-interface StellarContract {
- call(method: string, ...args: unknown[]): unknown;
-}
-
-// StellarAccount — used as opaque type passed to TransactionBuilder.
-type StellarAccount = Record;
-
-interface TxBuilder {
- addOperation(op: unknown): TxBuilder;
- setTimeout(seconds: number): TxBuilder;
- build(): StellarTransaction;
-}
-
-interface StellarTransaction {
- toXDR(): string;
-}
-
-interface SimulationResult {
- error?: string;
- result?: { retval?: unknown };
-}
-
-interface RpcServer {
- simulateTransaction(tx: StellarTransaction): Promise;
- getLatestLedger(): Promise<{ sequence: number }>;
- getHealth(): Promise<{
- status: string;
- latestLedger: number;
- oldestLedger: number;
- ledgerRetentionWindow: number;
- }>;
- getEvents(opts: EventsRequest): Promise;
-}
-
-interface EventsRequest {
- startLedger: number;
- filters: { type: string; contractIds: string[] }[];
- limit: number;
-}
-
-interface EventsResponse {
- events?: RawEvent[];
-}
-
-interface RawEvent {
- id: string;
- contractId?: string;
- ledger: number;
- createdAt: string;
- topic?: unknown[];
- value?: unknown;
-}
-
-// --- SDK lazy-loading ---
-
-let stellarSdk: StellarSdkSubset | null = null;
-
-// Constant dummy public key for read-only simulations (avoids generating random keys).
-const DUMMY_PK = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAWHF";
-
-async function sdk(): Promise {
- if (!stellarSdk) {
- // Runtime cast: we trust the SDK shape matches our interface for the
- // methods we use. A version mismatch will surface as a runtime error
- // on the first call, not silently.
- const mod = await import("stellar-sdk");
- stellarSdk = mod as unknown as StellarSdkSubset;
- }
- return stellarSdk;
-}
-
-async function getRpcServer(): Promise {
- const s = await sdk();
- return new s.rpc.Server(RPC_URL, {
- allowHttp: RPC_URL.startsWith("http://"),
- });
-}
-
-// --- Public API ---
-
-/**
- * Query a Privacy Channel contract for its supply.
- */
-export async function getChannelSupply(contractId: string): Promise {
- const gen = queryGeneration;
- try {
- const s = await sdk();
- const server = await getRpcServer();
- const contract = new s.Contract(contractId);
- const result = await withTimeout(
- simulateReadCall(server, s, contract, "supply"),
- REQUEST_TIMEOUT_MS,
- "getChannelSupply",
- );
- if (typeof result === "bigint") return result;
- if (typeof result === "number" && isFinite(result)) {
- return BigInt(Math.trunc(result));
- }
- if (typeof result === "string" && /^-?\d+$/.test(result)) {
- return BigInt(result);
- }
- throw new Error(`Unexpected supply type: ${typeof result}`);
- } catch (err) {
- recordError(`getChannelSupply(${contractId.slice(0, 8)})`, err, gen);
- return 0n;
- }
-}
-
-/**
- * Simulate a read-only contract call (no signing needed).
- */
-async function simulateReadCall(
- server: RpcServer,
- s: StellarSdkSubset,
- contract: StellarContract,
- method: string,
-): Promise {
- const account = new s.Account(DUMMY_PK, "0");
- const passphrase = getNetworkPassphrase();
-
- const tx = new s.TransactionBuilder(account, {
- fee: "100",
- networkPassphrase: passphrase,
- })
- .addOperation(contract.call(method))
- .setTimeout(30)
- .build();
-
- const sim = await server.simulateTransaction(tx);
- if (sim.error) {
- throw new Error(`Simulation failed: ${sim.error}`);
- }
-
- if (sim.result?.retval) {
- return s.scValToNative(sim.result.retval);
- }
-
- return null;
-}
-
-/**
- * Get contract events from RPC.
- *
- * Note: RPC event retention is limited (~24h of ledgers). Events older than
- * the retention window are not available. Provider counts derived from events
- * may be incomplete.
- */
-export async function getContractEvents(
- contractId: string,
- startLedger?: number,
- limit = 100,
-): Promise {
- const gen = queryGeneration;
- try {
- const server = await getRpcServer();
- const s = await sdk();
-
- const health = await withTimeout(
- server.getHealth(),
- REQUEST_TIMEOUT_MS,
- "getHealth",
- );
- const seq = health?.latestLedger;
- const oldest = health?.oldestLedger;
- if (typeof seq !== "number" || !isFinite(seq) || seq <= 0) {
- throw new Error("Invalid latest ledger sequence");
- }
- if (typeof oldest !== "number" || !isFinite(oldest) || oldest <= 0) {
- throw new Error("Invalid oldest ledger sequence");
- }
- const start = startLedger ?? Math.max(oldest, seq - 17280);
-
- const response = await withTimeout(
- server.getEvents({
- startLedger: start,
- filters: [{ type: "contract", contractIds: [contractId] }],
- limit,
- }),
- REQUEST_TIMEOUT_MS,
- "getEvents",
- );
-
- return (response.events ?? []).map((e: RawEvent) => ({
- id: typeof e.id === "string" ? e.id : String(e.id ?? ""),
- type: parseEventType(e, s),
- // SDK v15 may return contractId as an Address object — coerce to string
- // so downstream string ops (truncateAddress, etc.) work.
- contractId: typeof e.contractId === "string"
- ? e.contractId
- : (e.contractId != null ? String(e.contractId) : contractId),
- ledger: e.ledger,
- timestamp: e.createdAt,
- topic: e.topic?.map((t: unknown) => safeScValToNative(s, t)) ?? [],
- value: e.value ? safeScValToNative(s, e.value) : null,
- }));
- } catch (err) {
- recordError(`getContractEvents(${contractId.slice(0, 8)})`, err, gen);
- return [];
- }
-}
-
-/**
- * Derive active provider list from a chronologically-ordered event stream.
- * Processes events in order so add→remove→re-add correctly shows as active.
- * Exported for testability.
- */
-export function countProvidersFromEvents(events: ContractEvent[]): string[] {
- const state = new Map(); // address → currently active
-
- for (const e of events) {
- const addr = e.value != null ? String(e.value) : null;
- if (!addr) continue;
- if (e.type === "ProviderAdded") state.set(addr, true);
- if (e.type === "ProviderRemoved") state.set(addr, false);
- }
-
- return [...state.entries()].filter(([, active]) => active).map(([addr]) =>
- addr
- );
-}
-
-function safeScValToNative(s: StellarSdkSubset, val: unknown): unknown {
- try {
- const result = s.scValToNative(val);
- if (result !== null && typeof result === "object") {
- return JSON.stringify(result);
- }
- return result;
- } catch {
- return null;
- }
-}
-
-function parseEventType(event: RawEvent, s: StellarSdkSubset): string {
- if (!event.topic || event.topic.length === 0) return "unknown";
- try {
- const first = s.scValToNative(event.topic[0]);
- if (typeof first === "string") return first;
- return "unknown";
- } catch {
- return "unknown";
- }
-}
-
-export interface ContractEvent {
- id: string;
- type: string;
- contractId: string;
- ledger: number;
- timestamp: string;
- topic: unknown[];
- value: unknown;
-}
diff --git a/src/lib/stellar_test.ts b/src/lib/stellar_test.ts
deleted file mode 100644
index ffafd17..0000000
--- a/src/lib/stellar_test.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { assertEquals } from "@std/assert";
-import {
- clearQueryErrors,
- countProvidersFromEvents,
- queryErrors,
-} from "./stellar.ts";
-import type { ContractEvent } from "./stellar.ts";
-
-function mockEvent(
- type: string,
- value: string | null,
- ledger = 1,
-): ContractEvent {
- return {
- id: "e1",
- type,
- contractId: "C...",
- ledger,
- timestamp: "",
- topic: [],
- value,
- };
-}
-
-Deno.test("queryErrors starts empty", () => {
- assertEquals(Array.isArray(queryErrors), true);
-});
-
-Deno.test("clearQueryErrors empties the array", () => {
- queryErrors.push({ source: "test", message: "err", time: 0 });
- clearQueryErrors();
- assertEquals(queryErrors.length, 0);
-});
-
-Deno.test("countProvidersFromEvents tracks adds and removes", () => {
- const events = [
- mockEvent("ProviderAdded", "GAAA", 1),
- mockEvent("ProviderAdded", "GBBB", 2),
- mockEvent("ProviderRemoved", "GAAA", 3),
- ];
- const result = countProvidersFromEvents(events);
- assertEquals(result, ["GBBB"]);
-});
-
-Deno.test("countProvidersFromEvents handles add-remove-re-add", () => {
- const events = [
- mockEvent("ProviderAdded", "GAAA", 1),
- mockEvent("ProviderRemoved", "GAAA", 2),
- mockEvent("ProviderAdded", "GAAA", 3),
- ];
- const result = countProvidersFromEvents(events);
- assertEquals(result, ["GAAA"]);
-});
-
-Deno.test("countProvidersFromEvents deduplicates multiple adds", () => {
- const events = [
- mockEvent("ProviderAdded", "GAAA", 1),
- mockEvent("ProviderAdded", "GAAA", 2),
- ];
- assertEquals(countProvidersFromEvents(events).length, 1);
-});
-
-Deno.test("countProvidersFromEvents handles empty events", () => {
- assertEquals(countProvidersFromEvents([]).length, 0);
-});
-
-Deno.test("countProvidersFromEvents ignores null values", () => {
- const events = [mockEvent("ProviderAdded", null, 1)];
- assertEquals(countProvidersFromEvents(events).length, 0);
-});
diff --git a/src/lib/version-check.ts b/src/lib/version-check.ts
deleted file mode 100644
index 1cbd330..0000000
--- a/src/lib/version-check.ts
+++ /dev/null
@@ -1,87 +0,0 @@
-/**
- * Dev-mode version mismatch detection.
- * Compares local versions against latest GitHub releases.
- * Only runs in development; silently no-ops in production.
- */
-declare const __APP_VERSION__: string;
-declare const __SOROBAN_CORE_VERSION__: string;
-
-interface VersionEntry {
- name: string;
- local: string;
- latest: string | null;
-}
-
-async function fetchLatestRelease(repo: string): Promise {
- try {
- const res = await fetch(
- `https://api.github.com/repos/Moonlight-Protocol/${repo}/releases/latest`,
- );
- if (!res.ok) return null;
- const data = await res.json();
- return (data.tag_name ?? "").replace(/^v/, "");
- } catch {
- return null;
- }
-}
-
-function esc(s: string): string {
- return s.replace(/&/g, "&").replace(//g, ">")
- .replace(/"/g, """);
-}
-
-function renderBanner(entries: VersionEntry[]): HTMLElement {
- const banner = document.createElement("div");
- banner.className = "version-mismatch-banner";
-
- const spans = entries.map((e) => {
- let color: string;
- let text: string;
- if (!e.latest) {
- color = "var(--pending)";
- text = `${esc(e.name)} v${esc(e.local)}`;
- } else if (e.local === e.latest) {
- color = "var(--active)";
- text = `${esc(e.name)} v${esc(e.local)}`;
- } else {
- color = "var(--inactive)";
- text = `${esc(e.name)} v${esc(e.local)} (latest: v${esc(e.latest)})`;
- }
- return `${text}`;
- });
-
- banner.innerHTML = spans.join(
- ' · ',
- );
- return banner;
-}
-
-export async function checkVersions(): Promise {
- try {
- const entries: VersionEntry[] = [];
-
- const appLatest = await fetchLatestRelease("network-dashboard");
- entries.push({
- name: "network-dashboard",
- local: __APP_VERSION__,
- latest: appLatest,
- });
-
- const scVersion = typeof __SOROBAN_CORE_VERSION__ !== "undefined"
- ? __SOROBAN_CORE_VERSION__
- : null;
- if (scVersion && scVersion !== "unknown") {
- const scLatest = await fetchLatestRelease("soroban-core");
- entries.push({
- name: "soroban-core",
- local: scVersion,
- latest: scLatest,
- });
- }
-
- if (entries.length === 0) return null;
- return renderBanner(entries);
- } catch {
- return null;
- }
-}
diff --git a/src/lib/world-map.ts b/src/lib/world-map.ts
deleted file mode 100644
index 6c49290..0000000
--- a/src/lib/world-map.ts
+++ /dev/null
@@ -1,142 +0,0 @@
-/**
- * World map renderer using a static SVG map.
- * Uses simple-world-map (CC BY-SA 3.0, Al MacDonald / Fritz Lekschas)
- * with ISO 3166-1 country IDs on each path.
- *
- * The SVG is served as a static asset from public/world-map.svg.
- * Country coordinates for marker placement use the same hardcoded
- * centroids as before, projected via equirectangular transform into
- * the SVG's coordinate space.
- */
-
-// Only valid SVG path commands and numeric values.
-const SVG_PATH_VALID = /[^MmLlHhVvCcSsQqTtAaZz0-9eE.,\s\-+]/g;
-
-/**
- * Sanitize an SVG path `d` attribute by removing any characters
- * that are not valid path commands, digits, or separators.
- */
-export function sanitizeSvgPath(d: string): string {
- return d.replace(SVG_PATH_VALID, "");
-}
-
-// Original SVG viewBox: 30.767 241.591 784.077 458.627
-const SVG_VB_X = 30.767;
-const SVG_VB_Y = 241.591;
-const SVG_VB_W = 784.077;
-const SVG_VB_H = 458.627;
-
-/**
- * Fetch the world map SVG from the local static asset.
- * Returns the raw SVG string with all country paths.
- */
-export async function fetchWorldSvg(): Promise {
- const controller = new AbortController();
- const timer = setTimeout(() => controller.abort(), 10_000);
- try {
- const res = await fetch("/world-map.svg", { signal: controller.signal });
- if (!res.ok) throw new Error(`Failed to load map: ${res.status}`);
- return await res.text();
- } finally {
- clearTimeout(timer);
- }
-}
-
-/**
- * Country coordinates (centroids, lon/lat).
- * Projected at render time into the SVG's coordinate space.
- */
-export const COUNTRIES: Record<
- string,
- { lon: number; lat: number; name: string }
-> = {
- // Americas
- US: { lon: -98, lat: 39, name: "United States" },
- CA: { lon: -106, lat: 56, name: "Canada" },
- MX: { lon: -102, lat: 23, name: "Mexico" },
- BR: { lon: -51, lat: -14, name: "Brazil" },
- AR: { lon: -64, lat: -34, name: "Argentina" },
- CL: { lon: -71, lat: -35, name: "Chile" },
- CO: { lon: -74, lat: 4, name: "Colombia" },
- PE: { lon: -76, lat: -10, name: "Peru" },
- UY: { lon: -56, lat: -33, name: "Uruguay" },
- PY: { lon: -58, lat: -23, name: "Paraguay" },
- EC: { lon: -78, lat: -2, name: "Ecuador" },
- VE: { lon: -66, lat: 8, name: "Venezuela" },
- CR: { lon: -84, lat: 10, name: "Costa Rica" },
- PA: { lon: -80, lat: 9, name: "Panama" },
-
- // Europe
- GB: { lon: -2, lat: 54, name: "United Kingdom" },
- DE: { lon: 10, lat: 51, name: "Germany" },
- FR: { lon: 2, lat: 46, name: "France" },
- ES: { lon: -4, lat: 40, name: "Spain" },
- IT: { lon: 12, lat: 42, name: "Italy" },
- CH: { lon: 8, lat: 47, name: "Switzerland" },
- NL: { lon: 5, lat: 52, name: "Netherlands" },
- SE: { lon: 18, lat: 60, name: "Sweden" },
- NO: { lon: 10, lat: 62, name: "Norway" },
- FI: { lon: 26, lat: 64, name: "Finland" },
- PT: { lon: -8, lat: 39, name: "Portugal" },
- IE: { lon: -8, lat: 53, name: "Ireland" },
- PL: { lon: 20, lat: 52, name: "Poland" },
- AT: { lon: 14, lat: 47, name: "Austria" },
- BE: { lon: 4, lat: 51, name: "Belgium" },
- UA: { lon: 32, lat: 49, name: "Ukraine" },
- RU: { lon: 40, lat: 56, name: "Russia" },
-
- // Africa
- NG: { lon: 8, lat: 10, name: "Nigeria" },
- ZA: { lon: 25, lat: -29, name: "South Africa" },
- KE: { lon: 38, lat: 0, name: "Kenya" },
- EG: { lon: 30, lat: 27, name: "Egypt" },
- MA: { lon: -5, lat: 32, name: "Morocco" },
- GH: { lon: -2, lat: 8, name: "Ghana" },
-
- // Middle East
- AE: { lon: 54, lat: 24, name: "UAE" },
- SA: { lon: 45, lat: 24, name: "Saudi Arabia" },
- IL: { lon: 35, lat: 31, name: "Israel" },
- TR: { lon: 32, lat: 39, name: "Turkey" },
-
- // Asia
- IN: { lon: 78, lat: 21, name: "India" },
- CN: { lon: 104, lat: 35, name: "China" },
- JP: { lon: 138, lat: 36, name: "Japan" },
- KR: { lon: 128, lat: 36, name: "South Korea" },
- SG: { lon: 104, lat: 1, name: "Singapore" },
- TH: { lon: 101, lat: 15, name: "Thailand" },
- VN: { lon: 108, lat: 14, name: "Vietnam" },
- ID: { lon: 113, lat: -1, name: "Indonesia" },
- PH: { lon: 122, lat: 13, name: "Philippines" },
- MY: { lon: 102, lat: 4, name: "Malaysia" },
-
- // Oceania
- AU: { lon: 133, lat: -25, name: "Australia" },
- NZ: { lon: 174, lat: -41, name: "New Zealand" },
-};
-
-export function getCountryName(code: string): string {
- return COUNTRIES[code]?.name ?? code;
-}
-
-/**
- * Project a country's centroid into the SVG's coordinate space.
- * Uses the same equirectangular projection as the SVG map.
- */
-export function projectCountry(
- code: string,
- _width: number,
- _height: number,
-): { x: number; y: number } | null {
- const c = COUNTRIES[code.toUpperCase()];
- if (!c) return null;
-
- // Equirectangular: map lon/lat to the SVG viewBox coordinate space.
- // The SVG's viewBox maps roughly to -180..180 lon, -60..85 lat
- // (standard world map cropping).
- const x = SVG_VB_X + ((c.lon + 180) / 360) * SVG_VB_W;
- const y = SVG_VB_Y + ((85 - c.lat) / 145) * SVG_VB_H;
-
- return { x, y };
-}
diff --git a/src/lib/ws-client.ts b/src/lib/ws-client.ts
new file mode 100644
index 0000000..c1919aa
--- /dev/null
+++ b/src/lib/ws-client.ts
@@ -0,0 +1,147 @@
+import {
+ type Counters,
+ NETWORK_WS_SUBPROTOCOL,
+ type NetworkEvent,
+ parseServerFrame,
+ type SnapshotFrame,
+} from "./network-events.ts";
+
+/**
+ * Persistent WebSocket client for the network-dashboard backend.
+ *
+ * - Auto-reconnects with exponential backoff (capped at 30s).
+ * - Exposes `onSnapshot` for the initial frame, `onEvent` per live frame,
+ * and `onStatusChange` for the UI banner ("connecting" / "open" /
+ * "closed").
+ * - Status events fire on the next animation frame to avoid render
+ * loops; data events fire synchronously so the topology + feed update
+ * together.
+ */
+
+export type WsStatus = "idle" | "connecting" | "open" | "closed";
+
+export type WsHandlers = {
+ onSnapshot: (frame: SnapshotFrame) => void;
+ /** Live event + the counters snapshot the backend emitted alongside it. */
+ onEvent: (event: NetworkEvent, counters: Counters) => void;
+ onStatusChange: (status: WsStatus) => void;
+};
+
+const INITIAL_BACKOFF_MS = 500;
+const MAX_BACKOFF_MS = 30_000;
+
+/**
+ * Build the full WS URL from the configured base. The backend exposes its
+ * endpoint at `/api/v1/network/ws`; passing in just the host base keeps
+ * the config surface narrow.
+ */
+export function buildWsUrl(baseUrl: string): string | null {
+ const trimmed = baseUrl.trim();
+ if (!trimmed) return null;
+ let parsed: URL;
+ try {
+ parsed = new URL(trimmed);
+ } catch {
+ return null;
+ }
+ if (parsed.protocol === "http:") parsed.protocol = "ws:";
+ else if (parsed.protocol === "https:") parsed.protocol = "wss:";
+ // Path may already be present in the configured base; append only when missing.
+ if (!parsed.pathname.endsWith("/network/ws")) {
+ parsed.pathname = `${
+ parsed.pathname.replace(/\/+$/, "")
+ }/api/v1/network/ws`;
+ }
+ return parsed.toString();
+}
+
+export type WsHandle = {
+ close(): void;
+};
+
+export function connectNetworkPlatform(
+ baseUrl: string,
+ handlers: WsHandlers,
+): WsHandle {
+ const url = buildWsUrl(baseUrl);
+ if (url === null) {
+ handlers.onStatusChange("closed");
+ return { close() {} };
+ }
+
+ let socket: WebSocket | null = null;
+ let backoffMs = INITIAL_BACKOFF_MS;
+ let reconnectTimer: number | null = null;
+ let closed = false;
+
+ const setStatus = (status: WsStatus) => {
+ handlers.onStatusChange(status);
+ };
+
+ const connect = () => {
+ if (closed) return;
+ setStatus("connecting");
+ try {
+ socket = new WebSocket(url, NETWORK_WS_SUBPROTOCOL);
+ } catch (err) {
+ console.warn("WebSocket constructor failed", err);
+ scheduleReconnect();
+ return;
+ }
+ socket.onopen = () => {
+ setStatus("open");
+ backoffMs = INITIAL_BACKOFF_MS;
+ };
+ socket.onmessage = (ev) => {
+ let parsed: unknown;
+ try {
+ parsed = JSON.parse(ev.data);
+ } catch {
+ return;
+ }
+ const frame = parseServerFrame(parsed);
+ if (frame === null) return;
+ if (frame.type === "snapshot") {
+ handlers.onSnapshot(frame);
+ } else {
+ handlers.onEvent(frame.event, frame.counters);
+ }
+ };
+ socket.onclose = () => {
+ socket = null;
+ setStatus("closed");
+ scheduleReconnect();
+ };
+ socket.onerror = () => {
+ // onclose follows; let it drive the reconnect path.
+ };
+ };
+
+ const scheduleReconnect = () => {
+ if (closed) return;
+ if (reconnectTimer !== null) return;
+ const wait = backoffMs;
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
+ reconnectTimer = globalThis.setTimeout(() => {
+ reconnectTimer = null;
+ connect();
+ }, wait);
+ };
+
+ connect();
+
+ return {
+ close() {
+ closed = true;
+ if (reconnectTimer !== null) {
+ clearTimeout(reconnectTimer);
+ reconnectTimer = null;
+ }
+ if (socket) {
+ socket.close();
+ socket = null;
+ }
+ setStatus("closed");
+ },
+ };
+}
diff --git a/src/lib/ws-client_test.ts b/src/lib/ws-client_test.ts
new file mode 100644
index 0000000..03203f6
--- /dev/null
+++ b/src/lib/ws-client_test.ts
@@ -0,0 +1,36 @@
+import { assertEquals } from "@std/assert";
+import { buildWsUrl } from "./ws-client.ts";
+
+Deno.test("buildWsUrl: http:// → ws://", () => {
+ assertEquals(
+ buildWsUrl("http://localhost:3035"),
+ "ws://localhost:3035/api/v1/network/ws",
+ );
+});
+
+Deno.test("buildWsUrl: https:// → wss://", () => {
+ assertEquals(
+ buildWsUrl("https://dashboard-api.moonlightprotocol.io"),
+ "wss://dashboard-api.moonlightprotocol.io/api/v1/network/ws",
+ );
+});
+
+Deno.test("buildWsUrl: ws:// preserved", () => {
+ assertEquals(
+ buildWsUrl("ws://localhost:3035"),
+ "ws://localhost:3035/api/v1/network/ws",
+ );
+});
+
+Deno.test("buildWsUrl: already-suffixed paths kept as-is", () => {
+ assertEquals(
+ buildWsUrl("wss://example.com/network/ws"),
+ "wss://example.com/network/ws",
+ );
+});
+
+Deno.test("buildWsUrl: empty/invalid returns null", () => {
+ assertEquals(buildWsUrl(""), null);
+ assertEquals(buildWsUrl(" "), null);
+ assertEquals(buildWsUrl("not-a-url"), null);
+});
diff --git a/src/server.ts b/src/server.ts
index 409345f..a6307c1 100644
--- a/src/server.ts
+++ b/src/server.ts
@@ -18,18 +18,17 @@ const SECURITY_HEADERS: Record = {
function getCSP(): string {
const environment = Deno.env.get("ENVIRONMENT") || "development";
const isProd = environment === "production";
- // Non-prod runs against local services (council-platform, soroban-rpc,
- // friendbot, etc. on assorted localhost ports) — use `*` so dev work
- // isn't blocked by CSP. Production keeps the strict allow-list.
+ // Non-prod runs against local services (council-platform, network-dashboard-
+ // platform, etc. on assorted localhost ports) — use `*` so dev work isn't
+ // blocked. Production allows-list the dashboard-api WS hosts the SPA
+ // connects to; ws/wss schemes need explicit hosts even with 'self'.
const connectSrc = isProd
- ? "connect-src 'self' https://soroban-testnet.stellar.org https://cdn.jsdelivr.net/npm/world-atlas@2.0.2/"
+ ? "connect-src 'self' wss://dashboard-api.moonlightprotocol.io wss://dashboard-api-testnet.moonlightprotocol.io"
: "connect-src *";
- // The council-detail view renders inline `style="..."` attributes (progress
- // bars, stat colours), which `style-src 'self'` rejects. Production keeps
- // the strict policy; dev allows inline styles so the UI actually renders.
- const styleSrc = isProd
- ? "style-src 'self'"
- : "style-src 'self' 'unsafe-inline'";
+ // §4 asset-breakdown bars and a couple of inline transitions render via
+ // `style="..."`. The dashboard is public + anonymous, no auth boundary
+ // to defend, so 'unsafe-inline' on style-src is an acceptable trade.
+ const styleSrc = "style-src 'self' 'unsafe-inline'";
return [
"default-src 'self'",
"script-src 'self'",
diff --git a/src/views/activity-feed.ts b/src/views/activity-feed.ts
new file mode 100644
index 0000000..4cebf1e
--- /dev/null
+++ b/src/views/activity-feed.ts
@@ -0,0 +1,201 @@
+import type { NetworkEvent, NetworkEventKind } from "../lib/network-events.ts";
+import { truncateAddress } from "../lib/dom.ts";
+import { formatAmount } from "../lib/dom.ts";
+import type { WsStatus } from "../lib/ws-client.ts";
+
+/**
+ * §2 (right) — Activity feed. Newest card on top; fade-out after 8s.
+ * Up to 5 visible. One card kind per NetworkEventKind, colour-coded per
+ * the sketch palette.
+ */
+
+const CARD_TTL_MS = 5 * 60 * 1000;
+const MAX_VISIBLE = 10;
+
+const KIND_GLYPH: Record = {
+ council_formed: "★",
+ provider_added: "✓",
+ provider_removed: "✗",
+ asset_registered: "+",
+ channel_deposit: "↙",
+ channel_settlement: "↗",
+ channel_bundle: "•",
+};
+
+const KIND_TITLE: Record = {
+ council_formed: "New council formed",
+ provider_added: "PP joined",
+ provider_removed: "PP left",
+ asset_registered: "Asset registered",
+ channel_deposit: "Deposit",
+ channel_settlement: "Settlement",
+ channel_bundle: "Bundle",
+};
+
+const STATUS_COPY: Record = {
+ idle: "idle",
+ connecting: "connecting…",
+ open: "live",
+ closed: "disconnected",
+};
+
+function councilLabel(event: NetworkEvent): string {
+ if (event.councilName?.trim()) return event.councilName;
+ return `Council ${truncateAddress(event.councilId)}`;
+}
+
+function detailFor(event: NetworkEvent): string {
+ const p = event.payload;
+ switch (event.kind) {
+ case "provider_added":
+ case "provider_removed":
+ return typeof p.providerPublicKey === "string"
+ ? truncateAddress(p.providerPublicKey)
+ : "";
+ case "channel_deposit":
+ case "channel_settlement":
+ if (typeof p.amount !== "string") return "";
+ try {
+ return `${formatAmount(p.amount)}`;
+ } catch {
+ return "";
+ }
+ case "council_formed":
+ return truncateAddress(event.councilId);
+ case "asset_registered":
+ return typeof p.assetContractId === "string"
+ ? truncateAddress(p.assetContractId)
+ : "";
+ case "channel_bundle":
+ return typeof p.providerPublicKey === "string"
+ ? `via ${truncateAddress(p.providerPublicKey)}`
+ : "";
+ }
+}
+
+export class ActivityFeed {
+ private root: HTMLElement;
+ private list: HTMLDivElement;
+ private statusEl: HTMLSpanElement;
+ private seen = new Set();
+ private timers = new Map();
+
+ constructor() {
+ this.root = document.createElement("aside");
+ this.root.className = "section activity-feed";
+ this.root.setAttribute("aria-label", "Activity feed");
+ this.root.setAttribute("aria-live", "polite");
+
+ const header = document.createElement("header");
+ header.className = "activity-feed-header";
+ const title = document.createElement("span");
+ title.className = "activity-feed-title";
+ title.textContent = "Activity";
+ const status = document.createElement("span");
+ status.className = "activity-feed-status";
+ status.textContent = STATUS_COPY.idle;
+ this.statusEl = status;
+ header.append(title, status);
+
+ this.list = document.createElement("div");
+ this.list.className = "activity-feed-list";
+
+ this.root.append(header, this.list);
+ }
+
+ element(): HTMLElement {
+ return this.root;
+ }
+
+ setStatus(status: WsStatus): void {
+ this.statusEl.textContent = STATUS_COPY[status];
+ this.statusEl.dataset.state = status;
+ }
+
+ /**
+ * Replace the feed with the snapshot's recent ring buffer. Used on
+ * connect + reconnect.
+ */
+ seed(events: NetworkEvent[]): void {
+ this.clear();
+ for (const e of events) this.prepend(e);
+ }
+
+ /** Add a single live event to the top. */
+ append(event: NetworkEvent): void {
+ this.prepend(event);
+ }
+
+ // ── internals ──────────────────────────────────────────────────────
+
+ private clear(): void {
+ this.list.textContent = "";
+ this.seen.clear();
+ for (const t of this.timers.values()) clearTimeout(t);
+ this.timers.clear();
+ }
+
+ private prepend(event: NetworkEvent): void {
+ if (this.seen.has(event.id)) return;
+ this.seen.add(event.id);
+
+ const card = document.createElement("article");
+ card.className = `activity-card kind-${event.kind}`;
+ card.dataset.eventId = event.id;
+ card.style.setProperty("--ttl-ms", `${CARD_TTL_MS}ms`);
+
+ const glyph = document.createElement("span");
+ glyph.className = "activity-glyph";
+ glyph.textContent = KIND_GLYPH[event.kind];
+
+ const body = document.createElement("div");
+ body.className = "activity-body";
+
+ const titleRow = document.createElement("div");
+ titleRow.className = "activity-title";
+ titleRow.textContent = KIND_TITLE[event.kind];
+
+ const councilRow = document.createElement("div");
+ councilRow.className = "activity-council";
+ councilRow.textContent = councilLabel(event);
+
+ body.append(titleRow, councilRow);
+
+ const detail = detailFor(event);
+ if (detail) {
+ const d = document.createElement("div");
+ d.className = "activity-detail";
+ d.textContent = detail;
+ body.appendChild(d);
+ }
+
+ card.append(glyph, body);
+ this.list.prepend(card);
+
+ while (this.list.childElementCount > MAX_VISIBLE) {
+ const last = this.list.lastElementChild;
+ if (!last) break;
+ const eventId = (last as HTMLElement).dataset.eventId;
+ if (eventId) this.dropTimer(eventId);
+ last.remove();
+ }
+
+ const timer = globalThis.setTimeout(() => {
+ card.classList.add("fading");
+ const finalizer = globalThis.setTimeout(() => {
+ card.remove();
+ this.timers.delete(event.id);
+ }, 600);
+ this.timers.set(event.id, finalizer);
+ }, CARD_TTL_MS);
+ this.timers.set(event.id, timer);
+ }
+
+ private dropTimer(eventId: string): void {
+ const t = this.timers.get(eventId);
+ if (t !== undefined) {
+ clearTimeout(t);
+ this.timers.delete(eventId);
+ }
+ }
+}
diff --git a/src/views/asset-breakdown.ts b/src/views/asset-breakdown.ts
new file mode 100644
index 0000000..22e754c
--- /dev/null
+++ b/src/views/asset-breakdown.ts
@@ -0,0 +1,73 @@
+import type { AssetBreakdownRow } from "../lib/network-events.ts";
+import { formatPercent, formatStroops } from "../lib/format.ts";
+
+/**
+ * §4 (right) — Asset breakdown. Horizontal bar per asset showing share
+ * of total volume settled (deposits + settlements) over the trailing 24h.
+ * Rows sorted by percent descending.
+ */
+
+export class AssetBreakdown {
+ private root: HTMLElement;
+ private list: HTMLElement;
+
+ constructor() {
+ this.root = document.createElement("section");
+ this.root.className = "section asset-breakdown";
+ this.root.setAttribute("aria-label", "24h asset volume breakdown");
+
+ const header = document.createElement("header");
+ header.className = "asset-breakdown-header";
+ const title = document.createElement("span");
+ title.className = "asset-breakdown-title";
+ title.textContent = "Asset volume — last 24h";
+ header.appendChild(title);
+
+ this.list = document.createElement("div");
+ this.list.className = "asset-breakdown-list";
+
+ this.root.append(header, this.list);
+ }
+
+ element(): HTMLElement {
+ return this.root;
+ }
+
+ render(rows: AssetBreakdownRow[]): void {
+ this.list.textContent = "";
+ if (rows.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "asset-breakdown-empty";
+ empty.textContent = "No volume settled yet in the last 24h.";
+ this.list.appendChild(empty);
+ return;
+ }
+ for (const row of rows) {
+ const r = document.createElement("article");
+ r.className = "asset-row";
+
+ const code = document.createElement("span");
+ code.className = "asset-code";
+ code.textContent = row.assetCode;
+
+ const barBox = document.createElement("div");
+ barBox.className = "asset-bar-box";
+ const bar = document.createElement("div");
+ bar.className = "asset-bar";
+ const pct = Math.max(0, Math.min(100, row.percent));
+ bar.style.width = `${pct}%`;
+ barBox.appendChild(bar);
+
+ const percent = document.createElement("span");
+ percent.className = "asset-percent";
+ percent.textContent = formatPercent(row.percent);
+
+ const amount = document.createElement("span");
+ amount.className = "asset-amount";
+ amount.textContent = formatStroops(row.amountStroops);
+
+ r.append(code, barBox, percent, amount);
+ this.list.appendChild(r);
+ }
+ }
+}
diff --git a/src/views/council-detail.ts b/src/views/council-detail.ts
deleted file mode 100644
index e19b457..0000000
--- a/src/views/council-detail.ts
+++ /dev/null
@@ -1,274 +0,0 @@
-/**
- * Council detail view — drill into a council's channels, PPs, and activity.
- */
-import { getNav } from "../lib/nav.ts";
-import { getCouncils } from "../lib/config.ts";
-import {
- clearQueryErrors,
- countProvidersFromEvents,
- getChannelSupply,
- getContractEvents,
- queryErrors,
-} from "../lib/stellar.ts";
-import {
- escapeHtml,
- formatAmount,
- sanitizeUrl,
- timeAgo,
- truncateAddress,
-} from "../lib/dom.ts";
-import { getCountryName } from "../lib/world-map.ts";
-import { onCleanup } from "../lib/router.ts";
-import type { CouncilConfig } from "../lib/config.ts";
-import type { ContractEvent } from "../lib/stellar.ts";
-
-// deno-lint-ignore require-await -- view fn satisfies router's Promise contract
-export async function councilDetailView(
- params?: Record,
-): Promise {
- const el = document.createElement("div");
- el.appendChild(getNav());
-
- const main = document.createElement("main");
- main.className = "container";
-
- let councilId = "";
- try {
- councilId = params?.id ? decodeURIComponent(params.id) : "";
- } catch {
- // Malformed percent-encoding in URL
- }
-
- main.innerHTML = `Loading council...
`;
- el.appendChild(main);
-
- const ctx = { cancelled: false };
- onCleanup(() => {
- ctx.cancelled = true;
- });
-
- getCouncils()
- .then((councils) => {
- if (ctx.cancelled) return;
- const council = councils.find((c) => c.channelAuthId === councilId);
-
- if (!council) {
- main.innerHTML = `
- Council Not Found
- No council registered with ID ${
- escapeHtml(truncateAddress(councilId))
- }
- Back to councils
- `;
- return;
- }
-
- const safeWebsite = council.website ? sanitizeUrl(council.website) : null;
-
- main.innerHTML = `
-
- ${escapeHtml(council.name)}
- ${
- escapeHtml(council.channelAuthId)
- }
- ${
- council.jurisdictions.map((j) => escapeHtml(getCountryName(j))).join(
- ", ",
- )
- }${
- safeWebsite
- ? ` · ${escapeHtml(council.website!)}`
- : ""
- }
-
- `;
-
- loadCouncilDetail(main, council, ctx).catch(() => {});
- })
- .catch(() => {
- if (ctx.cancelled) return;
- main.innerHTML = `
- Council Not Found
- Unable to load council list.
- Back to councils
- `;
- });
-
- return el;
-}
-
-async function loadCouncilDetail(
- main: HTMLElement,
- council: CouncilConfig,
- ctx: { cancelled: boolean },
-): Promise {
- clearQueryErrors();
- const channelData: { id: string; asset: string; supply: bigint }[] = [];
- const allEvents: ContractEvent[] = [];
-
- const promises: Promise[] = [];
-
- for (const ch of council.channels) {
- promises.push(
- getChannelSupply(ch.privacyChannelId).then((supply) => {
- channelData.push({
- id: ch.privacyChannelId,
- asset: ch.assetCode,
- supply,
- });
- }),
- );
- }
-
- promises.push(
- getContractEvents(council.channelAuthId, undefined, 100).then((events) => {
- allEvents.push(...events);
- }),
- );
-
- for (const ch of council.channels) {
- promises.push(
- getContractEvents(ch.privacyChannelId, undefined, 100).then((events) => {
- allEvents.push(...events);
- }),
- );
- }
-
- await Promise.allSettled(promises);
- if (ctx.cancelled) return;
-
- allEvents.sort((a, b) => b.ledger - a.ledger);
-
- const content = main.querySelector("#council-detail-content");
- if (!content) return;
-
- const totalSupply = channelData.reduce((sum, c) => sum + c.supply, 0n);
- const txEvents = allEvents.filter((e) =>
- !["ProviderAdded", "ProviderRemoved", "ContractInitialized"].includes(
- e.type,
- )
- );
-
- // Derive active providers using chronological event processing
- const authEvents = allEvents.filter((e) =>
- e.contractId === council.channelAuthId &&
- (e.type === "ProviderAdded" || e.type === "ProviderRemoved")
- );
- authEvents.sort((a, b) => a.ledger - b.ledger); // chronological order
- const activeProviders = countProvidersFromEvents(authEvents);
-
- const hasErrors = queryErrors.length > 0;
-
- content.innerHTML = `
- ${
- hasErrors
- ? `Some data may be incomplete — network queries failed.
`
- : ""
- }
-
-
-
- ${channelData.length}
- Channels
-
-
- ${activeProviders.length}
- Providers (recent)
-
-
- ${formatAmount(totalSupply)}
- Total Supply
-
-
- ${txEvents.length}
- Recent Txns
-
-
-
- Channels
-
-
-
- | Channel ID |
- Asset |
- Supply |
-
-
-
- ${
- channelData.map((ch) => `
-
- | ${truncateAddress(ch.id)} |
- ${escapeHtml(ch.asset)} |
- ${formatAmount(ch.supply)} |
-
- `).join("")
- }
-
-
-
- ${
- activeProviders.length > 0
- ? `
- Registered Providers
-
-
-
- | Provider Address |
-
-
-
- ${
- activeProviders.map((p) => `
-
- | ${escapeHtml(p)} |
-
- `).join("")
- }
-
-
- `
- : `
- Registered Providers
- No providers discovered from recent on-chain events.
- `
- }
-
- Recent Activity
- ${
- allEvents.length > 0
- ? `
-
- ${
- allEvents.slice(0, 50).map((e) => `
-
-
-
${
- truncateAddress(e.contractId)
- }
-
- `).join("")
- }
-
- `
- : `
- No recent activity found.
- `
- }
- `;
-}
diff --git a/src/views/council-details.ts b/src/views/council-details.ts
new file mode 100644
index 0000000..80bd67b
--- /dev/null
+++ b/src/views/council-details.ts
@@ -0,0 +1,235 @@
+import type {
+ CouncilRollingMetrics,
+ CouncilTopologyEntry,
+} from "../lib/network-events.ts";
+import { truncateAddress } from "../lib/dom.ts";
+import { formatStroops } from "../lib/format.ts";
+
+/**
+ * §3 — Council details. Collapsed by default with a "click a council
+ * node for details" hint. When a council is selected, paints:
+ * - Council name + deployed-age (when available)
+ * - Jurisdiction
+ * - Channels count + total supply (sum of deposit minus settlement
+ * volume across council channels in the trailing hour)
+ * - Member PPs with full pubkey list
+ * - Assets registered (count + SAC symbols)
+ * - Recent activity (bundles last hour, rolling rate, deposit + settlement
+ * volume)
+ *
+ * The sketch deliberately omits a channels table — channels = assets, so
+ * the asset list covers it.
+ */
+
+const EMPTY_METRICS: CouncilRollingMetrics = {
+ bundlesLastHour: 0,
+ eventsLastHour: 0,
+ ratePerMin: 0,
+ depositVolumeStroops: "0",
+ settlementVolumeStroops: "0",
+};
+
+export type PpClickHandler = (publicKey: string) => void;
+
+export class CouncilDetails {
+ private root: HTMLElement;
+ private hint: HTMLElement;
+ private panel: HTMLElement;
+ private councilsById = new Map();
+ private rolling: Record = {};
+ private selectedCouncilId: string | null = null;
+ private onPpClick: PpClickHandler | null = null;
+
+ constructor() {
+ this.root = document.createElement("section");
+ this.root.className = "section council-details";
+
+ this.hint = document.createElement("div");
+ this.hint.className = "council-details-hint";
+ this.hint.textContent = "Click a council node for details.";
+
+ this.panel = document.createElement("div");
+ this.panel.className = "council-details-panel hidden";
+
+ this.root.append(this.hint, this.panel);
+ }
+
+ element(): HTMLElement {
+ return this.root;
+ }
+
+ setTopology(topology: CouncilTopologyEntry[]): void {
+ this.councilsById.clear();
+ for (const c of topology) this.councilsById.set(c.id, c);
+ if (
+ this.selectedCouncilId && !this.councilsById.has(this.selectedCouncilId)
+ ) {
+ this.clear();
+ return;
+ }
+ if (this.selectedCouncilId) this.paint();
+ }
+
+ setRollingMetrics(rolling: Record): void {
+ this.rolling = rolling;
+ if (this.selectedCouncilId) this.paint();
+ }
+
+ setOnPpClick(handler: PpClickHandler | null): void {
+ this.onPpClick = handler;
+ }
+
+ select(councilId: string): void {
+ if (!this.councilsById.has(councilId)) return;
+ this.selectedCouncilId = councilId;
+ this.paint();
+ }
+
+ clear(): void {
+ this.selectedCouncilId = null;
+ this.panel.classList.add("hidden");
+ this.hint.classList.remove("hidden");
+ this.panel.textContent = "";
+ }
+
+ // ── internals ──────────────────────────────────────────────────────
+
+ private paint(): void {
+ if (!this.selectedCouncilId) return;
+ const council = this.councilsById.get(this.selectedCouncilId);
+ if (!council) {
+ this.clear();
+ return;
+ }
+ const metrics = this.rolling[council.id] ?? EMPTY_METRICS;
+
+ this.panel.textContent = "";
+ this.panel.classList.remove("hidden");
+ this.hint.classList.add("hidden");
+
+ const headerRow = document.createElement("header");
+ headerRow.className = "council-details-header";
+ const nameEl = document.createElement("h2");
+ nameEl.className = "council-details-name";
+ nameEl.textContent = council.name ??
+ `Council ${truncateAddress(council.id)}`;
+ headerRow.appendChild(nameEl);
+ if (council.jurisdictions.length > 0) {
+ const j = document.createElement("span");
+ j.className = "council-details-jurisdiction";
+ j.textContent = council.jurisdictions.join(" · ");
+ headerRow.appendChild(j);
+ }
+ this.panel.appendChild(headerRow);
+
+ const grid = document.createElement("div");
+ grid.className = "council-details-grid";
+
+ const channelCount = council.channels.length;
+ const totalSupply = formatStroops(
+ this.computeTotalSupply(metrics),
+ );
+
+ grid.appendChild(
+ kvBlock("Channels", String(channelCount)),
+ );
+ grid.appendChild(
+ kvBlock("Net supply (1h)", totalSupply, "deposits − settlements"),
+ );
+ grid.appendChild(
+ kvBlock(
+ "Member PPs",
+ String(council.providers.length),
+ ),
+ );
+ grid.appendChild(
+ kvBlock(
+ "Assets",
+ String(
+ new Set(
+ council.channels
+ .map((c) => c.assetContractId)
+ .filter((id): id is string => !!id),
+ ).size,
+ ),
+ council.channels.map((c) => c.assetCode).join(", "),
+ ),
+ );
+ grid.appendChild(
+ kvBlock("Bundles (1h)", String(metrics.bundlesLastHour)),
+ );
+ grid.appendChild(
+ kvBlock("Events / min", metrics.ratePerMin.toFixed(1)),
+ );
+ grid.appendChild(
+ kvBlock(
+ "Deposits (1h)",
+ formatStroops(metrics.depositVolumeStroops),
+ ),
+ );
+ grid.appendChild(
+ kvBlock(
+ "Settlements (1h)",
+ formatStroops(metrics.settlementVolumeStroops),
+ ),
+ );
+ this.panel.appendChild(grid);
+
+ if (council.providers.length > 0) {
+ const ppHeader = document.createElement("h3");
+ ppHeader.className = "council-details-subheader";
+ ppHeader.textContent = "Member PPs";
+ this.panel.appendChild(ppHeader);
+
+ const ppList = document.createElement("ul");
+ ppList.className = "council-details-pp-list";
+ for (const pp of council.providers) {
+ const li = document.createElement("li");
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "council-details-pp-button";
+ btn.textContent = pp.label?.trim() || pp.publicKey;
+ btn.addEventListener("click", () => this.onPpClick?.(pp.publicKey));
+ li.appendChild(btn);
+ ppList.appendChild(li);
+ }
+ this.panel.appendChild(ppList);
+ }
+ }
+
+ /**
+ * Net supply over the last hour: deposits − settlements. The sketch
+ * asks for "Total supply: N" across the council's channels; the backend
+ * doesn't read live SAC balances (no application chain reads beyond
+ * watcher events), so the best proxy is the trailing-hour net flow.
+ * Labelled "Net supply (1h)" to be honest about the window.
+ */
+ private computeTotalSupply(metrics: CouncilRollingMetrics): string {
+ try {
+ const deposits = BigInt(metrics.depositVolumeStroops || "0");
+ const settlements = BigInt(metrics.settlementVolumeStroops || "0");
+ return (deposits - settlements).toString();
+ } catch {
+ return "0";
+ }
+ }
+}
+
+function kvBlock(label: string, value: string, sub?: string): HTMLElement {
+ const block = document.createElement("div");
+ block.className = "kv-block";
+ const labelEl = document.createElement("span");
+ labelEl.className = "kv-label";
+ labelEl.textContent = label;
+ const valueEl = document.createElement("span");
+ valueEl.className = "kv-value";
+ valueEl.textContent = value;
+ block.append(labelEl, valueEl);
+ if (sub) {
+ const subEl = document.createElement("span");
+ subEl.className = "kv-sub";
+ subEl.textContent = sub;
+ block.appendChild(subEl);
+ }
+ return block;
+}
diff --git a/src/views/councils.ts b/src/views/councils.ts
deleted file mode 100644
index 94ec22e..0000000
--- a/src/views/councils.ts
+++ /dev/null
@@ -1,208 +0,0 @@
-/**
- * Council list view — all registered councils with on-chain state.
- */
-import { getNav } from "../lib/nav.ts";
-import { getCouncils } from "../lib/config.ts";
-import {
- clearQueryErrors,
- countProvidersFromEvents,
- getChannelSupply,
- getContractEvents,
- queryErrors,
-} from "../lib/stellar.ts";
-import { escapeHtml, formatAmount, truncateAddress } from "../lib/dom.ts";
-import { getCountryName } from "../lib/world-map.ts";
-import { onCleanup } from "../lib/router.ts";
-
-interface CouncilState {
- name: string;
- channelAuthId: string;
- jurisdictions: string[];
- website?: string;
- channels: {
- privacyChannelId: string;
- assetCode: string;
- supply: bigint;
- }[];
- providerCount: number;
- loading: boolean;
-}
-
-// deno-lint-ignore require-await -- view fn satisfies router's Promise contract
-export async function councilsView(): Promise {
- const el = document.createElement("div");
- el.appendChild(getNav());
-
- const main = document.createElement("main");
- main.className = "container";
- main.innerHTML = `
- Councils
- All registered councils and their on-chain state.
-
- `;
- el.appendChild(main);
-
- const ctx = { cancelled: false };
- onCleanup(() => {
- ctx.cancelled = true;
- });
-
- loadCouncilData(main, ctx).catch(() => {});
-
- return el;
-}
-
-async function loadCouncilData(
- main: HTMLElement,
- ctx: { cancelled: boolean },
-): Promise {
- clearQueryErrors();
- const councils = await getCouncils();
- if (ctx.cancelled) return;
-
- const states: CouncilState[] = councils.map((council) => ({
- name: council.name,
- channelAuthId: council.channelAuthId,
- jurisdictions: council.jurisdictions,
- website: council.website,
- channels: [],
- providerCount: 0,
- loading: true,
- }));
-
- renderCouncilTable(main, states);
-
- const promises: Promise[] = [];
-
- for (const state of states) {
- const council = councils.find((c) =>
- c.channelAuthId === state.channelAuthId
- )!;
-
- for (const ch of council.channels) {
- promises.push(
- getChannelSupply(ch.privacyChannelId).then((supply) => {
- state.channels.push({
- privacyChannelId: ch.privacyChannelId,
- assetCode: ch.assetCode,
- supply,
- });
- }),
- );
- }
-
- promises.push(
- getContractEvents(state.channelAuthId).then((events) => {
- state.providerCount = countProvidersFromEvents(events).length;
- }),
- );
- }
-
- await Promise.allSettled(promises);
-
- if (ctx.cancelled) return;
-
- for (const state of states) {
- state.loading = false;
- }
- renderCouncilTable(main, states);
-}
-
-function renderCouncilTable(main: HTMLElement, states: CouncilState[]): void {
- const content = main.querySelector("#councils-content");
- if (!content) return;
-
- if (states.length === 0) {
- content.innerHTML =
- `No councils registered yet.
`;
- return;
- }
-
- const totalChannels = states.reduce((sum, s) => sum + s.channels.length, 0);
- const totalProviders = states.reduce((sum, s) => sum + s.providerCount, 0);
- const totalSupply = states.reduce(
- (sum, s) => sum + s.channels.reduce((cs, c) => cs + c.supply, 0n),
- 0n,
- );
-
- const hasErrors = queryErrors.length > 0;
-
- content.innerHTML = `
- ${
- hasErrors
- ? `Some data may be incomplete — network queries failed. (${queryErrors.length} error${
- queryErrors.length !== 1 ? "s" : ""
- })
`
- : ""
- }
-
-
-
- ${states.length}
- Councils
-
-
- ${totalChannels}
- Channels
-
-
- ${totalProviders}
- Providers (recent)
-
-
- ${formatAmount(totalSupply)}
- Total Supply
-
-
-
-
-
-
- | Council |
- Jurisdiction |
- Channels |
- Providers |
- Total Supply |
- Status |
-
-
-
- ${
- states.map((s) => {
- const supply = s.channels.reduce((sum, c) => sum + c.supply, 0n);
- return `
-
- |
- ${escapeHtml(s.name)}
- ${
- truncateAddress(s.channelAuthId)
- }
- |
- ${
- s.jurisdictions.map((j) => escapeHtml(getCountryName(j))).join(", ")
- } |
- ${s.channels.length} |
- ${
- s.loading ? '...' : s.providerCount
- } |
- ${
- s.loading ? '...' : formatAmount(supply)
- } |
- Active |
-
- `;
- }).join("")
- }
-
-
- `;
-
- content.querySelectorAll(".clickable-row").forEach((row) => {
- row.addEventListener("click", () => {
- const href = row.getAttribute("data-href");
- if (href) globalThis.location.hash = href;
- });
- });
-}
diff --git a/src/views/counter-strip.ts b/src/views/counter-strip.ts
new file mode 100644
index 0000000..4a67ed4
--- /dev/null
+++ b/src/views/counter-strip.ts
@@ -0,0 +1,93 @@
+import type { Counters } from "../lib/network-events.ts";
+import { formatCount, formatLatencyMs } from "../lib/format.ts";
+
+/**
+ * §1 — Counter strip. Six full-width tiles. Order matches the locked
+ * sketch and the SnapshotFrame.counters field order.
+ */
+
+type TileKey = keyof Counters;
+
+const TILE_ORDER: Array<{ key: TileKey; label: string }> = [
+ { key: "councils", label: "COUNCILS" },
+ { key: "activePPs", label: "ACTIVE PPs" },
+ { key: "eventsLast24h", label: "EVENTS / 24H" },
+ { key: "assetsRegistered", label: "ASSETS" },
+ { key: "throughputPerMin", label: "THROUGHPUT" },
+ { key: "latencyMs", label: "LATENCY" },
+];
+
+const ZERO_COUNTERS: Counters = {
+ councils: 0,
+ activePPs: 0,
+ eventsLast24h: 0,
+ assetsRegistered: 0,
+ throughputPerMin: 0,
+ latencyMs: null,
+};
+
+export class CounterStrip {
+ private root: HTMLElement;
+ private tiles = new Map();
+ private state: Counters = { ...ZERO_COUNTERS };
+
+ constructor() {
+ this.root = document.createElement("section");
+ this.root.className = "section counter-strip";
+ this.root.setAttribute("aria-label", "Network counters");
+ for (const { key, label } of TILE_ORDER) {
+ const tile = document.createElement("article");
+ tile.className = "counter-tile";
+ tile.dataset.counter = String(key);
+
+ const labelEl = document.createElement("div");
+ labelEl.className = "counter-label";
+ labelEl.textContent = label;
+
+ const valueEl = document.createElement("div");
+ valueEl.className = "counter-value";
+ valueEl.textContent = "—";
+
+ tile.append(labelEl, valueEl);
+ this.tiles.set(key, valueEl);
+ this.root.appendChild(tile);
+ }
+ this.paint();
+ }
+
+ element(): HTMLElement {
+ return this.root;
+ }
+
+ render(counters: Counters): void {
+ this.state = { ...counters };
+ this.paint();
+ }
+
+ /**
+ * Per-event ticker for EVENTS / 24H. The snapshot is the source of
+ * truth; this gives the user a "something happened" tick between
+ * snapshot updates so the layout doesn't feel frozen.
+ */
+ bumpFromLiveEvent(): void {
+ this.state = {
+ ...this.state,
+ eventsLast24h: this.state.eventsLast24h + 1,
+ };
+ const el = this.tiles.get("eventsLast24h");
+ if (el) el.textContent = formatCount(this.state.eventsLast24h);
+ }
+
+ private paint(): void {
+ for (const { key } of TILE_ORDER) {
+ const el = this.tiles.get(key);
+ if (!el) continue;
+ el.textContent = this.formatValue(key, this.state);
+ }
+ }
+
+ private formatValue(key: TileKey, counters: Counters): string {
+ if (key === "latencyMs") return formatLatencyMs(counters.latencyMs);
+ return formatCount(counters[key] as number);
+ }
+}
diff --git a/src/views/country-details.ts b/src/views/country-details.ts
new file mode 100644
index 0000000..78309a9
--- /dev/null
+++ b/src/views/country-details.ts
@@ -0,0 +1,130 @@
+import { COUNTRIES } from "@moonlight/ui/world-map";
+import type { CouncilTopologyEntry } from "../lib/network-events.ts";
+
+/**
+ * Right-rail panel paired with §5's world map. Clicking a country on the
+ * map updates this panel with the list of councils whose jurisdictions
+ * include that country: name, providers, channels, and the full
+ * jurisdictions list each council operates in.
+ *
+ * Defaults to a "click a country" hint while no country is selected.
+ */
+export type CouncilClickHandler = (councilId: string) => void;
+
+export class CountryDetails {
+ private root: HTMLElement;
+ private hint: HTMLElement;
+ private panel: HTMLElement;
+ private councils: CouncilTopologyEntry[] = [];
+ private selectedCountry: string | null = null;
+ private onCouncilClick: CouncilClickHandler | null = null;
+
+ constructor() {
+ this.root = document.createElement("section");
+ this.root.className = "section country-details";
+ this.root.setAttribute("aria-label", "Country council details");
+
+ this.hint = document.createElement("div");
+ this.hint.className = "country-details-hint";
+ this.hint.textContent = "Click a country on the map to see its councils.";
+
+ this.panel = document.createElement("div");
+ this.panel.className = "country-details-panel hidden";
+
+ this.root.append(this.hint, this.panel);
+ }
+
+ element(): HTMLElement {
+ return this.root;
+ }
+
+ setTopology(topology: CouncilTopologyEntry[]): void {
+ this.councils = topology;
+ if (this.selectedCountry) this.paint();
+ }
+
+ setOnCouncilClick(handler: CouncilClickHandler | null): void {
+ this.onCouncilClick = handler;
+ }
+
+ select(code: string): void {
+ this.selectedCountry = code.toUpperCase();
+ this.paint();
+ }
+
+ clear(): void {
+ this.selectedCountry = null;
+ this.panel.classList.add("hidden");
+ this.hint.classList.remove("hidden");
+ this.panel.textContent = "";
+ }
+
+ private paint(): void {
+ if (!this.selectedCountry) return;
+ const code = this.selectedCountry;
+ const match = this.councils.filter((c) =>
+ c.jurisdictions.some((j) => j.toUpperCase() === code)
+ );
+
+ this.panel.textContent = "";
+ this.panel.classList.remove("hidden");
+ this.hint.classList.add("hidden");
+
+ const header = document.createElement("header");
+ header.className = "country-details-header";
+ const title = document.createElement("h2");
+ title.className = "country-details-title";
+ title.textContent = COUNTRIES[code]?.name ?? code;
+ const codeBadge = document.createElement("span");
+ codeBadge.className = "country-details-code";
+ codeBadge.textContent = code;
+ header.append(title, codeBadge);
+ this.panel.appendChild(header);
+
+ if (match.length === 0) {
+ const empty = document.createElement("div");
+ empty.className = "country-details-empty";
+ empty.textContent = "No councils operate in this jurisdiction.";
+ this.panel.appendChild(empty);
+ return;
+ }
+
+ const list = document.createElement("ul");
+ list.className = "country-details-list";
+ for (const c of match) {
+ const li = document.createElement("li");
+ const btn = document.createElement("button");
+ btn.type = "button";
+ btn.className = "country-details-council";
+ btn.addEventListener("click", () => this.onCouncilClick?.(c.id));
+
+ const name = document.createElement("div");
+ name.className = "country-details-council-name";
+ name.textContent = c.name ?? c.id.slice(0, 12);
+ btn.appendChild(name);
+
+ const meta = document.createElement("div");
+ meta.className = "country-details-council-meta";
+ const ppCount = c.providers.length;
+ const chCount = c.channels.length;
+ const ppText = `${ppCount} PP${ppCount === 1 ? "" : "s"}`;
+ const chText = `${chCount} channel${chCount === 1 ? "" : "s"}`;
+ meta.textContent = `${ppText} · ${chText}`;
+ btn.appendChild(meta);
+
+ if (c.jurisdictions.length > 1) {
+ const juris = document.createElement("div");
+ juris.className = "country-details-council-juris";
+ const others = c.jurisdictions
+ .map((j) => j.toUpperCase())
+ .filter((j) => j !== code);
+ juris.textContent = `Also: ${others.join(", ")}`;
+ btn.appendChild(juris);
+ }
+
+ li.appendChild(btn);
+ list.appendChild(li);
+ }
+ this.panel.appendChild(list);
+ }
+}
diff --git a/src/views/map.ts b/src/views/map.ts
deleted file mode 100644
index c8c5a04..0000000
--- a/src/views/map.ts
+++ /dev/null
@@ -1,156 +0,0 @@
-/**
- * Map view — world map with councils plotted by jurisdiction.
- * Uses a static SVG map (simple-world-map, CC BY-SA 3.0).
- */
-import { getNav } from "../lib/nav.ts";
-import { type CouncilConfig, getCouncils } from "../lib/config.ts";
-import {
- fetchWorldSvg,
- getCountryName,
- projectCountry,
-} from "../lib/world-map.ts";
-import { escapeHtml, truncateAddress } from "../lib/dom.ts";
-import { onCleanup } from "../lib/router.ts";
-
-export async function mapView(): Promise {
- const el = document.createElement("div");
- el.appendChild(getNav());
-
- const main = document.createElement("main");
- main.className = "container";
- main.innerHTML = `
- Network Map
- Councils by declared jurisdiction. Dot size reflects number of channels.
-
- Councils by Jurisdiction
-
- `;
- el.appendChild(main);
-
- const ctx = { cancelled: false };
- onCleanup(() => {
- ctx.cancelled = true;
- });
-
- const councils = await getCouncils();
- if (ctx.cancelled) return el;
-
- const grid = main.querySelector(".council-grid");
- if (grid) {
- grid.innerHTML = councils.length === 0
- ? `No councils registered yet.
`
- : councils.map((c) => `
-
-
-
- ${
- c.jurisdictions.map((j) => escapeHtml(getCountryName(j))).join(", ")
- }
-
- ${
- truncateAddress(c.channelAuthId)
- }
-
- `).join("");
- }
-
- try {
- const svgText = await fetchWorldSvg();
- if (ctx.cancelled) return el;
-
- const mapContainer = main.querySelector(".map-container")!;
-
- // Parse the SVG to extract paths, then wrap in our styled SVG
- const parser = new DOMParser();
- const svgDoc = parser.parseFromString(svgText, "image/svg+xml");
- const origSvg = svgDoc.querySelector("svg");
- if (!origSvg) throw new Error("Invalid SVG");
-
- const viewBox = origSvg.getAttribute("viewBox") || "0 0 800 500";
-
- // Extract all path elements
- const pathElements = svgDoc.querySelectorAll("path");
- const pathStrings: string[] = [];
- pathElements.forEach((p) => {
- const d = p.getAttribute("d");
- if (d) pathStrings.push(d);
- });
-
- const dots = buildCouncilMarkers(councils);
-
- mapContainer.innerHTML = `
-
- `;
- } catch (err) {
- console.warn("[map] Failed to load world map:", err);
- if (!ctx.cancelled) {
- const mapContainer = main.querySelector(".map-container")!;
- mapContainer.innerHTML =
- `Failed to load map. Please try again later.
`;
- }
- }
-
- return el;
-}
-
-function buildCouncilMarkers(councils: CouncilConfig[]): string {
- const markers: string[] = [];
-
- for (const council of councils) {
- for (const code of council.jurisdictions) {
- const pos = projectCountry(code, 0, 0);
- if (!pos) continue;
-
- const channels = council.channels.length;
- const r = Math.min(4 + channels * 2, 10);
-
- markers.push(
- ``,
- );
- markers.push(
- ``,
- );
- markers.push(
- `` +
- `${escapeHtml(council.name)} — ${
- escapeHtml(getCountryName(code))
- }`,
- );
- markers.push(
- `${escapeHtml(council.name)}`,
- );
- }
- }
-
- return markers.join("\n ");
-}
diff --git a/src/views/provider-details.ts b/src/views/provider-details.ts
new file mode 100644
index 0000000..a9faee2
--- /dev/null
+++ b/src/views/provider-details.ts
@@ -0,0 +1,156 @@
+import { truncateAddress } from "../lib/dom.ts";
+import type {
+ CouncilTopologyEntry,
+ NetworkEvent,
+} from "../lib/network-events.ts";
+
+/**
+ * Provider info column in the §3 detail trio. Sibling of CountryDetails
+ * and CouncilDetails — collapsed-with-hint until a PP is selected, then
+ * shows that PP's full pubkey, label, the set of councils they're a
+ * member of, and a recent-bundle count derived from the activity ring
+ * buffer.
+ *
+ * Drilldown path: country click → country-details lists councils →
+ * council click → council-details lists PPs → PP click → this panel.
+ */
+export class ProviderDetails {
+ private root: HTMLElement;
+ private hint: HTMLElement;
+ private panel: HTMLElement;
+ private topology: CouncilTopologyEntry[] = [];
+ private recent: NetworkEvent[] = [];
+ private selectedPubKey: string | null = null;
+
+ constructor() {
+ this.root = document.createElement("section");
+ this.root.className = "section provider-details";
+ this.root.setAttribute("aria-label", "Provider details");
+
+ this.hint = document.createElement("div");
+ this.hint.className = "provider-details-hint";
+ this.hint.textContent = "Click a PP to see details.";
+
+ this.panel = document.createElement("div");
+ this.panel.className = "provider-details-panel hidden";
+
+ this.root.append(this.hint, this.panel);
+ }
+
+ element(): HTMLElement {
+ return this.root;
+ }
+
+ setTopology(topology: CouncilTopologyEntry[]): void {
+ this.topology = topology;
+ if (this.selectedPubKey) this.paint();
+ }
+
+ setRecent(events: NetworkEvent[]): void {
+ this.recent = events;
+ if (this.selectedPubKey) this.paint();
+ }
+
+ select(pubKey: string): void {
+ this.selectedPubKey = pubKey;
+ this.paint();
+ }
+
+ clear(): void {
+ this.selectedPubKey = null;
+ this.panel.classList.add("hidden");
+ this.hint.classList.remove("hidden");
+ this.panel.textContent = "";
+ }
+
+ private paint(): void {
+ if (!this.selectedPubKey) return;
+ const pubKey = this.selectedPubKey;
+
+ // Council memberships — derived from the topology each snapshot.
+ const councils = this.topology.filter((c) =>
+ c.providers.some((p) => p.publicKey === pubKey)
+ );
+ if (councils.length === 0) {
+ // PP no longer in any council (just removed) — clear panel.
+ this.clear();
+ return;
+ }
+
+ const label = councils
+ .flatMap((c) => c.providers)
+ .find((p) => p.publicKey === pubKey)?.label?.trim() ?? "";
+
+ const bundleCount = this.recent.reduce((n, ev) => {
+ if (ev.kind !== "channel_bundle") return n;
+ const p = ev.payload;
+ return typeof p.providerPublicKey === "string" &&
+ p.providerPublicKey === pubKey
+ ? n + 1
+ : n;
+ }, 0);
+
+ this.panel.textContent = "";
+ this.panel.classList.remove("hidden");
+ this.hint.classList.add("hidden");
+
+ const header = document.createElement("header");
+ header.className = "provider-details-header";
+ const title = document.createElement("h2");
+ title.className = "provider-details-title";
+ title.textContent = label || truncateAddress(pubKey);
+ header.appendChild(title);
+ this.panel.appendChild(header);
+
+ const grid = document.createElement("div");
+ grid.className = "provider-details-grid";
+ grid.appendChild(kvBlock("Councils", String(councils.length)));
+ grid.appendChild(kvBlock("Bundles (recent)", String(bundleCount)));
+ this.panel.appendChild(grid);
+
+ const pkHeader = document.createElement("div");
+ pkHeader.className = "provider-details-subheader";
+ pkHeader.textContent = "Public key";
+ this.panel.appendChild(pkHeader);
+
+ const pk = document.createElement("div");
+ pk.className = "provider-details-pubkey";
+ pk.textContent = pubKey;
+ this.panel.appendChild(pk);
+
+ const cHeader = document.createElement("div");
+ cHeader.className = "provider-details-subheader";
+ cHeader.textContent = "Member of";
+ this.panel.appendChild(cHeader);
+
+ const list = document.createElement("ul");
+ list.className = "provider-details-council-list";
+ for (const c of councils) {
+ const li = document.createElement("li");
+ const name = document.createElement("span");
+ name.className = "provider-details-council-name";
+ name.textContent = c.name?.trim() || truncateAddress(c.id);
+ const juris = document.createElement("span");
+ juris.className = "provider-details-council-juris";
+ juris.textContent = c.jurisdictions.length > 0
+ ? c.jurisdictions.map((j) => j.toUpperCase()).join(", ")
+ : "—";
+ li.append(name, juris);
+ list.appendChild(li);
+ }
+ this.panel.appendChild(list);
+ }
+}
+
+function kvBlock(label: string, value: string): HTMLElement {
+ const block = document.createElement("div");
+ block.className = "provider-details-kv";
+ const l = document.createElement("span");
+ l.className = "provider-details-kv-label";
+ l.textContent = label;
+ const v = document.createElement("span");
+ v.className = "provider-details-kv-value";
+ v.textContent = value;
+ block.append(l, v);
+ return block;
+}
diff --git a/src/views/sparklines.ts b/src/views/sparklines.ts
new file mode 100644
index 0000000..8b32e81
--- /dev/null
+++ b/src/views/sparklines.ts
@@ -0,0 +1,170 @@
+import type { Sparklines } from "../lib/network-events.ts";
+
+/**
+ * §4 (left) — Three sparkline charts. 60 buckets per series, one bucket
+ * per minute (oldest left, newest right).
+ *
+ * SVG polyline rendered per series. The Y-axis scales independently per
+ * chart so a tiny volume series doesn't squash the throughput chart.
+ */
+
+const SVG_NS = "http://www.w3.org/2000/svg";
+const CHART_W = 320;
+const CHART_H = 80;
+const PAD_X = 8;
+const PAD_Y = 10;
+
+type Series = {
+ key: keyof Sparklines;
+ title: string;
+ unit: string;
+ stroke: string;
+};
+
+const SERIES: Series[] = [
+ {
+ key: "throughput",
+ title: "Throughput",
+ unit: "events/min",
+ stroke: "#1c7ed6",
+ },
+ { key: "latency", title: "Latency", unit: "ms (avg)", stroke: "#9775fa" },
+ { key: "volume", title: "Volume", unit: "asset units", stroke: "#2f9e44" },
+];
+
+export class SparklineGroup {
+ private root: HTMLElement;
+ private charts = new Map();
+
+ constructor() {
+ this.root = document.createElement("section");
+ this.root.className = "section sparklines";
+ this.root.setAttribute("aria-label", "60-minute rolling sparklines");
+
+ for (const s of SERIES) {
+ const card = document.createElement("article");
+ card.className = "sparkline-card";
+
+ const header = document.createElement("header");
+ header.className = "sparkline-header";
+ const title = document.createElement("span");
+ title.className = "sparkline-title";
+ title.textContent = s.title;
+ const value = document.createElement("span");
+ value.className = "sparkline-value";
+ value.textContent = "—";
+ header.append(title, value);
+
+ const svg = document.createElementNS(SVG_NS, "svg");
+ svg.setAttribute("viewBox", `0 0 ${CHART_W} ${CHART_H}`);
+ svg.setAttribute("preserveAspectRatio", "none");
+ svg.classList.add("sparkline-svg");
+
+ const baseline = document.createElementNS(SVG_NS, "line");
+ baseline.setAttribute("x1", String(PAD_X));
+ baseline.setAttribute("y1", String(CHART_H - PAD_Y));
+ baseline.setAttribute("x2", String(CHART_W - PAD_X));
+ baseline.setAttribute("y2", String(CHART_H - PAD_Y));
+ baseline.setAttribute("stroke", "#dee2e6");
+ baseline.setAttribute("stroke-width", "1");
+
+ const line = document.createElementNS(SVG_NS, "polyline");
+ line.setAttribute("fill", "none");
+ line.setAttribute("stroke", s.stroke);
+ line.setAttribute("stroke-width", "2");
+ line.setAttribute("stroke-linejoin", "round");
+ line.setAttribute("stroke-linecap", "round");
+
+ svg.append(baseline, line);
+
+ const unit = document.createElement("span");
+ unit.className = "sparkline-unit";
+ unit.textContent = s.unit;
+
+ card.append(header, svg, unit);
+ this.root.appendChild(card);
+ this.charts.set(s.key, { svg, line, valueEl: value });
+ }
+ }
+
+ element(): HTMLElement {
+ return this.root;
+ }
+
+ render(sparklines: Sparklines): void {
+ this.paintSeries("throughput", sparklines.throughput);
+ this.paintLatency(sparklines.latency);
+ this.paintSeries("volume", sparklines.volume);
+ }
+
+ private paintSeries(
+ key: "throughput" | "volume",
+ series: number[],
+ ): void {
+ const chart = this.charts.get(key);
+ if (!chart) return;
+ if (series.length === 0) {
+ chart.line.setAttribute("points", "");
+ chart.valueEl.textContent = "—";
+ return;
+ }
+ const max = Math.max(...series, 1);
+ const points = series.map((v, i) => {
+ const x = projectX(i, series.length);
+ const y = projectY(v, max);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(" ");
+ chart.line.setAttribute("points", points);
+ const latest = series[series.length - 1];
+ chart.valueEl.textContent = formatTrend(latest);
+ }
+
+ private paintLatency(series: Array): void {
+ const chart = this.charts.get("latency");
+ if (!chart) return;
+ if (series.length === 0) {
+ chart.line.setAttribute("points", "");
+ chart.valueEl.textContent = "—";
+ return;
+ }
+ const numeric = series.filter((v): v is number => v !== null);
+ const max = Math.max(...numeric, 1);
+ // Bridge nulls with the last seen value so the line stays continuous;
+ // if there's no seen value yet, anchor at zero so the chart doesn't
+ // start with a spike.
+ let lastSeen = 0;
+ const points = series.map((v, i) => {
+ if (v !== null) lastSeen = v;
+ const x = projectX(i, series.length);
+ const y = projectY(lastSeen, max);
+ return `${x.toFixed(1)},${y.toFixed(1)}`;
+ }).join(" ");
+ chart.line.setAttribute("points", points);
+ const latest = numeric.length > 0 ? numeric[numeric.length - 1] : null;
+ chart.valueEl.textContent = latest === null ? "—" : `${latest} ms`;
+ }
+}
+
+function projectX(i: number, count: number): number {
+ if (count <= 1) return CHART_W / 2;
+ const span = CHART_W - PAD_X * 2;
+ return PAD_X + (span * i) / (count - 1);
+}
+
+function projectY(value: number, max: number): number {
+ if (!Number.isFinite(value) || value < 0) value = 0;
+ if (max <= 0) max = 1;
+ const span = CHART_H - PAD_Y * 2;
+ return CHART_H - PAD_Y - (span * Math.min(value, max)) / max;
+}
+
+function formatTrend(value: number): string {
+ if (!Number.isFinite(value)) return "—";
+ if (Math.abs(value) >= 1000) return value.toFixed(0);
+ if (Math.abs(value) >= 10) return value.toFixed(1);
+ return value.toFixed(2);
+}
diff --git a/src/views/transactions.ts b/src/views/transactions.ts
deleted file mode 100644
index 81fc628..0000000
--- a/src/views/transactions.ts
+++ /dev/null
@@ -1,203 +0,0 @@
-/**
- * Transaction feed — recent transactions across all channels.
- */
-import { getNav } from "../lib/nav.ts";
-import { getCouncils } from "../lib/config.ts";
-import {
- clearQueryErrors,
- getContractEvents,
- queryErrors,
-} from "../lib/stellar.ts";
-import { escapeHtml, timeAgo, truncateAddress } from "../lib/dom.ts";
-import { onCleanup } from "../lib/router.ts";
-import type { ContractEvent } from "../lib/stellar.ts";
-
-interface FeedEntry {
- event: ContractEvent;
- councilName: string;
- channelAsset: string;
- channelId: string;
-}
-
-// deno-lint-ignore require-await -- view fn satisfies router's Promise contract
-export async function transactionsView(): Promise {
- const el = document.createElement("div");
- el.appendChild(getNav());
-
- const main = document.createElement("main");
- main.className = "container";
- main.innerHTML = `
- Transaction Feed
- Recent on-chain activity across all channels.
-
- `;
- el.appendChild(main);
-
- const ctx = { cancelled: false };
- onCleanup(() => {
- ctx.cancelled = true;
- });
-
- loadTransactions(main, ctx).catch(() => {});
-
- return el;
-}
-
-async function loadTransactions(
- main: HTMLElement,
- ctx: { cancelled: boolean },
-): Promise {
- clearQueryErrors();
- const councils = await getCouncils();
- if (ctx.cancelled) return;
-
- const feed: FeedEntry[] = [];
- const promises: Promise[] = [];
-
- for (const council of councils) {
- promises.push(
- getContractEvents(council.channelAuthId, undefined, 50).then((events) => {
- for (const event of events) {
- feed.push({
- event,
- councilName: council.name,
- channelAsset: "\u2014",
- channelId: council.channelAuthId,
- });
- }
- }),
- );
-
- for (const ch of council.channels) {
- promises.push(
- getContractEvents(ch.privacyChannelId, undefined, 50).then((events) => {
- for (const event of events) {
- feed.push({
- event,
- councilName: council.name,
- channelAsset: ch.assetCode,
- channelId: ch.privacyChannelId,
- });
- }
- }),
- );
- }
- }
-
- await Promise.allSettled(promises);
-
- if (ctx.cancelled) return;
-
- feed.sort((a, b) => b.event.ledger - a.event.ledger);
-
- renderFeed(main, feed);
-}
-
-function eventIcon(type: string): string {
- switch (type) {
- case "ProviderAdded":
- return "+PP";
- case "ProviderRemoved":
- return "-PP";
- case "ContractInitialized":
- return "INIT";
- default:
- return "TX";
- }
-}
-
-function eventBadgeClass(type: string): string {
- switch (type) {
- case "ProviderAdded":
- return "badge-active";
- case "ProviderRemoved":
- return "badge-inactive";
- case "ContractInitialized":
- return "badge-pending";
- default:
- return "badge-active";
- }
-}
-
-function renderFeed(main: HTMLElement, feed: FeedEntry[]): void {
- const content = main.querySelector("#tx-content");
- if (!content) return;
-
- if (feed.length === 0) {
- const hasErrors = queryErrors.length > 0;
- content.innerHTML = `
-
-
No recent transactions found.
- ${
- hasErrors
- ? `
Network queries encountered errors. The RPC may be unreachable.
`
- : `
Transactions will appear here as channels process bundles.
`
- }
-
- `;
- return;
- }
-
- const txCount =
- feed.filter((f) =>
- !["ProviderAdded", "ProviderRemoved", "ContractInitialized"].includes(
- f.event.type,
- )
- ).length;
- const providerEvents =
- feed.filter((f) =>
- f.event.type === "ProviderAdded" || f.event.type === "ProviderRemoved"
- ).length;
-
- content.innerHTML = `
-
-
- ${feed.length}
- Total Events
-
-
- ${txCount}
- Transactions
-
-
- ${providerEvents}
- Provider Events
-
-
-
-
- ${
- feed.slice(0, 100).map((entry) => `
-
-
-
- Council: ${
- escapeHtml(entry.councilName)
- }
- ${
- entry.channelAsset !== "\u2014"
- ? `Asset: ${
- escapeHtml(entry.channelAsset)
- }`
- : ""
- }
-
-
${
- truncateAddress(entry.channelId)
- }
-
- `).join("")
- }
-
- `;
-}
diff --git a/src/views/world-map.ts b/src/views/world-map.ts
new file mode 100644
index 0000000..30fe8b3
--- /dev/null
+++ b/src/views/world-map.ts
@@ -0,0 +1,253 @@
+import {
+ COUNTRIES,
+ renderWorldMap,
+ type WorldMapHandle,
+} from "@moonlight/ui/world-map";
+import type { CouncilTopologyEntry } from "../lib/network-events.ts";
+
+/**
+ * §5 — World map. Bottom strip showing council jurisdictions.
+ *
+ * Visual states layered on top of the shared @moonlight/ui/world-map
+ * component (which itself knows nothing about councils):
+ * - `selected`: union of all council jurisdictions in the topology.
+ * - `hovered`: the country currently under the pointer.
+ * - `reachable`: every other country sharing a council with `hovered`.
+ * - `dimmed`: every country not in the active focus set (faded out).
+ *
+ * Hover handling lives entirely on this side; the map component only
+ * fires `onHover(code | null)` and exposes `setSlot()` to apply our
+ * dashboard-specific CSS classes. A small popover next to the cursor
+ * lists every council whose jurisdictions include the hovered country
+ * (name + provider count), or "No councils" when none.
+ */
+
+const HOVERED_SLOT = "hovered";
+const REACHABLE_SLOT = "reachable";
+const DIMMED_SLOT = "dimmed";
+const SELECTED_COUNTRY_SLOT = "active-country";
+
+export type CountryClickHandler = (countryCode: string) => void;
+
+export class WorldMap {
+ private root: HTMLElement;
+ private mapHost: HTMLDivElement;
+ private status: HTMLElement;
+ private popover: HTMLDivElement;
+ private handle: WorldMapHandle | null = null;
+ private pendingMount: Promise | null = null;
+ private councils: CouncilTopologyEntry[] = [];
+ private allCodes: string[] = [];
+ private failed = false;
+ private onCountryClick: CountryClickHandler | null = null;
+ private selectedCountry: string | null = null;
+
+ constructor() {
+ this.root = document.createElement("section");
+ this.root.className = "section world-map";
+ this.root.setAttribute("aria-label", "Council jurisdictions");
+
+ const header = document.createElement("header");
+ header.className = "world-map-header";
+ const title = document.createElement("span");
+ title.className = "world-map-title";
+ title.textContent = "Council jurisdictions";
+ const status = document.createElement("span");
+ status.className = "world-map-status";
+ status.textContent = "loading…";
+ this.status = status;
+ header.append(title, status);
+
+ this.mapHost = document.createElement("div");
+ this.mapHost.className = "world-map-section-host";
+
+ this.popover = document.createElement("div");
+ this.popover.className = "world-map-popover";
+ this.popover.hidden = true;
+ this.mapHost.appendChild(this.popover);
+
+ this.root.append(header, this.mapHost);
+ }
+
+ element(): HTMLElement {
+ return this.root;
+ }
+
+ /**
+ * Register a click handler. The map fires this on every country click; the
+ * consumer decides whether to toggle a side panel, navigate, etc. Pass
+ * null to clear.
+ */
+ setOnCountryClick(handler: CountryClickHandler | null): void {
+ this.onCountryClick = handler;
+ }
+
+ /**
+ * Highlight a country as the persistent "active" selection. Independent
+ * of hover. Pass null to clear.
+ */
+ setSelectedCountry(code: string | null): void {
+ if (!this.handle) {
+ this.selectedCountry = code;
+ return;
+ }
+ this.selectedCountry = code;
+ this.handle.setSlot(SELECTED_COUNTRY_SLOT, code ? [code] : []);
+ }
+
+ render(topology: CouncilTopologyEntry[]): void {
+ this.councils = topology;
+ this.allCodes = Array.from(
+ topology.reduce((acc, c) => {
+ for (const code of c.jurisdictions) {
+ const upper = code.toUpperCase();
+ if (COUNTRIES[upper]) acc.add(upper);
+ }
+ return acc;
+ }, new Set()),
+ );
+
+ if (this.failed) {
+ this.status.textContent = "map unavailable";
+ return;
+ }
+ if (this.handle) {
+ this.handle.setSelected(this.allCodes);
+ this.updateStatus();
+ return;
+ }
+ if (this.pendingMount) return;
+
+ this.pendingMount = renderWorldMap({
+ selected: this.allCodes,
+ svgUrl: "/world-map.svg",
+ onHover: (code) => this.handleHover(code),
+ onSelect: (code) => this.onCountryClick?.(code),
+ })
+ .then((handle) => {
+ this.handle = handle;
+ this.mapHost.insertBefore(handle.element, this.popover);
+ handle.setSelected(this.allCodes);
+ if (this.selectedCountry) {
+ handle.setSlot(SELECTED_COUNTRY_SLOT, [this.selectedCountry]);
+ }
+ this.updateStatus();
+ this.mapHost.addEventListener(
+ "mousemove",
+ (ev) => this.positionPopover(ev),
+ );
+ })
+ .catch((err) => {
+ this.failed = true;
+ this.status.textContent = "map unavailable";
+ console.warn("World map failed to load", err);
+ })
+ .finally(() => {
+ this.pendingMount = null;
+ });
+ }
+
+ private updateStatus(): void {
+ this.status.textContent = this.allCodes.length === 0
+ ? "no jurisdictions"
+ : `${this.allCodes.length} jurisdiction${
+ this.allCodes.length === 1 ? "" : "s"
+ }`;
+ }
+
+ private handleHover(code: string | null): void {
+ if (!this.handle) return;
+ if (code === null) {
+ this.handle.setSlot(HOVERED_SLOT, []);
+ this.handle.setSlot(REACHABLE_SLOT, []);
+ this.handle.setSlot(DIMMED_SLOT, []);
+ this.popover.hidden = true;
+ return;
+ }
+
+ // Councils whose jurisdictions include the hovered country.
+ const hoveredCouncils = this.councils.filter((c) =>
+ c.jurisdictions.some((j) => j.toUpperCase() === code)
+ );
+
+ if (hoveredCouncils.length === 0) {
+ // No-council country: just dim everything else, popover says so.
+ const dim = this.allCodes.filter((c) => c !== code);
+ this.handle.setSlot(HOVERED_SLOT, [code]);
+ this.handle.setSlot(REACHABLE_SLOT, []);
+ this.handle.setSlot(DIMMED_SLOT, dim);
+ this.renderPopoverEmpty(code);
+ return;
+ }
+
+ // Reachable = union of jurisdictions across all councils touching `code`,
+ // minus the hovered country itself.
+ const reachable = new Set();
+ for (const c of hoveredCouncils) {
+ for (const j of c.jurisdictions) {
+ const upper = j.toUpperCase();
+ if (upper !== code && COUNTRIES[upper]) reachable.add(upper);
+ }
+ }
+ const focus = new Set([code, ...reachable]);
+ const dim = this.allCodes.filter((c) => !focus.has(c));
+
+ this.handle.setSlot(HOVERED_SLOT, [code]);
+ this.handle.setSlot(REACHABLE_SLOT, Array.from(reachable));
+ this.handle.setSlot(DIMMED_SLOT, dim);
+ this.renderPopoverCouncils(code, hoveredCouncils);
+ }
+
+ private renderPopoverCouncils(
+ code: string,
+ councils: CouncilTopologyEntry[],
+ ): void {
+ this.popover.textContent = "";
+ const header = document.createElement("div");
+ header.className = "world-map-popover-header";
+ header.textContent = `${COUNTRIES[code]?.name ?? code} (${code})`;
+ this.popover.appendChild(header);
+
+ const list = document.createElement("ul");
+ list.className = "world-map-popover-list";
+ for (const c of councils) {
+ const li = document.createElement("li");
+ const name = document.createElement("span");
+ name.className = "world-map-popover-name";
+ name.textContent = c.name ?? c.id.slice(0, 12);
+ const meta = document.createElement("span");
+ meta.className = "world-map-popover-meta";
+ const n = c.providers.length;
+ meta.textContent = `${n} PP${n === 1 ? "" : "s"}`;
+ li.append(name, meta);
+ list.appendChild(li);
+ }
+ this.popover.appendChild(list);
+ this.popover.hidden = false;
+ }
+
+ private renderPopoverEmpty(code: string): void {
+ this.popover.textContent = "";
+ const header = document.createElement("div");
+ header.className = "world-map-popover-header";
+ header.textContent = `${COUNTRIES[code]?.name ?? code} (${code})`;
+ const body = document.createElement("div");
+ body.className = "world-map-popover-empty";
+ body.textContent = "No councils";
+ this.popover.append(header, body);
+ this.popover.hidden = false;
+ }
+
+ private positionPopover(ev: MouseEvent): void {
+ if (this.popover.hidden) return;
+ const hostRect = this.mapHost.getBoundingClientRect();
+ const x = ev.clientX - hostRect.left + 14;
+ const y = ev.clientY - hostRect.top + 14;
+ // Keep the popover inside the host bounds; nudge left/up if needed.
+ const popRect = this.popover.getBoundingClientRect();
+ const maxX = hostRect.width - popRect.width - 8;
+ const maxY = hostRect.height - popRect.height - 8;
+ this.popover.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
+ this.popover.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
+ }
+}