From fa14f6ebaad2215d2bdbd5ddcb4d523e38a3e5d4 Mon Sep 17 00:00:00 2001 From: archipelago Date: Sun, 9 Aug 2026 17:09:09 -0400 Subject: [PATCH] feat(federation): GSAP-powered 3D orbital node map, fills viewport to bottom - Add gsap 3.15 + design-system-aware motion module (src/utils/motion.ts): shared colour/duration/ease tokens mirrored from style.css, gsap.defaults, live prefers-reduced-motion check. - Replace the d3 force NetworkMap with NetworkMap3D.vue: peers on projected 3D orbital rings around the self node, cinematic intro (camera dolly + staggered fly-in + ring draw), idle rotation with drag-to-orbit inertia, depth-sorted painter's order, trust-colour palette, online/offline states, sonar pulse on self, tap-a-node opens the detail modal. - Map view now fills the dashboard panel to the bottom edge on desktop, mobile and companion: .dashboard-scroll-panel:has(.node-map-stage) turns the panel into a column (tab-bar/safe-area/audio-player aware padding) instead of leaving the old dead bottom margin. - Reduced motion: intro/idle skipped, scene renders static. Co-Authored-By: Claude Fable 5 --- neode-ui/package-lock.json | 7 + neode-ui/package.json | 1 + .../__tests__/meshMapLifecycle.test.ts | 2 +- .../src/components/federation/NetworkMap.vue | 182 ----- .../components/federation/NetworkMap3D.vue | 636 ++++++++++++++++++ neode-ui/src/style.css | 38 ++ neode-ui/src/utils/motion.ts | 69 ++ neode-ui/src/views/Federation.vue | 22 +- .../src/views/__tests__/meshTabCache.test.ts | 2 +- 9 files changed, 770 insertions(+), 189 deletions(-) delete mode 100644 neode-ui/src/components/federation/NetworkMap.vue create mode 100644 neode-ui/src/components/federation/NetworkMap3D.vue create mode 100644 neode-ui/src/utils/motion.ts diff --git a/neode-ui/package-lock.json b/neode-ui/package-lock.json index 099781c2..07fce07b 100644 --- a/neode-ui/package-lock.json +++ b/neode-ui/package-lock.json @@ -16,6 +16,7 @@ "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fuse.js": "^7.1.0", + "gsap": "^3.15.0", "leaflet": "^1.9.4", "pinia": "^3.0.4", "qr-scanner": "^1.4.2", @@ -7293,6 +7294,12 @@ "dev": true, "license": "ISC" }, + "node_modules/gsap": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/gsap/-/gsap-3.15.0.tgz", + "integrity": "sha512-dMW4CWBTUK1AEEDeZc1g4xpPGIrSf9fJF960qbTZmN/QwZIWY5wgliS6JWl9/25fpTGJrMRtSjGtOmPnfjZB+A==", + "license": "Standard 'no charge' license: https://gsap.com/standard-license." + }, "node_modules/has-bigints": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", diff --git a/neode-ui/package.json b/neode-ui/package.json index 0d7dfd62..b60698cb 100644 --- a/neode-ui/package.json +++ b/neode-ui/package.json @@ -33,6 +33,7 @@ "dompurify": "^3.3.3", "fast-json-patch": "^3.1.1", "fuse.js": "^7.1.0", + "gsap": "^3.15.0", "leaflet": "^1.9.4", "pinia": "^3.0.4", "qr-scanner": "^1.4.2", diff --git a/neode-ui/src/components/__tests__/meshMapLifecycle.test.ts b/neode-ui/src/components/__tests__/meshMapLifecycle.test.ts index 3452dac8..ac9f063f 100644 --- a/neode-ui/src/components/__tests__/meshMapLifecycle.test.ts +++ b/neode-ui/src/components/__tests__/meshMapLifecycle.test.ts @@ -12,7 +12,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 (or MeshMap.vue's); the only D3 force simulation -// in the codebase belongs to NetworkMap.vue (Federation.vue's graph, out of +// in the codebase belongs to NetworkMap3D.vue (Federation.vue's graph, out of // this plan's scope). This file therefore only covers the Leaflet map's // activate/deactivate lifecycle — the D3-specific truths from the plan are // vacuously satisfied (there is nothing to leak). diff --git a/neode-ui/src/components/federation/NetworkMap.vue b/neode-ui/src/components/federation/NetworkMap.vue deleted file mode 100644 index c5ebf165..00000000 --- a/neode-ui/src/components/federation/NetworkMap.vue +++ /dev/null @@ -1,182 +0,0 @@ - - - - - diff --git a/neode-ui/src/components/federation/NetworkMap3D.vue b/neode-ui/src/components/federation/NetworkMap3D.vue new file mode 100644 index 00000000..84dc9521 --- /dev/null +++ b/neode-ui/src/components/federation/NetworkMap3D.vue @@ -0,0 +1,636 @@ + + + + + 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 @@