From 7b0bf7d9ed7723463c0ee248e680967521307360 Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Sat, 21 Mar 2026 00:22:12 +0000 Subject: [PATCH 01/10] landing page almost ready --- .../docusaurus-theme/css/hero-background.css | 19 ++ .../docusaurus-theme/css/product-picker.css | 17 +- .../src/components/HeroBackground.tsx | 302 ++++++++++++++++++ .../docusaurus-theme/src/components/index.ts | 1 + packages/docusaurus-theme/src/index.ts | 2 + .../theme/NavbarItem/ComponentTypes.tsx | 4 +- .../NavbarItem/types/ProductPicker/index.tsx | 8 +- .../types/ResourcesPicker/index.tsx | 132 ++++++++ packages/test-site/docusaurus.config.ts | 12 +- packages/test-site/src/pages/index.tsx | 21 +- .../test-site/src/pages/landing.module.css | 7 +- .../test-site/static/img/openziti-sm-logo.svg | 64 ++++ packages/test-site/static/img/zlan-logo.svg | 54 ++++ 13 files changed, 616 insertions(+), 27 deletions(-) create mode 100644 packages/docusaurus-theme/css/hero-background.css create mode 100644 packages/docusaurus-theme/src/components/HeroBackground.tsx create mode 100644 packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx create mode 100644 packages/test-site/static/img/openziti-sm-logo.svg create mode 100644 packages/test-site/static/img/zlan-logo.svg diff --git a/packages/docusaurus-theme/css/hero-background.css b/packages/docusaurus-theme/css/hero-background.css new file mode 100644 index 0000000..c084836 --- /dev/null +++ b/packages/docusaurus-theme/css/hero-background.css @@ -0,0 +1,19 @@ +/* GPU-composited keyframes for HeroBackground SVG ring/pulse animations. + * r is set to max statically; transform: scale() starts near zero so the + * visual matches the original SMIL r 0→max behaviour. */ +@keyframes ix-pulse { + 0% { transform: scale(0.064); opacity: 0.6; } + 70% { transform: scale(1); opacity: 0; } + 100% { transform: scale(1); opacity: 0; } +} + +@keyframes pkt-ring { + 0% { transform: scale(0.111); opacity: 0.8; } + 70% { transform: scale(1); opacity: 0; } + 100% { transform: scale(1); opacity: 0; } +} + +/* Pause all CSS animations when the hero is off-screen (set by IntersectionObserver). */ +.paused * { + animation-play-state: paused !important; +} diff --git a/packages/docusaurus-theme/css/product-picker.css b/packages/docusaurus-theme/css/product-picker.css index 3270882..2af1bda 100644 --- a/packages/docusaurus-theme/css/product-picker.css +++ b/packages/docusaurus-theme/css/product-picker.css @@ -25,12 +25,14 @@ position: relative; padding-right: 1.2rem; transition: color 0.3s ease; + font-weight: 700; } .nf-picker-trigger:hover, .nf-resources-dropdown:hover, .navbar__item.dropdown--show .nf-picker-trigger, -.navbar__item.dropdown--show .nf-resources-dropdown { +.navbar__item.dropdown--show .nf-resources-dropdown, +.navbar__item.nf-picker--open .nf-picker-trigger { color: var(--ifm-color-primary); text-decoration: none; } @@ -59,17 +61,17 @@ opacity: 1; } -/* Open: chevron rotates 180° */ +/* Open: chevron holds the hover drop position */ .nf-picker--open .nf-picker-trigger::after, .navbar__item.dropdown--show .nf-resources-dropdown::after { - transform: translateY(-50%) rotate(180deg); + transform: translateY(-20%); opacity: 1; } /* Dark mode: cyan accent on hover */ [data-theme='dark'] .nf-picker-trigger:hover, [data-theme='dark'] .nf-resources-dropdown:hover, -[data-theme='dark'] .nf-picker--open .nf-picker-trigger, +[data-theme='dark'] .navbar__item.nf-picker--open .nf-picker-trigger, [data-theme='dark'] .navbar__item.dropdown--show .nf-resources-dropdown { color: #22d3ee; } @@ -138,6 +140,7 @@ /* Narrower panel for the 2-column Resources menu */ .dropdown__menu:has(.picker-resources) { max-width: 700px; } +.nf-picker-panel--narrow { max-width: 680px; } /* Ensure visibility when open */ .dropdown--show > .dropdown__menu, @@ -179,9 +182,9 @@ transition: all 0.2s ease; border-radius: 8px; } -.picker-link:hover { background: rgba(0, 118, 255, 0.06); transform: translateX(3px); } -.picker-link strong { color: var(--ifm-font-color-base); font-size: 0.95rem; font-weight: 900; letter-spacing: -0.02em; display: block; } -.picker-link span { color: #64748b; font-size: 0.82rem; display: block; margin-top: 2px; line-height: 1.35; } +.picker-link:hover { background: rgba(0, 118, 255, 0.06); transform: translateX(3px); text-decoration: none; } +.picker-link strong { color: var(--ifm-font-color-base); font-size: 0.95rem; font-weight: 900; letter-spacing: -0.02em; display: block; text-decoration: none; } +.picker-link span { color: #64748b; font-size: 0.82rem; display: block; margin-top: 2px; line-height: 1.35; text-decoration: none; } /* ── Mobile (<= 996 px) ─────────────────────────────────────────────────── */ @media (max-width: 996px) { diff --git a/packages/docusaurus-theme/src/components/HeroBackground.tsx b/packages/docusaurus-theme/src/components/HeroBackground.tsx new file mode 100644 index 0000000..8861399 --- /dev/null +++ b/packages/docusaurus-theme/src/components/HeroBackground.tsx @@ -0,0 +1,302 @@ +import React, {useEffect, useMemo, useRef} from 'react'; + +// ── Canvas & palette ──────────────────────────────────────────────────────── +const W = 1400; +const H = 800; +const BG = '#020617'; +const CYAN = '#22d3ee'; +const INDIGO = '#4338ca'; +const GREEN = '#22c55e'; + +// ── Seeded LCG PRNG — SSR-safe ────────────────────────────────────────────── +function makeLCG(seed: number) { + let s = seed >>> 0; + return () => { + s = (Math.imul(s, 1664525) + 1013904223) >>> 0; + return s / 0x100000000; + }; +} + +// ── Types ─────────────────────────────────────────────────────────────────── +type Vec2 = {x: number; y: number}; +type NodeData = {id: number; x: number; y: number; size: number; isHub: boolean}; +type ConnData = {id: string; from: Vec2; to: Vec2; pktDur: number; pktBegin: number}; + +// ── Segment-segment intersection (interior points only) ────────────────────── +function segIntersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2): Vec2 | null { + const dx1 = b.x - a.x, dy1 = b.y - a.y; + const dx2 = d.x - c.x, dy2 = d.y - c.y; + const denom = dx1 * dy2 - dy1 * dx2; + if (Math.abs(denom) < 1e-9) return null; + const t = ((c.x - a.x) * dy2 - (c.y - a.y) * dx2) / denom; + const u = ((c.x - a.x) * dy1 - (c.y - a.y) * dx1) / denom; + if (t > 0.02 && t < 0.98 && u > 0.02 && u < 0.98) { + return {x: a.x + t * dx1, y: a.y + t * dy1}; + } + return null; +} + +// ── Network parameters ─────────────────────────────────────────────────────── +const COLS = 10; +const ROWS = 6; +const K_NEAREST = 3; + +// ── Component ─────────────────────────────────────────────────────────────── +export default function HeroBackground(): React.ReactElement { + const containerRef = useRef(null); + + const {nodes, connections, packetConns, intersectionNodes} = useMemo(() => { + const rng = makeLCG(0xcafef00d); + + // ── Step 1: Stratified node placement ───────────────────────────────── + const cellW = (W - 40) / COLS; + const cellH = (H - 40) / ROWS; + const rawNodes: Vec2[] = []; + + for (let row = 0; row < ROWS; row++) { + for (let col = 0; col < COLS; col++) { + rawNodes.push({ + x: 20 + col * cellW + rng() * cellW, + y: 20 + row * cellH + rng() * cellH, + }); + } + } + + // ── Step 2: K-nearest-neighbor connections ───────────────────────────── + const degree = new Array(rawNodes.length).fill(0); + const edgeSet = new Set(); + const conns: ConnData[] = []; + let cid = 0; + + const dist2 = (a: Vec2, b: Vec2) => (b.x - a.x) ** 2 + (b.y - a.y) ** 2; + const edgeKey = (i: number, j: number) => `${Math.min(i, j)}-${Math.max(i, j)}`; + + for (let i = 0; i < rawNodes.length; i++) { + const nearest = rawNodes + .map((n, j) => ({j, d: dist2(rawNodes[i], n)})) + .filter(({j}) => j !== i) + .sort((a, b) => a.d - b.d) + .slice(0, K_NEAREST); + + for (const {j} of nearest) { + const key = edgeKey(i, j); + if (!edgeSet.has(key)) { + edgeSet.add(key); + degree[i]++; + degree[j]++; + const pktDur = 2.0 + rng() * 1.0; + conns.push({ + id: `c${cid++}`, + from: rawNodes[i], + to: rawNodes[j], + pktDur, + pktBegin: -(rng() * pktDur), + }); + } + } + } + + // ── Step 3: Build node display data ─────────────────────────────────── + const nodes: NodeData[] = rawNodes.map((p, i) => ({ + id: i, + x: p.x, + y: p.y, + size: degree[i] >= 4 ? 2 + rng() * 0.8 : 0.9 + rng() * 0.8, + isHub: degree[i] >= 4, + })); + + // 1 in 4 connections carries a visible data packet. + const packetConns = conns.filter((_, i) => i % 4 === 0); + + // ── Step 4: Geometric intersection nodes (capped at 25) ─────────────── + const intersectionNodes: Vec2[] = []; + outer: for (let i = 0; i < conns.length; i++) { + for (let j = i + 1; j < conns.length; j++) { + const pt = segIntersect(conns[i].from, conns[i].to, conns[j].from, conns[j].to); + if (!pt) continue; + if (rawNodes.some(n => (n.x - pt.x) ** 2 + (n.y - pt.y) ** 2 < 144)) continue; + if (intersectionNodes.some(n => (n.x - pt.x) ** 2 + (n.y - pt.y) ** 2 < 64)) continue; + intersectionNodes.push(pt); + if (intersectionNodes.length >= 25) break outer; + } + } + + return {nodes, connections: conns, packetConns, intersectionNodes}; + }, []); + + // ── Global mouse listener (rAF-throttled) ──────────────────────────────── + useEffect(() => { + const el = containerRef.current; + if (!el) return; + let rafId: number | null = null; + const onMove = (e: MouseEvent) => { + if (rafId) return; + rafId = requestAnimationFrame(() => { + rafId = null; + const r = el.getBoundingClientRect(); + const x = e.clientX - r.left; + const y = e.clientY - r.top; + const inside = x >= 0 && x <= r.width && y >= 0 && y <= r.height; + el.style.setProperty('--mx', inside ? `${x}px` : '-400px'); + el.style.setProperty('--my', inside ? `${y}px` : '-400px'); + }); + }; + document.addEventListener('mousemove', onMove, {passive: true}); + return () => { + document.removeEventListener('mousemove', onMove); + if (rafId) cancelAnimationFrame(rafId); + }; + }, []); + + // ── IntersectionObserver — pause animations when off-screen ────────────── + useEffect(() => { + const el = containerRef.current; + if (!el) return; + const obs = new IntersectionObserver( + ([entry]) => el.classList.toggle('paused', !entry.isIntersecting), + {threshold: 0}, + ); + obs.observe(el); + return () => obs.disconnect(); + }, []); + + return ( + + ); +} diff --git a/packages/docusaurus-theme/src/components/index.ts b/packages/docusaurus-theme/src/components/index.ts index 744424a..b66dafd 100644 --- a/packages/docusaurus-theme/src/components/index.ts +++ b/packages/docusaurus-theme/src/components/index.ts @@ -1,4 +1,5 @@ export * from './Alert' +export {default as HeroBackground} from './HeroBackground' export * from './CodeBlock'; export * from './Common'; export * from './NetFoundry' diff --git a/packages/docusaurus-theme/src/index.ts b/packages/docusaurus-theme/src/index.ts index b3d7eeb..359dce6 100644 --- a/packages/docusaurus-theme/src/index.ts +++ b/packages/docusaurus-theme/src/index.ts @@ -22,7 +22,9 @@ export default function themeNetFoundry( // Automatically inject CSS getClientModules() { const modules: string[] = [ + require.resolve('@docsearch/css'), require.resolve('../css/theme.css'), + require.resolve('../css/hero-background.css'), ]; // Add custom CSS if specified in options diff --git a/packages/docusaurus-theme/theme/NavbarItem/ComponentTypes.tsx b/packages/docusaurus-theme/theme/NavbarItem/ComponentTypes.tsx index 0053ff0..8a670f4 100644 --- a/packages/docusaurus-theme/theme/NavbarItem/ComponentTypes.tsx +++ b/packages/docusaurus-theme/theme/NavbarItem/ComponentTypes.tsx @@ -1,4 +1,5 @@ import ProductPicker from './types/ProductPicker'; +import ResourcesPicker from './types/ResourcesPicker'; // @theme-original resolves to OUR OWN file in a plugin theme (Docusaurus sets // both @theme and @theme-original to the plugin file). @theme-init resolves to @@ -10,5 +11,6 @@ const ComponentTypesOrig = require('@theme-init/NavbarItem/ComponentTypes').defa export default { ...ComponentTypesOrig, - 'custom-productPicker': ProductPicker, + 'custom-productPicker': ProductPicker, + 'custom-resourcesPicker': ResourcesPicker, }; diff --git a/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx b/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx index 6e26792..2494f42 100644 --- a/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx +++ b/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx @@ -2,6 +2,7 @@ import React, {useState, useRef, useEffect, useCallback} from 'react'; import Link from '@docusaurus/Link'; import clsx from 'clsx'; import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import useBaseUrl from '@docusaurus/useBaseUrl'; import {useThemeConfig} from '@docusaurus/theme-common'; export type PickerLink = { @@ -59,9 +60,14 @@ export default function ProductPicker({label = 'Products', className}: Props) { const themeConfig = useThemeConfig() as any; const consoleLogo = themeConfig?.netfoundry?.consoleLogo ?? NF_LOGO_DEFAULT; const img = `${siteConfig.url}${siteConfig.baseUrl}img`; + const zlanLogoLocal = useBaseUrl('/img/zlan-logo.svg'); const columns: PickerColumn[] = (themeConfig?.netfoundry?.productPickerColumns ?? []) .map((col: any, i: number) => ({...col, headerClass: HEADER_CLASSES[i] ?? ''})); - const resolvedColumns = columns.length ? columns : buildDefaultColumns(img, consoleLogo); + const defaultColumns = buildDefaultColumns(img, consoleLogo).map(col => ({ + ...col, + links: col.links.map(l => l.label === 'zLAN' ? {...l, logo: zlanLogoLocal} : l), + })); + const resolvedColumns = columns.length ? columns : defaultColumns; const wrapRef = useRef(null); const hasEnteredPanel = useRef(false); const [open, setOpen] = useState(false); diff --git a/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx b/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx new file mode 100644 index 0000000..2b55ef1 --- /dev/null +++ b/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx @@ -0,0 +1,132 @@ +import React, {useState, useRef, useEffect, useCallback} from 'react'; +import Link from '@docusaurus/Link'; +import useBaseUrl from '@docusaurus/useBaseUrl'; +import {useThemeConfig} from '@docusaurus/theme-common'; +import clsx from 'clsx'; + +const NF_LOGO_DEFAULT = 'https://raw.githubusercontent.com/netfoundry/branding/refs/heads/main/images/svg/icon/netfoundry-icon-color.svg'; + +const YOUTUBE_ICON = ``; +const DISCOURSE_ICON = ``; + +type Props = { + label?: string; + position?: 'left' | 'right'; + className?: string; +}; + +export default function ResourcesPicker({label = 'Resources', className}: Props) { + const themeConfig = useThemeConfig() as any; + const consoleLogo = themeConfig?.netfoundry?.consoleLogo ?? NF_LOGO_DEFAULT; + const openzitiLogo = useBaseUrl('/img/openziti-sm-logo.svg'); + + const wrapRef = useRef(null); + const hasEnteredPanel = useRef(false); + const [open, setOpen] = useState(false); + + const close = useCallback(() => { + setOpen(false); + hasEnteredPanel.current = false; + }, []); + + // Close on outside click/touch + useEffect(() => { + const onOutside = (e: MouseEvent | TouchEvent) => { + if (!wrapRef.current?.contains(e.target as Node)) close(); + }; + document.addEventListener('mousedown', onOutside); + document.addEventListener('touchstart', onOutside); + return () => { + document.removeEventListener('mousedown', onOutside); + document.removeEventListener('touchstart', onOutside); + }; + }, [close]); + + // Close when another picker opens + useEffect(() => { + const onOtherOpen = (e: any) => { + if (e.detail.label !== label) close(); + }; + window.addEventListener('nf-picker:open', onOtherOpen); + return () => window.removeEventListener('nf-picker:open', onOtherOpen); + }, [label, close]); + + const handleTriggerEnter = useCallback(() => { + hasEnteredPanel.current = false; + window.dispatchEvent(new CustomEvent('nf-picker:open', {detail: {label}})); + setOpen(true); + }, [label]); + + const handlePanelEnter = useCallback(() => { + hasEnteredPanel.current = true; + }, []); + + const handlePanelLeave = useCallback(() => { + if (hasEnteredPanel.current) close(); + }, [close]); + + const columns = [ + { + header: 'Learn & Engage', + headerClass: 'picker-header--nf-primary', + links: [ + { label: 'NetFoundry Blog', description: 'Latest news, updates, and insights from NetFoundry.', href: 'https://netfoundry.io/blog/', logoSrc: consoleLogo }, + { label: 'OpenZiti Blog', description: 'Technical articles and community updates.', href: 'https://blog.openziti.io/', logoSrc: openzitiLogo }, + ], + }, + { + header: 'Community & Support', + headerClass: 'picker-header--nf-secondary', + links: [ + { label: 'NetFoundry YouTube', description: 'Video tutorials, demos, and technical deep dives.', href: 'https://www.youtube.com/c/NetFoundry', svgIcon: YOUTUBE_ICON }, + { label: 'OpenZiti Discourse', description: 'Ask questions and connect with the community.', href: 'https://openziti.discourse.group/', svgIcon: DISCOURSE_ICON }, + ], + }, + ]; + + return ( +
+ {}} + onClick={e => { e.preventDefault(); setOpen(o => !o); }} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setOpen(o => !o); } }}> + {label} + + {open && ( +
e.stopPropagation()} + onMouseEnter={handlePanelEnter} + onMouseLeave={handlePanelLeave}> +
+ {columns.map((col, i) => ( +
+ {col.header} + {col.links.map((link, j) => ( + + {'logoSrc' in link + ? + : + } +
+ {link.label} + {link.description} +
+ + ))} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/test-site/docusaurus.config.ts b/packages/test-site/docusaurus.config.ts index 4661032..09dce51 100644 --- a/packages/test-site/docusaurus.config.ts +++ b/packages/test-site/docusaurus.config.ts @@ -176,7 +176,7 @@ export default { { label: 'zLAN', to: '/docs/zlan', - logo: 'https://netfoundry.io/docs/img/zlan-logo.svg', + logo: '/img/zlan-logo.svg', description: 'Zero-trust access for OT networks.', }, ], @@ -196,18 +196,14 @@ export default { image: 'https://netfoundry.io/wp-content/uploads/2024/07/netfoundry-logo-tag-color-stacked-1.svg', navbar: { hideOnScroll: false, - title: 'NetFoundry Documentation', + title: 'NetFoundry Docs', logo: { alt: 'NetFoundry Logo', src: 'https://raw.githubusercontent.com/netfoundry/branding/refs/heads/main/images/svg/icon/netfoundry-icon-color.svg', }, items: [ - { type: 'custom-productPicker', position: 'left' }, - { - to: '/docs', - label: 'Main Docs', - position: 'left', - }, + { type: 'custom-productPicker', position: 'left' }, + { type: 'custom-resourcesPicker', position: 'left' }, ], }, prism: { diff --git a/packages/test-site/src/pages/index.tsx b/packages/test-site/src/pages/index.tsx index 8c4367f..86bd420 100644 --- a/packages/test-site/src/pages/index.tsx +++ b/packages/test-site/src/pages/index.tsx @@ -1,8 +1,10 @@ import React, {JSX} from 'react'; import Layout from '@theme/Layout'; import Link from '@docusaurus/Link'; +import useBaseUrl from '@docusaurus/useBaseUrl'; import clsx from 'clsx'; import styles from './landing.module.css'; +import HeroBackground from '../../../docusaurus-theme/src/components/HeroBackground'; const CYAN = '#22d3ee'; const GREEN = '#22c55e'; @@ -15,7 +17,7 @@ const products = [ { id: 'frontdoor', title: 'Frontdoor', logo: `${IMG}/frontdoor-sm-logo.svg`, tag: 'Managed', accent: CYAN, link: '/docs/frontdoor', features: ['No agent or VPN required', 'Zero firewall rules', 'Identity-based access', 'Any app, any browser'], description: 'Secure, clientless access to any application — without a VPN or firewall rule. Expose nothing to the internet while giving authorized users instant access.' }, { id: 'zrok', title: 'zrok', logo: `${IMG}/zrok-1.0.0-rocket-purple.svg`, tag: 'Open Source', accent: GREEN, link: '/docs/zrok', description: 'Geo-scale secure sharing built on the OpenZiti mesh. Share services, files, or HTTP endpoints peer-to-peer — no open ports, no NAT traversal tricks.' }, { id: 'selfhosted', title: 'NetFoundry Self-Hosted', logo: `${IMG}/onprem-sm-logo.svg`, tag: 'Self-Hosted', accent: CYAN, link: '/docs/onprem', features: ['Full infrastructure control', 'Air-gap compatible', 'On-prem or any cloud', 'Enterprise SLA'], description: 'Deploy the full NetFoundry control plane and fabric in your own environment. Full sovereignty over your zero-trust infrastructure — on-prem, air-gapped, or any cloud.' }, - { id: 'zlan', title: 'zLAN', logo: `${IMG}/zlan-logo.svg`, tag: 'OT Security', accent: CYAN, link: '/docs/zlan', features: ['Deep OT/IT traffic visibility', 'Identity-aware micro-segmentation', 'Centralized zero-trust policy', 'Built on NetFoundry Self-Hosted'], description: 'Identity-aware micro-segmentation firewall for operational technology networks. Deep traffic visibility, centralized policy, and zero-trust access control for OT environments.' }, + { id: 'zlan', title: 'zLAN', logo: '', tag: 'OT Security', accent: CYAN, link: '/docs/zlan', features: ['Deep OT/IT traffic visibility', 'Identity-aware micro-segmentation', 'Centralized zero-trust policy', 'Built on NetFoundry Self-Hosted'], description: 'Identity-aware micro-segmentation firewall for operational technology networks. Deep traffic visibility, centralized policy, and zero-trust access control for OT environments.' }, ]; type Product = (typeof products)[number]; @@ -44,9 +46,14 @@ function BentoCard({product, featured = false}: {product: Product; featured?: bo } export default function Home(): JSX.Element { + const zlanLogo = useBaseUrl('/img/zlan-logo.svg'); + const resolved = Object.fromEntries( + products.map(p => [p.id, p.id === 'zlan' ? {...p, logo: zlanLogo} : p]) + ) as Record; return (
+

NetFoundry Docs

Secure, high-performance networking for the modern era.

@@ -61,18 +68,18 @@ export default function Home(): JSX.Element {
Managed Cloud
- +
open-source counterpart
- +
- +
open-source counterpart
- +
Run on your own infrastructure
- - + +
diff --git a/packages/test-site/src/pages/landing.module.css b/packages/test-site/src/pages/landing.module.css index f7a749b..5d454c9 100644 --- a/packages/test-site/src/pages/landing.module.css +++ b/packages/test-site/src/pages/landing.module.css @@ -39,7 +39,7 @@ } .nf-btn-primary:hover { background: #005ce6; border-color: #005ce6; transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 118, 255, 0.4); color: #ffffff; + box-shadow: 0 8px 24px rgba(0, 118, 255, 0.4); color: #ffffff; text-decoration: none; } .nf-btn-ghost { display: inline-flex; align-items: center; padding: 0.65rem 1.75rem; @@ -49,7 +49,7 @@ } .nf-btn-ghost:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(34, 211, 238, 0.5); - transform: translateY(-2px); color: #ffffff; + transform: translateY(-2px); color: #ffffff; text-decoration: none; } .nf-features-section { width: 100%; background: #0f172a; padding: 5rem 0 2.5rem; } @@ -106,10 +106,11 @@ transition: transform 0.2s ease, box-shadow 0.2s ease, border-top-color 0.2s ease; } .nf-bento-card:hover { - transform: translateY(-4px); border-top-color: #22c55e; + transform: translateY(-4px); border-top-color: #22c55e; text-decoration: none; box-shadow: 0 2px 6px rgba(0,0,0,0.4), 0 12px 32px rgba(0,0,0,0.45), 0 24px 60px rgba(0,0,0,0.25), 0 0 0 1px rgba(34,197,94,0.12); } +.nf-bento-card:hover * { text-decoration: none; } :global([data-theme='light']) .nf-bento-card { background: #edf3f8; border-color: rgba(0,0,0,0.08); box-shadow: 0 1px 3px rgba(0,0,0,0.06), 0 4px 14px rgba(0,0,0,0.05); diff --git a/packages/test-site/static/img/openziti-sm-logo.svg b/packages/test-site/static/img/openziti-sm-logo.svg new file mode 100644 index 0000000..d038e67 --- /dev/null +++ b/packages/test-site/static/img/openziti-sm-logo.svg @@ -0,0 +1,64 @@ + + + + + + + + + + + + Sheet.181 + + Page-1 + + Sheet.183 + + Sheet.184 + + + + + Lightning_Crashes + + Sheet.186 + + + + + Sheet.187 + + Sheet.188 + + Sheet.189 + + + + + + + \ No newline at end of file diff --git a/packages/test-site/static/img/zlan-logo.svg b/packages/test-site/static/img/zlan-logo.svg new file mode 100644 index 0000000..462d42d --- /dev/null +++ b/packages/test-site/static/img/zlan-logo.svg @@ -0,0 +1,54 @@ + + + + + + + + + + + + + + + + + + + + From 955b598ac409ac0e7516bbd7b80716805b34e07f Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Sat, 21 Mar 2026 00:35:15 +0000 Subject: [PATCH 02/10] CSS fix --- packages/test-site/src/pages/landing.module.css | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/test-site/src/pages/landing.module.css b/packages/test-site/src/pages/landing.module.css index 5d454c9..b3a1c44 100644 --- a/packages/test-site/src/pages/landing.module.css +++ b/packages/test-site/src/pages/landing.module.css @@ -34,22 +34,22 @@ .nf-hero-ctas { display: flex; gap: 1rem; justify-content: center; } .nf-btn-primary { display: inline-flex; align-items: center; padding: 0.65rem 1.75rem; - background: #0076FF; color: #ffffff; border-radius: 8px; font-weight: 700; + background: #0076FF; color: #ffffff !important; border-radius: 8px; font-weight: 700; font-size: 0.95rem; text-decoration: none; transition: all 0.25s ease; border: 2px solid #0076FF; } .nf-btn-primary:hover { background: #005ce6; border-color: #005ce6; transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 118, 255, 0.4); color: #ffffff; text-decoration: none; + box-shadow: 0 8px 24px rgba(0, 118, 255, 0.4); color: #ffffff !important; text-decoration: none; } .nf-btn-ghost { display: inline-flex; align-items: center; padding: 0.65rem 1.75rem; - background: transparent; color: #ffffff; border-radius: 8px; font-weight: 700; + background: transparent; color: #ffffff !important; border-radius: 8px; font-weight: 700; font-size: 0.95rem; text-decoration: none; transition: all 0.25s ease; border: 2px solid rgba(255, 255, 255, 0.25); } .nf-btn-ghost:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(34, 211, 238, 0.5); - transform: translateY(-2px); color: #ffffff; text-decoration: none; + transform: translateY(-2px); color: #ffffff !important; text-decoration: none; } .nf-features-section { width: 100%; background: #0f172a; padding: 5rem 0 2.5rem; } From 7707032bf0301f35447d8b531adfbc3035237cd2 Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Sat, 21 Mar 2026 00:55:07 +0000 Subject: [PATCH 03/10] EOD edits, looking good --- packages/docusaurus-theme/css/theme.css | 31 +++++++++++++++++++ .../NetFoundryFooter/NetFoundryFooter.tsx | 2 +- .../NavbarItem/types/ProductPicker/index.tsx | 2 +- .../types/ResourcesPicker/index.tsx | 4 +-- packages/test-site/docusaurus.config.ts | 20 ++++++++++-- packages/test-site/src/pages/index.tsx | 2 +- 6 files changed, 54 insertions(+), 7 deletions(-) diff --git a/packages/docusaurus-theme/css/theme.css b/packages/docusaurus-theme/css/theme.css index 0b06560..64f4a11 100644 --- a/packages/docusaurus-theme/css/theme.css +++ b/packages/docusaurus-theme/css/theme.css @@ -18,3 +18,34 @@ /* Legacy design system variables and comprehensive styling */ @import "./legacy.css"; + +/* ── Footer social link hover ───────────────────────────────────────────── */ +a[class*="footerSocialLink"] { + transition: all 0.2s ease !important; +} + +[data-theme='dark'] a[class*="footerSocialLink"] { + background-color: #1a2640 !important; + color: #64748b !important; + border: 1px solid rgba(148, 163, 184, 0.1) !important; +} +[data-theme='dark'] a[class*="footerSocialLink"]:hover { + background-color: #22d3ee !important; + color: #020617 !important; + border-color: transparent !important; + transform: translateY(-3px) !important; + box-shadow: 0 6px 16px rgba(34, 211, 238, 0.35) !important; +} + +[data-theme='light'] a[class*="footerSocialLink"] { + background-color: #e2e8f0 !important; + color: #475569 !important; + border: 1px solid rgba(0, 0, 0, 0.06) !important; +} +[data-theme='light'] a[class*="footerSocialLink"]:hover { + background-color: #0891b2 !important; + color: #ffffff !important; + border-color: transparent !important; + transform: translateY(-3px) !important; + box-shadow: 0 6px 16px rgba(8, 145, 178, 0.3) !important; +} diff --git a/packages/docusaurus-theme/src/components/NetFoundryFooter/NetFoundryFooter.tsx b/packages/docusaurus-theme/src/components/NetFoundryFooter/NetFoundryFooter.tsx index 102de0c..f0f86e0 100644 --- a/packages/docusaurus-theme/src/components/NetFoundryFooter/NetFoundryFooter.tsx +++ b/packages/docusaurus-theme/src/components/NetFoundryFooter/NetFoundryFooter.tsx @@ -167,7 +167,7 @@ export function NetFoundryFooter(props: NetFoundryFooterProps) {
-

© 2025 NetFoundry Inc. OpenZiti is an open source project sponsored by NetFoundry. All rights reserved.

+

© 2026 NetFoundry Inc. OpenZiti is an open source project sponsored by NetFoundry. All rights reserved.

diff --git a/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx b/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx index 2494f42..4ded072 100644 --- a/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx +++ b/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx @@ -25,7 +25,7 @@ type Props = { className?: string; }; -const HEADER_CLASSES = ['picker-header--nf-primary', 'picker-header--nf-secondary', 'picker-header--nf-tertiary']; +const HEADER_CLASSES = ['picker-header--nf-tertiary', 'picker-header--nf-secondary', 'picker-header--nf-primary']; const NF_LOGO_DEFAULT = 'https://raw.githubusercontent.com/netfoundry/branding/refs/heads/main/images/svg/icon/netfoundry-icon-color.svg'; const buildDefaultColumns = (img: string, consoleLogo: string): PickerColumn[] => [ diff --git a/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx b/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx index 2b55ef1..264baae 100644 --- a/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx +++ b/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx @@ -68,10 +68,10 @@ export default function ResourcesPicker({label = 'Resources', className}: Props) const columns = [ { header: 'Learn & Engage', - headerClass: 'picker-header--nf-primary', + headerClass: 'picker-header--nf-tertiary', links: [ { label: 'NetFoundry Blog', description: 'Latest news, updates, and insights from NetFoundry.', href: 'https://netfoundry.io/blog/', logoSrc: consoleLogo }, - { label: 'OpenZiti Blog', description: 'Technical articles and community updates.', href: 'https://blog.openziti.io/', logoSrc: openzitiLogo }, + { label: 'OpenZiti Tech Blog', description: 'Technical articles and community updates.', href: 'https://blog.openziti.io/', logoSrc: openzitiLogo }, ], }, { diff --git a/packages/test-site/docusaurus.config.ts b/packages/test-site/docusaurus.config.ts index 09dce51..551efdb 100644 --- a/packages/test-site/docusaurus.config.ts +++ b/packages/test-site/docusaurus.config.ts @@ -183,13 +183,29 @@ export default { }, ], footer: { - description: 'This is just a test site for the NetFoundry Docusaurus theme.', + description: 'Secure, high-performance networking for the modern era.', + copyright: `Copyright © 2026 NetFoundry Inc.`, socialProps: { githubUrl: 'https://github.com/netfoundry/', youtubeUrl: 'https://youtube.com/netfoundry/', linkedInUrl: 'https://www.linkedin.com/company/netfoundry/', - twitterUrl: 'https://twitter.com/netfoundry/', + twitterUrl: 'https://x.com/netfoundry/', }, + documentationLinks: [ + {href: '/docs/learn/quickstarts/services/ztha', label: 'Get started'}, + {href: '/docs/reference/developer/api/', label: 'API reference'}, + {href: '/docs/reference/developer/sdk/', label: 'SDK integration'}, + ], + communityLinks: [ + {href: 'https://github.com/openziti/ziti', label: 'GitHub'}, + {href: 'https://openziti.discourse.group/', label: 'OpenZiti Discourse'}, + {href: '/docs/openziti/policies/CONTRIBUTING', label: 'Contribute'}, + ], + resourceLinks: [ + {href: 'https://netfoundry.io/', label: 'NetFoundry'}, + {href: 'https://netfoundry.io/blog/', label: 'NetFoundry Tech Blog'}, + {href: 'https://blog.openziti.io', label: 'OpenZiti Tech Blog'}, + ], }, }, // Replace with your project's social card diff --git a/packages/test-site/src/pages/index.tsx b/packages/test-site/src/pages/index.tsx index 86bd420..e37ab85 100644 --- a/packages/test-site/src/pages/index.tsx +++ b/packages/test-site/src/pages/index.tsx @@ -59,7 +59,7 @@ export default function Home(): JSX.Element {

Secure, high-performance networking for the modern era.

Get Started - Request Demo + Request Demo
From be94b437151c180aff1b40b44324bc26a7c5cc42 Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Mon, 23 Mar 2026 16:50:28 +0000 Subject: [PATCH 04/10] remove changelog call from build-docs.sh --- unified-doc/build-docs.sh | 4 ---- 1 file changed, 4 deletions(-) diff --git a/unified-doc/build-docs.sh b/unified-doc/build-docs.sh index 1960f74..db2fb4c 100755 --- a/unified-doc/build-docs.sh +++ b/unified-doc/build-docs.sh @@ -321,10 +321,6 @@ mkdir -p "${SDK_ROOT_TARGET}" # -d = skip docusaurus build (unified-doc does its own build) "${script_dir}/_remotes/openziti/gendoc.sh" -d "${OTHER_FLAGS[@]}" -# --- GENERATE SELFHOSTED CHANGELOG --- -echo "Generating selfhosted changelog from CHANGELOG file..." -node "${script_dir}/_remotes/selfhosted/docusaurus/scripts/generate-changelog.mjs" - # --- DOCUSAURUS BUILD --- pushd "${script_dir}" >/dev/null yarn install From 118aa30ef3536ad9a0bf390c88c7c13e6911f7ca Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Mon, 23 Mar 2026 17:17:09 +0000 Subject: [PATCH 05/10] fix build --- unified-doc/src/pages/index.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/unified-doc/src/pages/index.tsx b/unified-doc/src/pages/index.tsx index ca6cb1f..081a08d 100644 --- a/unified-doc/src/pages/index.tsx +++ b/unified-doc/src/pages/index.tsx @@ -46,7 +46,7 @@ const products = [ logo: `${IMG}/zrok-1.0.0-rocket-purple.svg`, tag: 'Open Source', accent: GREEN, - link: `${DOCS_BASE}zrok/getting-started`, + link: `${DOCS_BASE}zrok`, description: 'Geo-scale secure sharing built on the OpenZiti mesh. Share services, files, or HTTP endpoints peer-to-peer — no open ports, no NAT traversal tricks.' }, { From 115e074e6718e907c643bef5e4cf948e0c6c3054 Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Mon, 23 Mar 2026 18:38:27 +0000 Subject: [PATCH 06/10] add fixes post-clint check --- .../docusaurus-theme/css/hero-background.css | 3 - packages/docusaurus-theme/css/theme.css | 102 +++--- .../src/components/HeroBackground.tsx | 7 +- .../NavbarItem/types/ProductPicker/index.tsx | 318 +++++++++--------- .../types/ResourcesPicker/index.tsx | 5 +- packages/test-site/src/pages/index.tsx | 21 +- .../test-site/src/pages/landing.module.css | 8 +- .../test-site/static/img/openziti-sm-logo.svg | 64 ---- 8 files changed, 228 insertions(+), 300 deletions(-) delete mode 100644 packages/test-site/static/img/openziti-sm-logo.svg diff --git a/packages/docusaurus-theme/css/hero-background.css b/packages/docusaurus-theme/css/hero-background.css index c084836..fea85e7 100644 --- a/packages/docusaurus-theme/css/hero-background.css +++ b/packages/docusaurus-theme/css/hero-background.css @@ -14,6 +14,3 @@ } /* Pause all CSS animations when the hero is off-screen (set by IntersectionObserver). */ -.paused * { - animation-play-state: paused !important; -} diff --git a/packages/docusaurus-theme/css/theme.css b/packages/docusaurus-theme/css/theme.css index 64f4a11..276aa22 100644 --- a/packages/docusaurus-theme/css/theme.css +++ b/packages/docusaurus-theme/css/theme.css @@ -1,51 +1,51 @@ -/** - * NetFoundry Docusaurus Theme - Combined Styles - * - * This file is automatically loaded by the theme via getClientModules(). - * - * Consuming projects no longer need to manually add these imports - * to their custom.css files. - */ - -/* CSS variables for light mode */ -@import "./vars.css"; - -/* CSS variables for dark mode */ -@import "./vars-dark.css"; - -/* Layout styles */ -@import "./layout.css"; - -/* Legacy design system variables and comprehensive styling */ -@import "./legacy.css"; - -/* ── Footer social link hover ───────────────────────────────────────────── */ -a[class*="footerSocialLink"] { - transition: all 0.2s ease !important; -} - -[data-theme='dark'] a[class*="footerSocialLink"] { - background-color: #1a2640 !important; - color: #64748b !important; - border: 1px solid rgba(148, 163, 184, 0.1) !important; -} -[data-theme='dark'] a[class*="footerSocialLink"]:hover { - background-color: #22d3ee !important; - color: #020617 !important; - border-color: transparent !important; - transform: translateY(-3px) !important; - box-shadow: 0 6px 16px rgba(34, 211, 238, 0.35) !important; -} - -[data-theme='light'] a[class*="footerSocialLink"] { - background-color: #e2e8f0 !important; - color: #475569 !important; - border: 1px solid rgba(0, 0, 0, 0.06) !important; -} -[data-theme='light'] a[class*="footerSocialLink"]:hover { - background-color: #0891b2 !important; - color: #ffffff !important; - border-color: transparent !important; - transform: translateY(-3px) !important; - box-shadow: 0 6px 16px rgba(8, 145, 178, 0.3) !important; -} +/** + * NetFoundry Docusaurus Theme - Combined Styles + * + * This file is automatically loaded by the theme via getClientModules(). + * + * Consuming projects no longer need to manually add these imports + * to their custom.css files. + */ + +/* CSS variables for light mode */ +@import "./vars.css"; + +/* CSS variables for dark mode */ +@import "./vars-dark.css"; + +/* Layout styles */ +@import "./layout.css"; + +/* Legacy design system variables and comprehensive styling */ +@import "./legacy.css"; + +/* ── Footer social link hover ───────────────────────────────────────────── */ +footer a[class*="footerSocialLink"] { + transition: all 0.2s ease; +} + +[data-theme='dark'] footer a[class*="footerSocialLink"] { + background-color: #1a2640; + color: #64748b; + border: 1px solid rgba(148, 163, 184, 0.1); +} +[data-theme='dark'] footer a[class*="footerSocialLink"]:hover { + background-color: #22d3ee; + color: #020617; + border-color: transparent; + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(34, 211, 238, 0.35); +} + +[data-theme='light'] footer a[class*="footerSocialLink"] { + background-color: #e2e8f0; + color: #475569; + border: 1px solid rgba(0, 0, 0, 0.06); +} +[data-theme='light'] footer a[class*="footerSocialLink"]:hover { + background-color: #0891b2; + color: #ffffff; + border-color: transparent; + transform: translateY(-3px); + box-shadow: 0 6px 16px rgba(8, 145, 178, 0.3); +} diff --git a/packages/docusaurus-theme/src/components/HeroBackground.tsx b/packages/docusaurus-theme/src/components/HeroBackground.tsx index 8861399..77d88f5 100644 --- a/packages/docusaurus-theme/src/components/HeroBackground.tsx +++ b/packages/docusaurus-theme/src/components/HeroBackground.tsx @@ -153,7 +153,12 @@ export default function HeroBackground(): React.ReactElement { const el = containerRef.current; if (!el) return; const obs = new IntersectionObserver( - ([entry]) => el.classList.toggle('paused', !entry.isIntersecting), + ([entry]) => { + const state = entry.isIntersecting ? 'running' : 'paused'; + el.querySelectorAll('[style*="animation"]').forEach( + child => { child.style.animationPlayState = state; } + ); + }, {threshold: 0}, ); obs.observe(el); diff --git a/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx b/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx index 4ded072..920cf33 100644 --- a/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx +++ b/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx @@ -1,162 +1,156 @@ -import React, {useState, useRef, useEffect, useCallback} from 'react'; -import Link from '@docusaurus/Link'; -import clsx from 'clsx'; -import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; -import useBaseUrl from '@docusaurus/useBaseUrl'; -import {useThemeConfig} from '@docusaurus/theme-common'; - -export type PickerLink = { - label: string; - to: string; - logo?: string; - logoDark?: string; - description?: string; -}; - -export type PickerColumn = { - header: string; - headerClass?: string; - links: PickerLink[]; -}; - -type Props = { - label?: string; - position?: 'left' | 'right'; - className?: string; -}; - -const HEADER_CLASSES = ['picker-header--nf-tertiary', 'picker-header--nf-secondary', 'picker-header--nf-primary']; -const NF_LOGO_DEFAULT = 'https://raw.githubusercontent.com/netfoundry/branding/refs/heads/main/images/svg/icon/netfoundry-icon-color.svg'; - -const buildDefaultColumns = (img: string, consoleLogo: string): PickerColumn[] => [ - { - header: 'Managed Cloud', - headerClass: HEADER_CLASSES[0], - links: [ - { label: 'NetFoundry Console', to: '#', logo: consoleLogo, description: 'Cloud-managed orchestration and global fabric control.' }, - { label: 'Frontdoor', to: '/docs/frontdoor', logo: `${img}/frontdoor-sm-logo.svg`, description: 'Secure application access gateway.' }, - ], - }, - { - header: 'Open Source', - headerClass: HEADER_CLASSES[1], - links: [ - { label: 'OpenZiti', to: '/docs/openziti', logo: `${img}/openziti-sm-logo.svg`, description: 'Programmable zero-trust mesh infrastructure.' }, - { label: 'zrok', to: '/docs/zrok', logo: `${img}/zrok-1.0.0-rocket-purple.svg`, logoDark: `${img}/zrok-1.0.0-rocket-green.svg`, description: 'Secure peer-to-peer sharing built on OpenZiti.' }, - ], - }, - { - header: 'Your own infrastructure', - headerClass: HEADER_CLASSES[2], - links: [ - { label: 'Self-Hosted', to: '/docs/selfhosted', logo: `${img}/onprem-sm-logo.svg`, description: 'Deploy the full stack in your own environment.' }, - { label: 'zLAN', to: '/docs/zlan', logo: `${img}/zlan-logo.svg`, description: 'Zero-trust access for OT networks.' }, - ], - }, -]; - -export default function ProductPicker({label = 'Products', className}: Props) { - const {siteConfig} = useDocusaurusContext(); - const themeConfig = useThemeConfig() as any; - const consoleLogo = themeConfig?.netfoundry?.consoleLogo ?? NF_LOGO_DEFAULT; - const img = `${siteConfig.url}${siteConfig.baseUrl}img`; - const zlanLogoLocal = useBaseUrl('/img/zlan-logo.svg'); - const columns: PickerColumn[] = (themeConfig?.netfoundry?.productPickerColumns ?? []) - .map((col: any, i: number) => ({...col, headerClass: HEADER_CLASSES[i] ?? ''})); - const defaultColumns = buildDefaultColumns(img, consoleLogo).map(col => ({ - ...col, - links: col.links.map(l => l.label === 'zLAN' ? {...l, logo: zlanLogoLocal} : l), - })); - const resolvedColumns = columns.length ? columns : defaultColumns; - const wrapRef = useRef(null); - const hasEnteredPanel = useRef(false); - const [open, setOpen] = useState(false); - - const close = useCallback(() => { - setOpen(false); - hasEnteredPanel.current = false; - }, []); - - // Close on outside click/touch - useEffect(() => { - const onOutside = (e: MouseEvent | TouchEvent) => { - if (!wrapRef.current?.contains(e.target as Node)) close(); - }; - document.addEventListener('mousedown', onOutside); - document.addEventListener('touchstart', onOutside); - return () => { - document.removeEventListener('mousedown', onOutside); - document.removeEventListener('touchstart', onOutside); - }; - }, [close]); - - // Sync: close when another product picker opens - useEffect(() => { - const onOtherOpen = (e: any) => { - if (e.detail.label !== label) close(); - }; - window.addEventListener('nf-picker:open', onOtherOpen); - return () => window.removeEventListener('nf-picker:open', onOtherOpen); - }, [label, close]); - - const handleTriggerEnter = useCallback(() => { - hasEnteredPanel.current = false; - window.dispatchEvent(new CustomEvent('nf-picker:open', {detail: {label}})); - setOpen(true); - }, [label]); - - // Stay open until user enters the panel — no timer - const handleTriggerLeave = useCallback(() => {}, []); - - const handlePanelEnter = useCallback(() => { - hasEnteredPanel.current = true; - }, []); - - const handlePanelLeave = useCallback(() => { - if (hasEnteredPanel.current) close(); - }, [close]); - - return ( -
- { e.preventDefault(); setOpen(o => !o); }} - onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setOpen(o => !o); } }}> - {label} - - {open && ( -
e.stopPropagation()} - onMouseEnter={handlePanelEnter} - onMouseLeave={handlePanelLeave}> -
- {resolvedColumns.map((col, i) => ( -
- {col.header} - {col.links.map((link, j) => ( - - {link.logo && } - {link.logoDark && } -
- {link.label} - {link.description && {link.description}} -
- - ))} -
- ))} -
-
- )} -
- ); -} +import React, {useState, useRef, useEffect, useCallback} from 'react'; +import Link from '@docusaurus/Link'; +import clsx from 'clsx'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; +import {useThemeConfig} from '@docusaurus/theme-common'; + +export type PickerLink = { + label: string; + to: string; + logo?: string; + logoDark?: string; + description?: string; +}; + +export type PickerColumn = { + header: string; + headerClass?: string; + links: PickerLink[]; +}; + +type Props = { + label?: string; + position?: 'left' | 'right'; + className?: string; +}; + +const HEADER_CLASSES = ['picker-header--nf-tertiary', 'picker-header--nf-secondary', 'picker-header--nf-primary']; +const NF_LOGO_DEFAULT = 'https://raw.githubusercontent.com/netfoundry/branding/refs/heads/main/images/svg/icon/netfoundry-icon-color.svg'; + +const buildDefaultColumns = (img: string, consoleLogo: string): PickerColumn[] => [ + { + header: 'Managed Cloud', + headerClass: HEADER_CLASSES[0], + links: [ + { label: 'NetFoundry Console', to: '#', logo: consoleLogo, description: 'Cloud-managed orchestration and global fabric control.' }, + { label: 'Frontdoor', to: '/docs/frontdoor', logo: `${img}/frontdoor-sm-logo.svg`, description: 'Secure application access gateway.' }, + ], + }, + { + header: 'Open Source', + headerClass: HEADER_CLASSES[1], + links: [ + { label: 'OpenZiti', to: '/docs/openziti', logo: `${img}/openziti-sm-logo.svg`, description: 'Programmable zero-trust mesh infrastructure.' }, + { label: 'zrok', to: '/docs/zrok', logo: `${img}/zrok-1.0.0-rocket-purple.svg`, logoDark: `${img}/zrok-1.0.0-rocket-green.svg`, description: 'Secure peer-to-peer sharing built on OpenZiti.' }, + ], + }, + { + header: 'Your own infrastructure', + headerClass: HEADER_CLASSES[2], + links: [ + { label: 'Self-Hosted', to: '/docs/selfhosted', logo: `${img}/onprem-sm-logo.svg`, description: 'Deploy the full stack in your own environment.' }, + { label: 'zLAN', to: '/docs/zlan', logo: `${img}/zlan-logo.svg`, description: 'Zero-trust access for OT networks.' }, + ], + }, +]; + +export default function ProductPicker({label = 'Products', className}: Props) { + const {siteConfig} = useDocusaurusContext(); + const themeConfig = useThemeConfig() as any; + const consoleLogo = themeConfig?.netfoundry?.consoleLogo ?? NF_LOGO_DEFAULT; + const img = `${siteConfig.url}${siteConfig.baseUrl}img`; + const columns: PickerColumn[] = (themeConfig?.netfoundry?.productPickerColumns ?? []) + .map((col: any, i: number) => ({...col, headerClass: HEADER_CLASSES[i] ?? ''})); + const resolvedColumns = columns.length ? columns : buildDefaultColumns(img, consoleLogo); + const wrapRef = useRef(null); + const hasEnteredPanel = useRef(false); + const [open, setOpen] = useState(false); + + const close = useCallback(() => { + setOpen(false); + hasEnteredPanel.current = false; + }, []); + + // Close on outside click/touch + useEffect(() => { + const onOutside = (e: MouseEvent | TouchEvent) => { + if (!wrapRef.current?.contains(e.target as Node)) close(); + }; + document.addEventListener('mousedown', onOutside); + document.addEventListener('touchstart', onOutside); + return () => { + document.removeEventListener('mousedown', onOutside); + document.removeEventListener('touchstart', onOutside); + }; + }, [close]); + + // Sync: close when another product picker opens + useEffect(() => { + const onOtherOpen = (e: any) => { + if (e.detail.label !== label) close(); + }; + window.addEventListener('nf-picker:open', onOtherOpen); + return () => window.removeEventListener('nf-picker:open', onOtherOpen); + }, [label, close]); + + const handleTriggerEnter = useCallback(() => { + hasEnteredPanel.current = false; + window.dispatchEvent(new CustomEvent('nf-picker:open', {detail: {label}})); + setOpen(true); + }, [label]); + + // Stay open until user enters the panel — no timer + const handleTriggerLeave = useCallback(() => {}, []); + + const handlePanelEnter = useCallback(() => { + hasEnteredPanel.current = true; + }, []); + + const handlePanelLeave = useCallback(() => { + if (hasEnteredPanel.current) close(); + }, [close]); + + return ( +
+ { e.preventDefault(); setOpen(o => !o); }} + onKeyDown={e => { if (e.key === 'Enter') { e.preventDefault(); setOpen(o => !o); } }}> + {label} + + {open && ( +
e.stopPropagation()} + onMouseEnter={handlePanelEnter} + onMouseLeave={handlePanelLeave}> +
+ {resolvedColumns.map((col, i) => ( +
+ {col.header} + {col.links.map((link, j) => ( + + {link.logo && } + {link.logoDark && } +
+ {link.label} + {link.description && {link.description}} +
+ + ))} +
+ ))} +
+
+ )} +
+ ); +} diff --git a/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx b/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx index 264baae..5cd4846 100644 --- a/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx +++ b/packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx @@ -1,6 +1,6 @@ import React, {useState, useRef, useEffect, useCallback} from 'react'; import Link from '@docusaurus/Link'; -import useBaseUrl from '@docusaurus/useBaseUrl'; +import useDocusaurusContext from '@docusaurus/useDocusaurusContext'; import {useThemeConfig} from '@docusaurus/theme-common'; import clsx from 'clsx'; @@ -16,9 +16,10 @@ type Props = { }; export default function ResourcesPicker({label = 'Resources', className}: Props) { + const {siteConfig} = useDocusaurusContext(); const themeConfig = useThemeConfig() as any; const consoleLogo = themeConfig?.netfoundry?.consoleLogo ?? NF_LOGO_DEFAULT; - const openzitiLogo = useBaseUrl('/img/openziti-sm-logo.svg'); + const openzitiLogo = `${siteConfig.url}${siteConfig.baseUrl}img/openziti-sm-logo.svg`; const wrapRef = useRef(null); const hasEnteredPanel = useRef(false); diff --git a/packages/test-site/src/pages/index.tsx b/packages/test-site/src/pages/index.tsx index e37ab85..a2a5933 100644 --- a/packages/test-site/src/pages/index.tsx +++ b/packages/test-site/src/pages/index.tsx @@ -1,10 +1,9 @@ import React, {JSX} from 'react'; import Layout from '@theme/Layout'; import Link from '@docusaurus/Link'; -import useBaseUrl from '@docusaurus/useBaseUrl'; import clsx from 'clsx'; import styles from './landing.module.css'; -import HeroBackground from '../../../docusaurus-theme/src/components/HeroBackground'; +import {HeroBackground} from '@netfoundry/docusaurus-theme/ui'; const CYAN = '#22d3ee'; const GREEN = '#22c55e'; @@ -17,7 +16,7 @@ const products = [ { id: 'frontdoor', title: 'Frontdoor', logo: `${IMG}/frontdoor-sm-logo.svg`, tag: 'Managed', accent: CYAN, link: '/docs/frontdoor', features: ['No agent or VPN required', 'Zero firewall rules', 'Identity-based access', 'Any app, any browser'], description: 'Secure, clientless access to any application — without a VPN or firewall rule. Expose nothing to the internet while giving authorized users instant access.' }, { id: 'zrok', title: 'zrok', logo: `${IMG}/zrok-1.0.0-rocket-purple.svg`, tag: 'Open Source', accent: GREEN, link: '/docs/zrok', description: 'Geo-scale secure sharing built on the OpenZiti mesh. Share services, files, or HTTP endpoints peer-to-peer — no open ports, no NAT traversal tricks.' }, { id: 'selfhosted', title: 'NetFoundry Self-Hosted', logo: `${IMG}/onprem-sm-logo.svg`, tag: 'Self-Hosted', accent: CYAN, link: '/docs/onprem', features: ['Full infrastructure control', 'Air-gap compatible', 'On-prem or any cloud', 'Enterprise SLA'], description: 'Deploy the full NetFoundry control plane and fabric in your own environment. Full sovereignty over your zero-trust infrastructure — on-prem, air-gapped, or any cloud.' }, - { id: 'zlan', title: 'zLAN', logo: '', tag: 'OT Security', accent: CYAN, link: '/docs/zlan', features: ['Deep OT/IT traffic visibility', 'Identity-aware micro-segmentation', 'Centralized zero-trust policy', 'Built on NetFoundry Self-Hosted'], description: 'Identity-aware micro-segmentation firewall for operational technology networks. Deep traffic visibility, centralized policy, and zero-trust access control for OT environments.' }, + { id: 'zlan', title: 'zLAN', logo: '/img/zlan-logo.svg', tag: 'OT Security', accent: CYAN, link: '/docs/zlan', features: ['Deep OT/IT traffic visibility', 'Identity-aware micro-segmentation', 'Centralized zero-trust policy', 'Built on NetFoundry Self-Hosted'], description: 'Identity-aware micro-segmentation firewall for operational technology networks. Deep traffic visibility, centralized policy, and zero-trust access control for OT environments.' }, ]; type Product = (typeof products)[number]; @@ -46,10 +45,6 @@ function BentoCard({product, featured = false}: {product: Product; featured?: bo } export default function Home(): JSX.Element { - const zlanLogo = useBaseUrl('/img/zlan-logo.svg'); - const resolved = Object.fromEntries( - products.map(p => [p.id, p.id === 'zlan' ? {...p, logo: zlanLogo} : p]) - ) as Record; return (
@@ -68,18 +63,18 @@ export default function Home(): JSX.Element {
Managed Cloud
- +
open-source counterpart
- +
- +
open-source counterpart
- +
Run on your own infrastructure
- - + +
diff --git a/packages/test-site/src/pages/landing.module.css b/packages/test-site/src/pages/landing.module.css index b3a1c44..5d454c9 100644 --- a/packages/test-site/src/pages/landing.module.css +++ b/packages/test-site/src/pages/landing.module.css @@ -34,22 +34,22 @@ .nf-hero-ctas { display: flex; gap: 1rem; justify-content: center; } .nf-btn-primary { display: inline-flex; align-items: center; padding: 0.65rem 1.75rem; - background: #0076FF; color: #ffffff !important; border-radius: 8px; font-weight: 700; + background: #0076FF; color: #ffffff; border-radius: 8px; font-weight: 700; font-size: 0.95rem; text-decoration: none; transition: all 0.25s ease; border: 2px solid #0076FF; } .nf-btn-primary:hover { background: #005ce6; border-color: #005ce6; transform: translateY(-2px); - box-shadow: 0 8px 24px rgba(0, 118, 255, 0.4); color: #ffffff !important; text-decoration: none; + box-shadow: 0 8px 24px rgba(0, 118, 255, 0.4); color: #ffffff; text-decoration: none; } .nf-btn-ghost { display: inline-flex; align-items: center; padding: 0.65rem 1.75rem; - background: transparent; color: #ffffff !important; border-radius: 8px; font-weight: 700; + background: transparent; color: #ffffff; border-radius: 8px; font-weight: 700; font-size: 0.95rem; text-decoration: none; transition: all 0.25s ease; border: 2px solid rgba(255, 255, 255, 0.25); } .nf-btn-ghost:hover { background: rgba(255, 255, 255, 0.06); border-color: rgba(34, 211, 238, 0.5); - transform: translateY(-2px); color: #ffffff !important; text-decoration: none; + transform: translateY(-2px); color: #ffffff; text-decoration: none; } .nf-features-section { width: 100%; background: #0f172a; padding: 5rem 0 2.5rem; } diff --git a/packages/test-site/static/img/openziti-sm-logo.svg b/packages/test-site/static/img/openziti-sm-logo.svg deleted file mode 100644 index d038e67..0000000 --- a/packages/test-site/static/img/openziti-sm-logo.svg +++ /dev/null @@ -1,64 +0,0 @@ - - - - - - - - - - - - Sheet.181 - - Page-1 - - Sheet.183 - - Sheet.184 - - - - - Lightning_Crashes - - Sheet.186 - - - - - Sheet.187 - - Sheet.188 - - Sheet.189 - - - - - - - \ No newline at end of file From 26437cb9ccf1945b979a97f343cc6ee750c48f15 Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Mon, 23 Mar 2026 21:25:04 +0000 Subject: [PATCH 07/10] new hero image --- .../docusaurus-theme/css/hero-background.css | 17 +- packages/docusaurus-theme/package.json | 7 +- .../src/components/HeroBackground.tsx | 456 +++++++----------- packages/docusaurus-theme/src/vanta.d.ts | 1 + .../types/ResourcesPicker/index.tsx | 5 +- packages/test-site/package.json | 1 + .../test-site/src/pages/landing.module.css | 327 ++++++------- .../test-site/static/img/openziti-sm-logo.svg | 64 +++ yarn.lock | 74 ++- 9 files changed, 478 insertions(+), 474 deletions(-) create mode 100644 packages/docusaurus-theme/src/vanta.d.ts create mode 100644 packages/test-site/static/img/openziti-sm-logo.svg diff --git a/packages/docusaurus-theme/css/hero-background.css b/packages/docusaurus-theme/css/hero-background.css index fea85e7..5b75434 100644 --- a/packages/docusaurus-theme/css/hero-background.css +++ b/packages/docusaurus-theme/css/hero-background.css @@ -1,16 +1 @@ -/* GPU-composited keyframes for HeroBackground SVG ring/pulse animations. - * r is set to max statically; transform: scale() starts near zero so the - * visual matches the original SMIL r 0→max behaviour. */ -@keyframes ix-pulse { - 0% { transform: scale(0.064); opacity: 0.6; } - 70% { transform: scale(1); opacity: 0; } - 100% { transform: scale(1); opacity: 0; } -} - -@keyframes pkt-ring { - 0% { transform: scale(0.111); opacity: 0.8; } - 70% { transform: scale(1); opacity: 0; } - 100% { transform: scale(1); opacity: 0; } -} - -/* Pause all CSS animations when the hero is off-screen (set by IntersectionObserver). */ +/* HeroBackground — reserved for future animation styles */ diff --git a/packages/docusaurus-theme/package.json b/packages/docusaurus-theme/package.json index f59c710..6fe4694 100644 --- a/packages/docusaurus-theme/package.json +++ b/packages/docusaurus-theme/package.json @@ -51,13 +51,17 @@ "react-dom": "^18 || ^19" }, "dependencies": { + "@docsearch/css": "^3", "@docsearch/react": "^3", + "@types/three": "^0.183.1", "algoliasearch": "^5", "clsx": "^2.0.0", "instantsearch.js": "^4", "react-device-detect": "^2.2.3", "react-github-btn": "^1.4.0", - "react-instantsearch": "^7" + "react-instantsearch": "^7", + "three": "^0.134.0", + "vanta": "^0.5.24" }, "devDependencies": { "@docusaurus/core": "^3", @@ -67,6 +71,7 @@ "@types/js-yaml": "^4.0.9", "@types/react": "^18", "@types/react-dom": "^18", + "@types/three": "^0.183.1", "jest": "^30.0.4", "react": "^18", "react-dom": "^18", diff --git a/packages/docusaurus-theme/src/components/HeroBackground.tsx b/packages/docusaurus-theme/src/components/HeroBackground.tsx index 77d88f5..22169f9 100644 --- a/packages/docusaurus-theme/src/components/HeroBackground.tsx +++ b/packages/docusaurus-theme/src/components/HeroBackground.tsx @@ -1,307 +1,187 @@ -import React, {useEffect, useMemo, useRef} from 'react'; - -// ── Canvas & palette ──────────────────────────────────────────────────────── -const W = 1400; -const H = 800; -const BG = '#020617'; -const CYAN = '#22d3ee'; -const INDIGO = '#4338ca'; -const GREEN = '#22c55e'; - -// ── Seeded LCG PRNG — SSR-safe ────────────────────────────────────────────── -function makeLCG(seed: number) { - let s = seed >>> 0; - return () => { - s = (Math.imul(s, 1664525) + 1013904223) >>> 0; - return s / 0x100000000; +import React, {useEffect, useRef} from 'react'; + +const PKT_GLOW = 'rgba(34,197,94,'; +const PKT_CORE = '#86efac'; + +// Project a Vanta 3D point onto the overlay canvas. +// vec.project(camera) returns NDC (-1..1); convert to CSS pixels. +function project(position: any, camera: any, W: number, H: number) { + const v = position.clone(); + v.project(camera); + return { + x: (v.x + 1) / 2 * W, + y: (-v.y + 1) / 2 * H, }; } -// ── Types ─────────────────────────────────────────────────────────────────── -type Vec2 = {x: number; y: number}; -type NodeData = {id: number; x: number; y: number; size: number; isHub: boolean}; -type ConnData = {id: string; from: Vec2; to: Vec2; pktDur: number; pktBegin: number}; - -// ── Segment-segment intersection (interior points only) ────────────────────── -function segIntersect(a: Vec2, b: Vec2, c: Vec2, d: Vec2): Vec2 | null { - const dx1 = b.x - a.x, dy1 = b.y - a.y; - const dx2 = d.x - c.x, dy2 = d.y - c.y; - const denom = dx1 * dy2 - dy1 * dx2; - if (Math.abs(denom) < 1e-9) return null; - const t = ((c.x - a.x) * dy2 - (c.y - a.y) * dx2) / denom; - const u = ((c.x - a.x) * dy1 - (c.y - a.y) * dx1) / denom; - if (t > 0.02 && t < 0.98 && u > 0.02 && u < 0.98) { - return {x: a.x + t * dx1, y: a.y + t * dy1}; - } - return null; +// Return true if the projected point is within the canvas bounds +function inBounds(p: {x:number;y:number}, W: number, H: number) { + return p.x >= 0 && p.x <= W && p.y >= 0 && p.y <= H; } -// ── Network parameters ─────────────────────────────────────────────────────── -const COLS = 10; -const ROWS = 6; -const K_NEAREST = 3; +type PacketState = { + fromIdx: number; toIdx: number; speed: number; t: number; + phase: 'travel' | 'pulse' | 'wait'; + pulse: number; waitUntil: number; +}; -// ── Component ─────────────────────────────────────────────────────────────── export default function HeroBackground(): React.ReactElement { - const containerRef = useRef(null); - - const {nodes, connections, packetConns, intersectionNodes} = useMemo(() => { - const rng = makeLCG(0xcafef00d); - - // ── Step 1: Stratified node placement ───────────────────────────────── - const cellW = (W - 40) / COLS; - const cellH = (H - 40) / ROWS; - const rawNodes: Vec2[] = []; - - for (let row = 0; row < ROWS; row++) { - for (let col = 0; col < COLS; col++) { - rawNodes.push({ - x: 20 + col * cellW + rng() * cellW, - y: 20 + row * cellH + rng() * cellH, - }); - } - } - - // ── Step 2: K-nearest-neighbor connections ───────────────────────────── - const degree = new Array(rawNodes.length).fill(0); - const edgeSet = new Set(); - const conns: ConnData[] = []; - let cid = 0; - - const dist2 = (a: Vec2, b: Vec2) => (b.x - a.x) ** 2 + (b.y - a.y) ** 2; - const edgeKey = (i: number, j: number) => `${Math.min(i, j)}-${Math.max(i, j)}`; - - for (let i = 0; i < rawNodes.length; i++) { - const nearest = rawNodes - .map((n, j) => ({j, d: dist2(rawNodes[i], n)})) - .filter(({j}) => j !== i) - .sort((a, b) => a.d - b.d) - .slice(0, K_NEAREST); - - for (const {j} of nearest) { - const key = edgeKey(i, j); - if (!edgeSet.has(key)) { - edgeSet.add(key); - degree[i]++; - degree[j]++; - const pktDur = 2.0 + rng() * 1.0; - conns.push({ - id: `c${cid++}`, - from: rawNodes[i], - to: rawNodes[j], - pktDur, - pktBegin: -(rng() * pktDur), - }); - } - } - } - - // ── Step 3: Build node display data ─────────────────────────────────── - const nodes: NodeData[] = rawNodes.map((p, i) => ({ - id: i, - x: p.x, - y: p.y, - size: degree[i] >= 4 ? 2 + rng() * 0.8 : 0.9 + rng() * 0.8, - isHub: degree[i] >= 4, - })); - - // 1 in 4 connections carries a visible data packet. - const packetConns = conns.filter((_, i) => i % 4 === 0); - - // ── Step 4: Geometric intersection nodes (capped at 25) ─────────────── - const intersectionNodes: Vec2[] = []; - outer: for (let i = 0; i < conns.length; i++) { - for (let j = i + 1; j < conns.length; j++) { - const pt = segIntersect(conns[i].from, conns[i].to, conns[j].from, conns[j].to); - if (!pt) continue; - if (rawNodes.some(n => (n.x - pt.x) ** 2 + (n.y - pt.y) ** 2 < 144)) continue; - if (intersectionNodes.some(n => (n.x - pt.x) ** 2 + (n.y - pt.y) ** 2 < 64)) continue; - intersectionNodes.push(pt); - if (intersectionNodes.length >= 25) break outer; - } - } - - return {nodes, connections: conns, packetConns, intersectionNodes}; - }, []); + const vantaRef = useRef(null); + const canvasRef = useRef(null); + const effectRef = useRef(null); - // ── Global mouse listener (rAF-throttled) ──────────────────────────────── useEffect(() => { - const el = containerRef.current; - if (!el) return; - let rafId: number | null = null; - const onMove = (e: MouseEvent) => { - if (rafId) return; - rafId = requestAnimationFrame(() => { - rafId = null; - const r = el.getBoundingClientRect(); - const x = e.clientX - r.left; - const y = e.clientY - r.top; - const inside = x >= 0 && x <= r.width && y >= 0 && y <= r.height; - el.style.setProperty('--mx', inside ? `${x}px` : '-400px'); - el.style.setProperty('--my', inside ? `${y}px` : '-400px'); + if (typeof window === 'undefined' || !vantaRef.current) return; + let cancelled = false; + let rafId: number; + + Promise.all([ + import('three'), + import('vanta/dist/vanta.net.min'), + ]).then(([THREE, vantaMod]) => { + if (cancelled || !vantaRef.current) return; + + const VANTA = (vantaMod as any).default ?? vantaMod; + effectRef.current = VANTA({ + el: vantaRef.current, + THREE, + mouseControls: true, + touchControls: false, + gyroControls: false, + color: 0x22d3ee, + backgroundColor: 0x020617, + points: 9, + maxDistance: 26, + spacing: 22, + showDots: true, + speed: 0.8, }); - }; - document.addEventListener('mousemove', onMove, {passive: true}); + + // Wait two frames for Vanta to finish building its points array + requestAnimationFrame(() => requestAnimationFrame(() => { + if (cancelled) return; + const canvas = canvasRef.current; + if (!canvas || !effectRef.current?.points?.length) return; + + const vPoints: any[] = effectRef.current.points; + const cam = effectRef.current.camera; + + // Pick node indices that project onto visible parts of the canvas, + // spread evenly across the point array for natural coverage + const W = canvas.clientWidth; + const H = canvas.clientHeight; + + const visible = vPoints + .map((p, i) => ({i, proj: project(p.position, cam, W, H)})) + .filter(({proj}) => inBounds(proj, W, H)); + + // Space 10 anchors evenly across the visible set + const step = Math.max(1, Math.floor(visible.length / 10)); + const anchors = visible.filter((_, k) => k % step === 0).slice(0, 10); + + if (anchors.length < 4) return; // not enough visible nodes yet + + // Build 5 packet routes between anchors spread across the canvas + const routes: {fromIdx:number; toIdx:number; speed:number; startT:number}[] = [ + {fromIdx: anchors[0].i, toIdx: anchors[Math.floor(anchors.length*0.5)].i, speed: 0.22, startT: 0.30}, + {fromIdx: anchors[anchors.length-1].i, toIdx: anchors[Math.floor(anchors.length*0.5)].i, speed: 0.20, startT: 0.70}, + {fromIdx: anchors[2].i, toIdx: anchors[anchors.length-2].i, speed: 0.25, startT: 0.10}, + {fromIdx: anchors[1].i, toIdx: anchors[Math.floor(anchors.length*0.6)].i, speed: 0.23, startT: 0.55}, + {fromIdx: anchors[Math.floor(anchors.length*0.4)].i, toIdx: anchors[anchors.length-1].i, speed: 0.21, startT: 0.45}, + ]; + + const packets: PacketState[] = routes.map(r => ({ + fromIdx: r.fromIdx, toIdx: r.toIdx, speed: r.speed, + t: r.startT, phase: 'travel', pulse: 0, waitUntil: 0, + })); + + let last = performance.now(); + + const tick = (now: number) => { + const dt = Math.min((now - last) / 1000, 0.05); + last = now; + + const dpr = window.devicePixelRatio || 1; + const cW = canvas.clientWidth; + const cH = canvas.clientHeight; + if (canvas.width !== cW * dpr || canvas.height !== cH * dpr) { + canvas.width = cW * dpr; + canvas.height = cH * dpr; + } + + const ctx = canvas.getContext('2d')!; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + ctx.clearRect(0, 0, cW, cH); + + for (const p of packets) { + // Re-project each frame so packets track nodes as they drift + const A = project(vPoints[p.fromIdx].position, cam, cW, cH); + const B = project(vPoints[p.toIdx].position, cam, cW, cH); + + if (p.phase === 'travel') { + p.t += p.speed * dt; + if (p.t >= 1) { p.t = 1; p.phase = 'pulse'; p.pulse = 0; } + + const x = A.x + (B.x - A.x) * p.t; + const y = A.y + (B.y - A.y) * p.t; + + const g = ctx.createRadialGradient(x, y, 0, x, y, 9); + g.addColorStop(0, PKT_GLOW + '0.85)'); + g.addColorStop(1, PKT_GLOW + '0)'); + ctx.beginPath(); + ctx.arc(x, y, 9, 0, Math.PI * 2); + ctx.fillStyle = g; + ctx.fill(); + + ctx.beginPath(); + ctx.arc(x, y, 2.5, 0, Math.PI * 2); + ctx.fillStyle = PKT_CORE; + ctx.fill(); + } + + if (p.phase === 'pulse') { + p.pulse += dt * 22; + const alpha = Math.max(0, 1 - p.pulse / 18); + ctx.beginPath(); + ctx.arc(B.x, B.y, p.pulse, 0, Math.PI * 2); + ctx.strokeStyle = `rgba(34,197,94,${alpha.toFixed(2)})`; + ctx.lineWidth = 1.5; + ctx.stroke(); + if (p.pulse >= 18) { + p.phase = 'wait'; + p.waitUntil = now + 700 + Math.random() * 600; + p.t = 0; + } + } + + if (p.phase === 'wait' && now >= p.waitUntil) { + p.phase = 'travel'; + p.t = 0; + } + } + + rafId = requestAnimationFrame(tick); + }; + + rafId = requestAnimationFrame(tick); + })); + }); + return () => { - document.removeEventListener('mousemove', onMove); + cancelled = true; if (rafId) cancelAnimationFrame(rafId); + if (effectRef.current) { effectRef.current.destroy(); effectRef.current = null; } }; }, []); - // ── IntersectionObserver — pause animations when off-screen ────────────── - useEffect(() => { - const el = containerRef.current; - if (!el) return; - const obs = new IntersectionObserver( - ([entry]) => { - const state = entry.isIntersecting ? 'running' : 'paused'; - el.querySelectorAll('[style*="animation"]').forEach( - child => { child.style.animationPlayState = state; } - ); - }, - {threshold: 0}, - ); - obs.observe(el); - return () => obs.disconnect(); - }, []); - return ( -