Invite a peer or discover nodes to grow your federation
+
+
Drag to orbit · tap a node for details
+
+
+
+
+
+
diff --git a/neode-ui/src/style.css b/neode-ui/src/style.css
index e3307c14..9a47a6f4 100644
--- a/neode-ui/src/style.css
+++ b/neode-ui/src/style.css
@@ -3149,3 +3149,41 @@ select {
select::-ms-expand {
display: none;
}
+
+/* =========================================================================
+ Federation 3D node map — fill-to-bottom layout
+ When the map stage is on screen, the dashboard scroll panel switches from
+ a scrolling document to a column that hands all remaining height to the
+ map, killing the big bottom margin on every form factor. List view (no
+ .node-map-stage in the DOM) is untouched, and browsers without :has()
+ gracefully fall back to the old scrolling behaviour via the stage's
+ min-height.
+ ========================================================================= */
+.dashboard-scroll-panel:has(.node-map-stage) {
+ display: flex;
+ flex-direction: column;
+ /* Desktop: trim the 6rem .mobile-scroll-pad breathing room to a slim edge */
+ padding-bottom: 1rem;
+}
+
+/* The routed view stretches; DashboardRouterView tags it .view-container
+ (with Tailwind's flex-none, which this outranks on specificity). */
+.dashboard-scroll-panel:has(.node-map-stage) > .view-container {
+ flex: 1 1 auto;
+ display: flex;
+ flex-direction: column;
+ min-height: 0;
+}
+
+/* The wrapper's bottom scroll spacer is dead weight in a filled column */
+.dashboard-scroll-panel:has(.node-map-stage) > div[aria-hidden="true"] {
+ display: none;
+}
+
+/* Mobile/tablet: fill down to the tab bar (+ audio player / safe area),
+ not under it — the bar is viewport-fixed and would cover the map. */
+@media (max-width: 920px) {
+ .dashboard-scroll-panel:has(.node-map-stage) {
+ padding-bottom: calc(var(--mobile-tab-bar-height, 88px) + var(--safe-area-bottom, env(safe-area-inset-bottom, 0px)) + var(--audio-player-height, 0px) + 12px);
+ }
+}
diff --git a/neode-ui/src/utils/motion.ts b/neode-ui/src/utils/motion.ts
new file mode 100644
index 00000000..c9478921
--- /dev/null
+++ b/neode-ui/src/utils/motion.ts
@@ -0,0 +1,69 @@
+/**
+ * Design-system-aware GSAP setup — the single place animation code pulls
+ * timing, easing, and colour tokens from, so every GSAP-driven surface moves
+ * (and is coloured) like the rest of the glass UI instead of inventing its
+ * own physics per component.
+ *
+ * Usage: `import { gsap, motionTokens, prefersReducedMotion } from '@/utils/motion'`
+ * — never `import gsap from 'gsap'` directly, or the shared defaults are lost.
+ */
+import { gsap } from 'gsap'
+
+/** Colour tokens mirrored from style.css / tailwind.config.js. The UI is
+ * dark-only (style.css pins `color-scheme: dark`), so these are constants,
+ * not theme-dependent lookups. */
+export const motionTokens = {
+ color: {
+ /** Brand accent — the orange used for focus glows and highlights
+ * (tailwind orange-400, e.g. `.glass-button:focus-visible`). */
+ accent: '#fb923c',
+ /** Trust-level palette — matches NodeList / trust badges. */
+ trusted: '#4ade80',
+ observer: '#fb923c',
+ untrusted: '#ef4444',
+ neutral: '#9ca3af',
+ /** Text/line opacities on the dark glass ground. */
+ textPrimary: 'rgba(255, 255, 255, 0.95)',
+ textSecondary: 'rgba(255, 255, 255, 0.7)',
+ textFaint: 'rgba(255, 255, 255, 0.45)',
+ line: 'rgba(255, 255, 255, 0.18)',
+ lineFaint: 'rgba(255, 255, 255, 0.08)',
+ glassDark: 'rgba(0, 0, 0, 0.35)',
+ glassDarker: 'rgba(0, 0, 0, 0.6)',
+ },
+ /** Durations (seconds) — align with the CSS transitions already shipped
+ * (modal 0.3s, press feedback 0.1s). */
+ duration: {
+ fast: 0.18,
+ base: 0.3,
+ slow: 0.6,
+ /** Scene-setting intros (map fly-in, hero moments). */
+ cinematic: 1.4,
+ },
+ ease: {
+ /** Default UI ease — matches the snappy glass feel. */
+ out: 'power3.out',
+ inOut: 'power2.inOut',
+ /** Playful overshoot for elements "arriving" (node pop-ins). */
+ arrive: 'back.out(1.6)',
+ /** Springy attention pulse. */
+ pulse: 'sine.inOut',
+ },
+} as const
+
+// Shared defaults: any tween that doesn't say otherwise moves like the rest
+// of the design system.
+gsap.defaults({
+ ease: motionTokens.ease.out,
+ duration: motionTokens.duration.base,
+})
+
+/** Live reduced-motion check. Query at animation-build time (not module
+ * scope) so OS-level toggles apply without a reload. Callers should skip
+ * intros / idle loops and jump to end state when this is true. */
+export function prefersReducedMotion(): boolean {
+ return typeof window !== 'undefined'
+ && window.matchMedia?.('(prefers-reduced-motion: reduce)').matches === true
+}
+
+export { gsap }
diff --git a/neode-ui/src/views/Federation.vue b/neode-ui/src/views/Federation.vue
index 9fc43ddd..d957c5c4 100644
--- a/neode-ui/src/views/Federation.vue
+++ b/neode-ui/src/views/Federation.vue
@@ -1,5 +1,8 @@
-
+
+
-
-
-
+
+
+
@@ -243,7 +246,7 @@ import { useCachedResource } from '@/composables/useCachedResource'
import { useTransportStore } from '@/stores/transport'
import { useAppStore } from '@/stores/app'
import { useSyncStore } from '@/stores/sync'
-import NetworkMap from '@/components/federation/NetworkMap.vue'
+import NetworkMap3D from '@/components/federation/NetworkMap3D.vue'
import FederationHeader from './federation/FederationHeader.vue'
import RotateDidModal from './federation/RotateDidModal.vue'
import QuickActions from './federation/QuickActions.vue'
@@ -308,6 +311,15 @@ function setView(id: ViewId) {
localStorage.setItem('federation-view', id)
}
+const mapActive = computed(() => activeView.value === 'map' && nodes.value.length > 0)
+
+/** Map click-through: tapping a peer opens the same detail modal as the list
+ * view. Tapping the self node is a no-op (its actions live in the header). */
+function onMapSelect(did: string) {
+ const node = nodes.value.find(n => n.did === did)
+ if (node) selectedNode.value = node
+}
+
const selfDid = ref('')
const mapNodes = computed(() => {
diff --git a/neode-ui/src/views/__tests__/meshTabCache.test.ts b/neode-ui/src/views/__tests__/meshTabCache.test.ts
index db35939a..5ec03fd3 100644
--- a/neode-ui/src/views/__tests__/meshTabCache.test.ts
+++ b/neode-ui/src/views/__tests__/meshTabCache.test.ts
@@ -6,7 +6,7 @@
// live D3 force simulation does not hold for this codebase — a full grep for
// `d3`/`forceSimulation`/`simulation` across neode-ui/src turns up nothing in
// Mesh.vue's component tree; the only D3 force simulation belongs to
-// NetworkMap.vue (Federation.vue's graph, out of this plan's scope). This
+// NetworkMap3D.vue (Federation.vue's graph, out of this plan's scope). This
// file therefore only covers the six cached fetch groups (Task 1) and the
// Leaflet map's activate/deactivate lifecycle (Task 2, MeshMap.vue) — the
// D3-specific truths are vacuously satisfied (there is nothing to leak).