From a150841e2835d7540d725c9d84df763189094a35 Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Tue, 24 Mar 2026 21:31:29 +0000 Subject: [PATCH 1/9] landing page redesign, hero background, resources picker, product icon paths Co-Authored-By: Claude Sonnet 4.6 --- .../docusaurus-theme/css/hero-background.css | 1 + .../docusaurus-theme/css/product-picker.css | 17 +- packages/docusaurus-theme/css/theme.css | 71 ++-- packages/docusaurus-theme/package.json | 7 +- .../src/components/HeroBackground.tsx | 276 +++++++++++++++ .../NetFoundryFooter/NetFoundryFooter.tsx | 2 +- .../docusaurus-theme/src/components/index.ts | 1 + packages/docusaurus-theme/src/index.ts | 2 + packages/docusaurus-theme/src/vanta.d.ts | 1 + .../theme/NavbarItem/ComponentTypes.tsx | 2 + .../NavbarItem/types/ProductPicker/index.tsx | 312 ++++++++--------- .../types/ResourcesPicker/index.tsx | 132 +++++++ packages/test-site/docusaurus.config.ts | 30 +- packages/test-site/package.json | 1 + packages/test-site/src/custom/custom.css | 4 +- packages/test-site/src/pages/index.tsx | 6 +- .../test-site/src/pages/landing.module.css | 326 +++++++++--------- 17 files changed, 834 insertions(+), 357 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/src/vanta.d.ts create mode 100644 packages/docusaurus-theme/theme/NavbarItem/types/ResourcesPicker/index.tsx diff --git a/packages/docusaurus-theme/css/hero-background.css b/packages/docusaurus-theme/css/hero-background.css new file mode 100644 index 00000000..5b75434e --- /dev/null +++ b/packages/docusaurus-theme/css/hero-background.css @@ -0,0 +1 @@ +/* HeroBackground — reserved for future animation styles */ diff --git a/packages/docusaurus-theme/css/product-picker.css b/packages/docusaurus-theme/css/product-picker.css index 3270882f..bd109337 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: var(--ifm-font-weight-bold); } .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/css/theme.css b/packages/docusaurus-theme/css/theme.css index 0b06560f..276aa224 100644 --- a/packages/docusaurus-theme/css/theme.css +++ b/packages/docusaurus-theme/css/theme.css @@ -1,20 +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"; +/** + * 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/package.json b/packages/docusaurus-theme/package.json index f59c7104..6fe46948 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 new file mode 100644 index 00000000..94a39221 --- /dev/null +++ b/packages/docusaurus-theme/src/components/HeroBackground.tsx @@ -0,0 +1,276 @@ +import React, {useEffect, useRef} from 'react'; + +const PKT_GLOW = 'rgba(34,197,94,'; +const PKT_CORE = '#86efac'; + +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}; +} + +function inBounds(p: {x:number;y:number}, W: number, H: number) { + return p.x >= 0 && p.x <= W && p.y >= 0 && p.y <= H; +} + +type PacketState = { + fromIdx: number; toIdx: number; speed: number; t: number; + phase: 'travel' | 'pulse' | 'wait'; + pulse: number; waitUntil: number; +}; + +export default function HeroBackground(): React.ReactElement { + const vantaRef = useRef(null); + const canvasRef = useRef(null); + const effectRef = useRef(null); + const pausedRef = useRef(false); + const mouseRef = useRef({x: -1, y: -1, active: false}); + const revealRef = useRef({x: -1, y: -1}); // smoothed reveal position + + useEffect(() => { + if (typeof window === 'undefined' || !vantaRef.current) return; + let cancelled = false; + let rafId: number; + + // Global mousemove so events fire regardless of what element is on top + const onMouseMove = (e: MouseEvent) => { + const canvas = canvasRef.current; + if (!canvas) return; + const rect = canvas.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + mouseRef.current = { + x, y, + active: x >= 0 && x <= rect.width && y >= 0 && y <= rect.height, + }; + }; + const onMouseLeave = () => { mouseRef.current.active = false; }; + window.addEventListener('mousemove', onMouseMove); + document.addEventListener('mouseleave', onMouseLeave); + + const onVisibility = () => { + pausedRef.current = document.hidden; + if (effectRef.current?.renderer) { + effectRef.current.renderer.setAnimationLoop( + document.hidden ? null : () => effectRef.current?.onUpdate?.() + ); + } + }; + document.addEventListener('visibilitychange', onVisibility); + + const observer = new IntersectionObserver( + ([entry]) => { + pausedRef.current = !entry.isIntersecting; + if (effectRef.current?.renderer) { + effectRef.current.renderer.setAnimationLoop( + entry.isIntersecting ? () => effectRef.current?.onUpdate?.() : null + ); + } + }, + {threshold: 0} + ); + if (vantaRef.current) observer.observe(vantaRef.current); + + Promise.all([ + import('three'), + import('vanta/dist/vanta.net.min'), + ]).then(([THREE, vantaMod]) => { + if (cancelled || !vantaRef.current) return; + if (effectRef.current) { effectRef.current.destroy(); effectRef.current = null; } + const VANTA = (vantaMod as any).default ?? vantaMod; + effectRef.current = VANTA({ + el: vantaRef.current, + THREE, + mouseControls: false, + touchControls: false, + gyroControls: false, + color: 0x22d3ee, + backgroundColor: 0x020617, + points: 7, + maxDistance: 26, + spacing: 22, + showDots: true, + speed: 0.8, + }); + + 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; + const W = canvas.clientWidth; + const H = canvas.clientHeight; + + // Only use edges well inside maxDistance so they stay connected as nodes drift + const SAFE_DIST = 18; + + // Pick a single fresh valid edge: both endpoints on-screen, firmly connected + const pickEdge = (): {a: number; b: number} | null => { + const cW2 = canvas.clientWidth; + const cH2 = canvas.clientHeight; + const candidates: {a:number; b:number; len:number}[] = []; + for (let i = 0; i < vPoints.length; i++) { + for (let j = i + 1; j < vPoints.length; j++) { + const dx = vPoints[i].position.x - vPoints[j].position.x; + const dy = vPoints[i].position.y - vPoints[j].position.y; + const dz = vPoints[i].position.z - vPoints[j].position.z; + if (Math.sqrt(dx*dx + dy*dy + dz*dz) > SAFE_DIST) continue; + const pa = project(vPoints[i].position, cam, cW2, cH2); + const pb = project(vPoints[j].position, cam, cW2, cH2); + if (!inBounds(pa, W, H) || !inBounds(pb, W, H)) continue; + const len = Math.sqrt((pa.x-pb.x)**2 + (pa.y-pb.y)**2); + if (len > 20) candidates.push({a: i, b: j, len}); // skip tiny hops + } + } + if (!candidates.length) return null; + candidates.sort((x, y) => y.len - x.len); + // Pick randomly from top half so we get variety + const pool = candidates.slice(0, Math.max(1, Math.floor(candidates.length * 0.5))); + return pool[Math.floor(Math.random() * pool.length)]; + }; + + const speeds = [0.22, 0.20, 0.25, 0.23, 0.21, 0.24, 0.19]; + const startTs = [0.30, 0.70, 0.10, 0.55, 0.45, 0.80, 0.20]; + const initialEdges = Array.from({length: 7}, () => pickEdge()); + const routes = initialEdges + .map((e, i) => e ? {fromIdx: e.a, toIdx: e.b, speed: speeds[i], startT: startTs[i]} : null) + .filter(Boolean) as {fromIdx:number; toIdx:number; speed:number; startT:number}[]; + + 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) => { + rafId = requestAnimationFrame(tick); + if (pausedRef.current) return; + + 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); + + // ── Flashlight reveal ──────────────────────────────────────────── + // Dark overlay — always present + ctx.fillStyle = 'rgba(2,6,23,0.85)'; + ctx.fillRect(0, 0, cW, cH); + + // Only cut the hole when the mouse is inside the hero + if (mouseRef.current.active) { + if (revealRef.current.x < 0) { + revealRef.current.x = mouseRef.current.x; + revealRef.current.y = mouseRef.current.y; + } + revealRef.current.x += (mouseRef.current.x - revealRef.current.x) * 0.1; + revealRef.current.y += (mouseRef.current.y - revealRef.current.y) * 0.1; + + ctx.globalCompositeOperation = 'destination-out'; + const hole = ctx.createRadialGradient( + revealRef.current.x, revealRef.current.y, 0, + revealRef.current.x, revealRef.current.y, 180 + ); + hole.addColorStop(0, 'rgba(0,0,0,1)'); + hole.addColorStop(0.6, 'rgba(0,0,0,0.85)'); + hole.addColorStop(1, 'rgba(0,0,0,0)'); + ctx.fillStyle = hole; + ctx.fillRect(0, 0, cW, cH); + ctx.globalCompositeOperation = 'source-over'; + } + + // ── Packets drawn after overlay so destination-out doesn't erase them ── + // Reveal factor dims orbs outside the flashlight to match the mesh behavior. + const revealActive = mouseRef.current.active && revealRef.current.x >= 0; + const HOLE_R = 180; + const revealFactor = (px: number, py: number): number => { + if (!revealActive) return 0.28; + const d = Math.sqrt((px - revealRef.current.x) ** 2 + (py - revealRef.current.y) ** 2); + if (d < HOLE_R * 0.6) return 1; + if (d > HOLE_R) return 0.28; + const t = (d - HOLE_R * 0.6) / (HOLE_R * 0.4); + return 1 - t * 0.72; + }; + + for (const p of packets) { + 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 rf = revealFactor(x, y); + const g = ctx.createRadialGradient(x, y, 0, x, y, 7); + g.addColorStop(0, PKT_GLOW + (0.85 * rf).toFixed(2) + ')'); + g.addColorStop(1, PKT_GLOW + '0)'); + ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI*2); + ctx.fillStyle = g; ctx.fill(); + ctx.globalAlpha = rf; + ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI*2); + ctx.fillStyle = PKT_CORE; ctx.fill(); + ctx.globalAlpha = 1; + } + + if (p.phase === 'pulse') { + p.pulse += dt * 22; + const alpha = Math.max(0, 1 - p.pulse / 18); + const rf = revealFactor(B.x, B.y); + ctx.beginPath(); ctx.arc(B.x, B.y, p.pulse, 0, Math.PI*2); + ctx.strokeStyle = `rgba(34,197,94,${(alpha * rf).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) { + const next = pickEdge(); + if (next) { p.fromIdx = next.a; p.toIdx = next.b; } + p.phase = 'travel'; p.t = 0; + } + } + + }; + + rafId = requestAnimationFrame(tick); + })); + }); + + return () => { + cancelled = true; + window.removeEventListener('mousemove', onMouseMove); + document.removeEventListener('mouseleave', onMouseLeave); + document.removeEventListener('visibilitychange', onVisibility); + observer.disconnect(); + if (rafId) cancelAnimationFrame(rafId); + if (effectRef.current) { effectRef.current.destroy(); effectRef.current = null; } + }; + }, []); + + return ( +
+
+
+ ); +} diff --git a/packages/docusaurus-theme/src/components/NetFoundryFooter/NetFoundryFooter.tsx b/packages/docusaurus-theme/src/components/NetFoundryFooter/NetFoundryFooter.tsx index 102de0cc..f0f86e0d 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/src/components/index.ts b/packages/docusaurus-theme/src/components/index.ts index 744424ac..b66dafd4 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 b3d7eeb5..359dce66 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/src/vanta.d.ts b/packages/docusaurus-theme/src/vanta.d.ts new file mode 100644 index 00000000..d5f2f923 --- /dev/null +++ b/packages/docusaurus-theme/src/vanta.d.ts @@ -0,0 +1 @@ +declare module 'vanta/dist/vanta.net.min'; diff --git a/packages/docusaurus-theme/theme/NavbarItem/ComponentTypes.tsx b/packages/docusaurus-theme/theme/NavbarItem/ComponentTypes.tsx index 0053ff05..33403a87 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 @@ -11,4 +12,5 @@ const ComponentTypesOrig = require('@theme-init/NavbarItem/ComponentTypes').defa export default { ...ComponentTypesOrig, '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 0671509d..6c54c2de 100644 --- a/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx +++ b/packages/docusaurus-theme/theme/NavbarItem/types/ProductPicker/index.tsx @@ -1,156 +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 {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-primary', 'picker-header--nf-secondary', 'picker-header--nf-tertiary']; -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/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}} -
- - ))} -
- ))} -
-
- )} -
- ); -} +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/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 new file mode 100644 index 00000000..264baae6 --- /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-tertiary', + links: [ + { label: 'NetFoundry Blog', description: 'Latest news, updates, and insights from NetFoundry.', href: 'https://netfoundry.io/blog/', logoSrc: consoleLogo }, + { label: 'OpenZiti Tech 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 46610321..d3e2438d 100644 --- a/packages/test-site/docusaurus.config.ts +++ b/packages/test-site/docusaurus.config.ts @@ -183,31 +183,43 @@ 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 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/package.json b/packages/test-site/package.json index 815e9be9..79b28b49 100644 --- a/packages/test-site/package.json +++ b/packages/test-site/package.json @@ -18,6 +18,7 @@ "@docusaurus/preset-classic": "3.9.2", "@docusaurus/theme-common": "3.9.2", "@docusaurus/theme-mermaid": "^3.9.2", + "@docusaurus/theme-search-algolia": "3.9.2", "@docusaurus/utils": "3.9.2", "@mdx-js/react": "^3.0.0", "@netfoundry/docusaurus-theme": "^0.1.2", diff --git a/packages/test-site/src/custom/custom.css b/packages/test-site/src/custom/custom.css index ca8a8153..53b3ef4b 100644 --- a/packages/test-site/src/custom/custom.css +++ b/packages/test-site/src/custom/custom.css @@ -1,6 +1,5 @@ @import '@netfoundry/docusaurus-theme/css/tabs-v8-float.css'; -@import '@netfoundry/docusaurus-theme/css/layout.css'; :root { --ifm-navbar-height: 60px; @@ -59,6 +58,9 @@ body, display: flex; flex-direction: column; + article { + padding-bottom: 2em; + } > div { max-width: var(--ziti-landing-max-width); width: 100%; diff --git a/packages/test-site/src/pages/index.tsx b/packages/test-site/src/pages/index.tsx index 8c4367f0..93a12a02 100644 --- a/packages/test-site/src/pages/index.tsx +++ b/packages/test-site/src/pages/index.tsx @@ -3,6 +3,7 @@ import Layout from '@theme/Layout'; import Link from '@docusaurus/Link'; import clsx from 'clsx'; import styles from './landing.module.css'; +import {HeroBackground} from '@netfoundry/docusaurus-theme/ui'; const CYAN = '#22d3ee'; const GREEN = '#22c55e'; @@ -47,12 +48,13 @@ export default function Home(): JSX.Element { return (
+

NetFoundry Docs

-

Secure, high-performance networking for the modern era.

+

Secure your workloads with Identity-First Connectivity™

Get Started - Request Demo + Request Demo
diff --git a/packages/test-site/src/pages/landing.module.css b/packages/test-site/src/pages/landing.module.css index f7a749bf..8b5828f4 100644 --- a/packages/test-site/src/pages/landing.module.css +++ b/packages/test-site/src/pages/landing.module.css @@ -1,160 +1,166 @@ -.nf-hero-stage { - position: relative; width: 100%; min-height: 370px; - display: flex; align-items: center; justify-content: center; - overflow: hidden; text-align: center; background: #020617; z-index: 4; -} -.nf-hero-stage::after { - content: ''; position: absolute; bottom: 0; left: 0; right: 0; - height: 220px; background: linear-gradient(to bottom, transparent 0%, #0f172a 100%); - pointer-events: none; z-index: 1; -} -:global([data-theme='light']) .nf-hero-stage::after { - height: 80px; - background: linear-gradient(to bottom, transparent 0%, #020617 100%); -} -.nf-hero-overlay { display: none; } -.nf-hero-content { - position: relative; z-index: 2; padding: 2.5rem 3.5rem; - background: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; -} -.nf-hero-title { - font-size: 4rem; font-weight: 900; color: #ffffff; margin-bottom: 0.75rem; - letter-spacing: -0.02em; line-height: 1.05; - text-shadow: 0 0 20px rgba(34, 211, 238, 0.8), 0 2px 12px rgba(0, 0, 0, 0.9); -} -.nf-green-text { - background: linear-gradient(to right, #22c55e 0%, #86efac 100%); - -webkit-background-clip: text; background-clip: text; - -webkit-text-fill-color: transparent; display: inline-block; -} -.nf-hero-subtext { - color: rgba(203, 213, 225, 0.95); font-size: 1.15rem; max-width: 560px; - margin: 0 auto 2rem; line-height: 1.65; text-shadow: 0 1px 8px rgba(0, 0, 0, 0.95); -} -.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; - 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; -} -.nf-btn-ghost { - display: inline-flex; align-items: center; padding: 0.65rem 1.75rem; - 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; -} - -.nf-features-section { width: 100%; background: #0f172a; padding: 5rem 0 2.5rem; } -:global([data-theme='light']) .nf-features-section { - background: linear-gradient(to bottom, - #020617 0px, #020617 80px, #404350 18%, #7d808a 28%, - #b8bbc1 38%, #e4e7ea 48%, #f8fafc 100%); -} - -.nf-bento-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } - -.nf-bento-divider { - grid-column: 1 / -1; display: flex; align-items: center; gap: 1rem; - padding: 1.5rem 0 0.65rem; color: #94a3b8; font-size: 0.82rem; font-weight: 800; - letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; -} -.nf-bento-divider::before, .nf-bento-divider::after { - content: ''; flex: 1; height: 1px; background: rgba(148, 163, 184, 0.2); -} -.nf-divider--top { padding-top: 0; } -.nf-divider--managed { - color: #22d3ee; font-size: 1.05rem; letter-spacing: 0.15em; - text-shadow: 0 0 18px rgba(34, 211, 238, 0.5); -} -.nf-divider--managed::before, .nf-divider--managed::after { background: rgba(34, 211, 238, 0.4); height: 2px; } -:global([data-theme='light']) .nf-bento-divider { color: #64748b; } -:global([data-theme='light']) .nf-bento-divider::before, -:global([data-theme='light']) .nf-bento-divider::after { background: rgba(100, 116, 139, 0.25); } -:global([data-theme='light']) .nf-divider--managed { color: #0891b2; text-shadow: none; } -:global([data-theme='light']) .nf-divider--managed::before, -:global([data-theme='light']) .nf-divider--managed::after { background: rgba(8, 145, 178, 0.35); height: 2px; } - -.nf-pair { display: flex; flex-direction: column; } -.nf-pair-connector { - display: flex; align-items: center; gap: 1rem; padding: 0.5rem 0; - font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; - text-transform: uppercase; color: #94a3b8; -} -.nf-pair-connector::before, .nf-pair-connector::after { - content: ''; flex: 1; height: 1px; background: rgba(148, 163, 184, 0.2); -} -:global([data-theme='light']) .nf-pair-connector { color: #64748b; } -:global([data-theme='light']) .nf-pair-connector::before, -:global([data-theme='light']) .nf-pair-connector::after { background: rgba(148, 163, 184, 0.35); } - -.nf-bento-wrap { display: flex; flex-direction: column; } - -.nf-bento-card { - position: relative; display: flex; flex-direction: column; flex: 1; - padding: 1rem; border-radius: 12px; text-decoration: none; - background: #1a1b2e; border: 1px solid rgba(148, 163, 184, 0.1); - border-top: 2px solid #22d3ee; - box-shadow: 0 1px 3px rgba(0,0,0,0.3), 0 6px 20px rgba(0,0,0,0.3), 0 16px 40px rgba(0,0,0,0.2); - 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; - 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); -} -: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); -} -:global([data-theme='light']) .nf-bento-card:hover { - box-shadow: 0 2px 6px rgba(0,0,0,0.09), 0 8px 24px rgba(0,0,0,0.07), 0 0 0 1px rgba(34,197,94,0.18); -} -:global([data-theme='light']) .nf-bento-card--accent-cyan { border-top-color: #0891b2; } -:global([data-theme='light']) .nf-bento-card--accent-green { border-top-color: #16a34a; } -.nf-bento-card--featured { padding: 1.25rem; border-top-width: 3px; } -.nf-bento-card--featured .nf-card-logo { width: 48px; height: 48px; } -.nf-bento-card--featured .nf-card-header h3 { font-size: 1.25rem; } - -.nf-card-badge { - position: absolute; top: 1rem; right: 1rem; display: inline-flex; width: fit-content; - background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); - font-size: 0.6rem; font-weight: 800; letter-spacing: 0.1em; - padding: 2px 8px; border-radius: 4px; text-transform: uppercase; -} -:global([data-theme='light']) .nf-card-badge { color: #15803d; border-color: rgba(34,197,94,0.25); } - -.nf-card-header { - display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; padding-right: 3rem; -} -.nf-card-header h3 { margin: 0; } -.nf-bento-card h3 { color: #f1f5f9; font-weight: 900; font-size: 1.05rem; line-height: 1.3; letter-spacing: -0.02em; } -:global([data-theme='light']) .nf-bento-card h3 { color: #0f172a; } -.nf-bento-card p { color: #94a3b8; font-size: 0.875rem; line-height: 1.65; flex-grow: 1; margin: 0 0 0.5rem; } -:global([data-theme='light']) .nf-bento-card p { color: #475569; } - -.nf-bento-features { list-style: none; padding: 0; margin: 0 0 0.5rem; display: flex; flex-direction: column; gap: 0.25rem; } -.nf-bento-features li { display: flex; align-items: center; gap: 0.4rem; font-size: 0.775rem; color: #64748b; } -.nf-bento-features li::before { content: '✓'; color: #22c55e; font-weight: 800; font-size: 0.75rem; flex-shrink: 0; } -:global([data-theme='light']) .nf-bento-features li::before { color: #16a34a; } - -.nf-card-link { color: #22d3ee; font-size: 0.8rem; font-weight: 700; margin-top: auto; padding-top: 0.5rem; letter-spacing: 0.03em; } -:global([data-theme='light']) .nf-card-link { color: #0284c7; } -.nf-card-logo { width: 40px; height: 40px; object-fit: contain; flex-shrink: 0; } - -@media (max-width: 996px) { - .nf-hero-title { font-size: 2.2rem; } - .nf-hero-content { padding: 2rem 1.25rem; } - .nf-hero-ctas { flex-wrap: wrap; } - .nf-btn-primary, .nf-btn-ghost { padding: 0.55rem 1.25rem; font-size: 0.9rem; } -} -@media (max-width: 640px) { - .nf-bento-grid { grid-template-columns: 1fr; } -} +.nf-hero-stage { + position: relative; width: 100%; min-height: 370px; + display: flex; align-items: center; justify-content: center; + overflow: hidden; text-align: center; background: #020617; z-index: 4; +} +.nf-hero-stage::after { + content: ''; position: absolute; bottom: 0; left: 0; right: 0; + height: 220px; background: linear-gradient(to bottom, transparent 0%, #0f172a 100%); + pointer-events: none; z-index: 1; +} +:global([data-theme='light']) .nf-hero-stage::after { + height: 80px; + background: linear-gradient(to bottom, transparent 0%, #020617 100%); +} +.nf-hero-overlay { display: none; } +.nf-hero-content { + position: relative; z-index: 2; padding: 2.5rem 3.5rem; + background: transparent; backdrop-filter: none; -webkit-backdrop-filter: none; +} +.nf-hero-title { + font-size: 4rem; font-weight: 900; color: #ffffff; margin-bottom: 0.75rem; + letter-spacing: -0.02em; line-height: 1.05; + text-shadow: 0 0 20px rgba(34, 211, 238, 0.75), 0 2px 12px rgba(0, 0, 0, 0.9); +} +.nf-green-text { + color: #22c55e; + font-family: inherit; font-weight: inherit; font-size: inherit; + text-shadow: 0 0 20px rgba(34, 197, 94, 0.75), 0 2px 12px rgba(0, 0, 0, 0.9); +} +.nf-hero-subtext { + color: rgba(203, 213, 225, 0.95); font-size: 1.15rem; max-width: 560px; + margin: 0 auto 2rem; line-height: 1.65; text-shadow: 0 1px 8px rgba(0, 0, 0, 0.95); +} +.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; + 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; +} +.nf-btn-ghost { + display: inline-flex; align-items: center; padding: 0.65rem 1.75rem; + 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; text-decoration: none; +} + +:global([data-theme='dark']) .nf-btn-primary, +:global([data-theme='dark']) .nf-btn-primary:hover, +:global([data-theme='dark']) .nf-btn-ghost, +:global([data-theme='dark']) .nf-btn-ghost:hover { color: #ffffff; } + +.nf-features-section { width: 100%; background: #0f172a; padding: 5rem 0 2.5rem; } +:global([data-theme='light']) .nf-features-section { + background: linear-gradient(to bottom, + #020617 0px, #020617 80px, #404350 18%, #7d808a 28%, + #b8bbc1 38%, #e4e7ea 48%, #f8fafc 100%); +} + +.nf-bento-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; } + +.nf-bento-divider { + grid-column: 1 / -1; display: flex; align-items: center; gap: 1rem; + padding: 1.5rem 0 0.65rem; color: #94a3b8; font-size: 0.82rem; font-weight: 800; + letter-spacing: 0.12em; text-transform: uppercase; white-space: nowrap; +} +.nf-bento-divider::before, .nf-bento-divider::after { + content: ''; flex: 1; height: 1px; background: rgba(148, 163, 184, 0.2); +} +.nf-divider--top { padding-top: 0; } +.nf-divider--managed { + color: #22d3ee; font-size: 1.05rem; letter-spacing: 0.15em; + text-shadow: 0 0 18px rgba(34, 211, 238, 0.5); +} +.nf-divider--managed::before, .nf-divider--managed::after { background: rgba(34, 211, 238, 0.4); height: 2px; } +:global([data-theme='light']) .nf-bento-divider { color: #64748b; } +:global([data-theme='light']) .nf-bento-divider::before, +:global([data-theme='light']) .nf-bento-divider::after { background: rgba(100, 116, 139, 0.25); } +:global([data-theme='light']) .nf-divider--managed { color: #0891b2; text-shadow: none; } +:global([data-theme='light']) .nf-divider--managed::before, +:global([data-theme='light']) .nf-divider--managed::after { background: rgba(8, 145, 178, 0.35); height: 2px; } + +.nf-pair { display: flex; flex-direction: column; } +.nf-pair-connector { + display: flex; align-items: center; gap: 1rem; padding: 0.5rem 0; + font-size: 0.7rem; font-weight: 800; letter-spacing: 0.12em; + text-transform: uppercase; color: #94a3b8; +} +.nf-pair-connector::before, .nf-pair-connector::after { + content: ''; flex: 1; height: 1px; background: rgba(148, 163, 184, 0.2); +} +:global([data-theme='light']) .nf-pair-connector { color: #64748b; } +:global([data-theme='light']) .nf-pair-connector::before, +:global([data-theme='light']) .nf-pair-connector::after { background: rgba(148, 163, 184, 0.35); } + +.nf-bento-wrap { display: flex; flex-direction: column; } + +.nf-bento-card { + position: relative; display: flex; flex-direction: column; flex: 1; + padding: 1rem; border-radius: 12px; text-decoration: none; + background: #1a1b2e; border: 1px solid rgba(148, 163, 184, 0.1); + border-top: 2px solid #22d3ee; + box-shadow: 0 1px 3px rgba(0,0,0,0.3), 0 6px 20px rgba(0,0,0,0.3), 0 16px 40px rgba(0,0,0,0.2); + 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; 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); +} +:global([data-theme='light']) .nf-bento-card:hover { + box-shadow: 0 2px 6px rgba(0,0,0,0.09), 0 8px 24px rgba(0,0,0,0.07), 0 0 0 1px rgba(34,197,94,0.18); +} +:global([data-theme='light']) .nf-bento-card--accent-cyan { border-top-color: #0891b2; } +:global([data-theme='light']) .nf-bento-card--accent-green { border-top-color: #16a34a; } +.nf-bento-card--featured { padding: 1.25rem; border-top-width: 3px; } +.nf-bento-card--featured .nf-card-logo { width: 48px; height: 48px; } +.nf-bento-card--featured .nf-card-header h3 { font-size: 1.25rem; } + +.nf-card-badge { + position: absolute; top: 1rem; right: 1rem; display: inline-flex; width: fit-content; + background: rgba(34,197,94,0.1); color: #4ade80; border: 1px solid rgba(34,197,94,0.2); + font-size: 0.6rem; font-weight: 800; letter-spacing: 0.1em; + padding: 2px 8px; border-radius: 4px; text-transform: uppercase; +} +:global([data-theme='light']) .nf-card-badge { color: #15803d; border-color: rgba(34,197,94,0.25); } + +.nf-card-header { + display: flex; align-items: center; gap: 0.5rem; margin-bottom: 0.5rem; padding-right: 3rem; +} +.nf-card-header h3 { margin: 0; } +.nf-bento-card h3 { color: #f1f5f9; font-weight: 900; font-size: 1.05rem; line-height: 1.3; letter-spacing: -0.02em; } +:global([data-theme='light']) .nf-bento-card h3 { color: #0f172a; } +.nf-bento-card p { color: #94a3b8; font-size: 0.875rem; line-height: 1.65; flex-grow: 1; margin: 0 0 0.5rem; } +:global([data-theme='light']) .nf-bento-card p { color: #475569; } + +.nf-bento-features { list-style: none; padding: 0; margin: 0 0 0.5rem; display: flex; flex-direction: column; gap: 0.25rem; } +.nf-bento-features li { display: flex; align-items: center; gap: 0.4rem; font-size: 0.775rem; color: #64748b; } +.nf-bento-features li::before { content: '✓'; color: #22c55e; font-weight: 800; font-size: 0.75rem; flex-shrink: 0; } +:global([data-theme='light']) .nf-bento-features li::before { color: #16a34a; } + +.nf-card-link { color: #22d3ee; font-size: 0.8rem; font-weight: 700; margin-top: auto; padding-top: 0.5rem; letter-spacing: 0.03em; } +:global([data-theme='light']) .nf-card-link { color: #0284c7; } +.nf-card-logo { width: 40px; height: 40px; object-fit: contain; flex-shrink: 0; } + +@media (max-width: 996px) { + .nf-hero-title { font-size: 2.2rem; } + .nf-hero-content { padding: 2rem 1.25rem; } + .nf-hero-ctas { flex-wrap: wrap; } + .nf-btn-primary, .nf-btn-ghost { padding: 0.55rem 1.25rem; font-size: 0.9rem; } +} +@media (max-width: 640px) { + .nf-bento-grid { grid-template-columns: 1fr; } +} From 3eb48f9b69d62c2e9e67fc06a0c13999f67ae009 Mon Sep 17 00:00:00 2001 From: Nico Alba Date: Fri, 27 Mar 2026 00:55:47 +0000 Subject: [PATCH 2/9] new hero --- .../docusaurus-theme/css/product-picker.css | 23 ++ .../src/components/HeroBackground.module.css | 57 ++++ .../src/components/HeroBackground.tsx | 276 +----------------- .../NetFoundryFooter/NetFoundryFooter.tsx | 4 +- .../NetFoundryFooter/styles.module.css | 2 +- .../theme/NavbarItem/ComponentTypes.tsx | 2 + .../NavbarItem/types/IconLinks/index.tsx | 50 ++++ packages/test-site/docusaurus.config.ts | 3 +- .../static/img/openziti-sm-logo.svg | 64 ++++ .../zlan/docusaurus/static/img/zlan-logo.svg | 54 ++++ packages/test-site/src/custom/custom.css | 12 + packages/test-site/src/pages/index.tsx | 2 +- 12 files changed, 274 insertions(+), 275 deletions(-) create mode 100644 packages/docusaurus-theme/src/components/HeroBackground.module.css create mode 100644 packages/docusaurus-theme/theme/NavbarItem/types/IconLinks/index.tsx create mode 100644 packages/test-site/remotes/openziti/docusaurus/static/img/openziti-sm-logo.svg create mode 100644 packages/test-site/remotes/zlan/docusaurus/static/img/zlan-logo.svg diff --git a/packages/docusaurus-theme/css/product-picker.css b/packages/docusaurus-theme/css/product-picker.css index bd109337..1be6a429 100644 --- a/packages/docusaurus-theme/css/product-picker.css +++ b/packages/docusaurus-theme/css/product-picker.css @@ -186,6 +186,29 @@ .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; } +/* ── Navbar icon links (GitHub, Discourse) ──────────────────────────────── */ +.nf-icon-links { + display: flex; + align-items: center; + gap: 0.1rem; +} + +.nf-icon-link, +.nf-icon-link:visited { + display: flex; + align-items: center; + justify-content: center; + width: 2rem; + height: 2rem; + color: var(--ifm-navbar-link-color); + text-decoration: none; +} + +.nf-icon-link:hover { + color: var(--ifm-color-primary); + text-decoration: none; +} + /* ── Mobile (<= 996 px) ─────────────────────────────────────────────────── */ @media (max-width: 996px) { /* Fixed grid makes no sense in the narrow sidebar — hide the raw HTML blob */ diff --git a/packages/docusaurus-theme/src/components/HeroBackground.module.css b/packages/docusaurus-theme/src/components/HeroBackground.module.css new file mode 100644 index 00000000..20ee16d1 --- /dev/null +++ b/packages/docusaurus-theme/src/components/HeroBackground.module.css @@ -0,0 +1,57 @@ +.bg { + position: absolute; + inset: 0; + background-color: #020617; + background-image: + repeating-linear-gradient(0deg, transparent, transparent 59px, rgba(34, 211, 238, 0.04) 59px, rgba(34, 211, 238, 0.04) 60px), + repeating-linear-gradient(90deg, transparent, transparent 59px, rgba(34, 211, 238, 0.04) 59px, rgba(34, 211, 238, 0.04) 60px); + overflow: hidden; +} + +.orb { + position: absolute; + border-radius: 50%; + filter: blur(80px); +} + +.orb1 { + width: 55%; + height: 75%; + background: radial-gradient(circle, rgba(34, 211, 238, 0.35) 0%, transparent 70%); + top: -15%; + left: -10%; + animation: drift1 14s ease-in-out infinite alternate; +} + +.orb2 { + width: 45%; + height: 65%; + background: radial-gradient(circle, rgba(0, 104, 249, 0.28) 0%, transparent 70%); + top: 20%; + right: -10%; + animation: drift2 18s ease-in-out infinite alternate; +} + +.orb3 { + width: 40%; + height: 55%; + background: radial-gradient(circle, rgba(34, 197, 94, 0.18) 0%, transparent 70%); + bottom: -15%; + left: 25%; + animation: drift3 16s ease-in-out infinite alternate; +} + +@keyframes drift1 { + from { transform: translate(0, 0); } + to { transform: translate(18%, 22%); } +} + +@keyframes drift2 { + from { transform: translate(0, 0); } + to { transform: translate(-22%, 18%); } +} + +@keyframes drift3 { + from { transform: translate(0, 0); } + to { transform: translate(12%, -28%); } +} diff --git a/packages/docusaurus-theme/src/components/HeroBackground.tsx b/packages/docusaurus-theme/src/components/HeroBackground.tsx index 94a39221..40b7361b 100644 --- a/packages/docusaurus-theme/src/components/HeroBackground.tsx +++ b/packages/docusaurus-theme/src/components/HeroBackground.tsx @@ -1,276 +1,12 @@ -import React, {useEffect, useRef} from 'react'; - -const PKT_GLOW = 'rgba(34,197,94,'; -const PKT_CORE = '#86efac'; - -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}; -} - -function inBounds(p: {x:number;y:number}, W: number, H: number) { - return p.x >= 0 && p.x <= W && p.y >= 0 && p.y <= H; -} - -type PacketState = { - fromIdx: number; toIdx: number; speed: number; t: number; - phase: 'travel' | 'pulse' | 'wait'; - pulse: number; waitUntil: number; -}; +import React from 'react'; +import styles from './HeroBackground.module.css'; export default function HeroBackground(): React.ReactElement { - const vantaRef = useRef(null); - const canvasRef = useRef(null); - const effectRef = useRef(null); - const pausedRef = useRef(false); - const mouseRef = useRef({x: -1, y: -1, active: false}); - const revealRef = useRef({x: -1, y: -1}); // smoothed reveal position - - useEffect(() => { - if (typeof window === 'undefined' || !vantaRef.current) return; - let cancelled = false; - let rafId: number; - - // Global mousemove so events fire regardless of what element is on top - const onMouseMove = (e: MouseEvent) => { - const canvas = canvasRef.current; - if (!canvas) return; - const rect = canvas.getBoundingClientRect(); - const x = e.clientX - rect.left; - const y = e.clientY - rect.top; - mouseRef.current = { - x, y, - active: x >= 0 && x <= rect.width && y >= 0 && y <= rect.height, - }; - }; - const onMouseLeave = () => { mouseRef.current.active = false; }; - window.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseleave', onMouseLeave); - - const onVisibility = () => { - pausedRef.current = document.hidden; - if (effectRef.current?.renderer) { - effectRef.current.renderer.setAnimationLoop( - document.hidden ? null : () => effectRef.current?.onUpdate?.() - ); - } - }; - document.addEventListener('visibilitychange', onVisibility); - - const observer = new IntersectionObserver( - ([entry]) => { - pausedRef.current = !entry.isIntersecting; - if (effectRef.current?.renderer) { - effectRef.current.renderer.setAnimationLoop( - entry.isIntersecting ? () => effectRef.current?.onUpdate?.() : null - ); - } - }, - {threshold: 0} - ); - if (vantaRef.current) observer.observe(vantaRef.current); - - Promise.all([ - import('three'), - import('vanta/dist/vanta.net.min'), - ]).then(([THREE, vantaMod]) => { - if (cancelled || !vantaRef.current) return; - if (effectRef.current) { effectRef.current.destroy(); effectRef.current = null; } - const VANTA = (vantaMod as any).default ?? vantaMod; - effectRef.current = VANTA({ - el: vantaRef.current, - THREE, - mouseControls: false, - touchControls: false, - gyroControls: false, - color: 0x22d3ee, - backgroundColor: 0x020617, - points: 7, - maxDistance: 26, - spacing: 22, - showDots: true, - speed: 0.8, - }); - - 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; - const W = canvas.clientWidth; - const H = canvas.clientHeight; - - // Only use edges well inside maxDistance so they stay connected as nodes drift - const SAFE_DIST = 18; - - // Pick a single fresh valid edge: both endpoints on-screen, firmly connected - const pickEdge = (): {a: number; b: number} | null => { - const cW2 = canvas.clientWidth; - const cH2 = canvas.clientHeight; - const candidates: {a:number; b:number; len:number}[] = []; - for (let i = 0; i < vPoints.length; i++) { - for (let j = i + 1; j < vPoints.length; j++) { - const dx = vPoints[i].position.x - vPoints[j].position.x; - const dy = vPoints[i].position.y - vPoints[j].position.y; - const dz = vPoints[i].position.z - vPoints[j].position.z; - if (Math.sqrt(dx*dx + dy*dy + dz*dz) > SAFE_DIST) continue; - const pa = project(vPoints[i].position, cam, cW2, cH2); - const pb = project(vPoints[j].position, cam, cW2, cH2); - if (!inBounds(pa, W, H) || !inBounds(pb, W, H)) continue; - const len = Math.sqrt((pa.x-pb.x)**2 + (pa.y-pb.y)**2); - if (len > 20) candidates.push({a: i, b: j, len}); // skip tiny hops - } - } - if (!candidates.length) return null; - candidates.sort((x, y) => y.len - x.len); - // Pick randomly from top half so we get variety - const pool = candidates.slice(0, Math.max(1, Math.floor(candidates.length * 0.5))); - return pool[Math.floor(Math.random() * pool.length)]; - }; - - const speeds = [0.22, 0.20, 0.25, 0.23, 0.21, 0.24, 0.19]; - const startTs = [0.30, 0.70, 0.10, 0.55, 0.45, 0.80, 0.20]; - const initialEdges = Array.from({length: 7}, () => pickEdge()); - const routes = initialEdges - .map((e, i) => e ? {fromIdx: e.a, toIdx: e.b, speed: speeds[i], startT: startTs[i]} : null) - .filter(Boolean) as {fromIdx:number; toIdx:number; speed:number; startT:number}[]; - - 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) => { - rafId = requestAnimationFrame(tick); - if (pausedRef.current) return; - - 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); - - // ── Flashlight reveal ──────────────────────────────────────────── - // Dark overlay — always present - ctx.fillStyle = 'rgba(2,6,23,0.85)'; - ctx.fillRect(0, 0, cW, cH); - - // Only cut the hole when the mouse is inside the hero - if (mouseRef.current.active) { - if (revealRef.current.x < 0) { - revealRef.current.x = mouseRef.current.x; - revealRef.current.y = mouseRef.current.y; - } - revealRef.current.x += (mouseRef.current.x - revealRef.current.x) * 0.1; - revealRef.current.y += (mouseRef.current.y - revealRef.current.y) * 0.1; - - ctx.globalCompositeOperation = 'destination-out'; - const hole = ctx.createRadialGradient( - revealRef.current.x, revealRef.current.y, 0, - revealRef.current.x, revealRef.current.y, 180 - ); - hole.addColorStop(0, 'rgba(0,0,0,1)'); - hole.addColorStop(0.6, 'rgba(0,0,0,0.85)'); - hole.addColorStop(1, 'rgba(0,0,0,0)'); - ctx.fillStyle = hole; - ctx.fillRect(0, 0, cW, cH); - ctx.globalCompositeOperation = 'source-over'; - } - - // ── Packets drawn after overlay so destination-out doesn't erase them ── - // Reveal factor dims orbs outside the flashlight to match the mesh behavior. - const revealActive = mouseRef.current.active && revealRef.current.x >= 0; - const HOLE_R = 180; - const revealFactor = (px: number, py: number): number => { - if (!revealActive) return 0.28; - const d = Math.sqrt((px - revealRef.current.x) ** 2 + (py - revealRef.current.y) ** 2); - if (d < HOLE_R * 0.6) return 1; - if (d > HOLE_R) return 0.28; - const t = (d - HOLE_R * 0.6) / (HOLE_R * 0.4); - return 1 - t * 0.72; - }; - - for (const p of packets) { - 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 rf = revealFactor(x, y); - const g = ctx.createRadialGradient(x, y, 0, x, y, 7); - g.addColorStop(0, PKT_GLOW + (0.85 * rf).toFixed(2) + ')'); - g.addColorStop(1, PKT_GLOW + '0)'); - ctx.beginPath(); ctx.arc(x, y, 7, 0, Math.PI*2); - ctx.fillStyle = g; ctx.fill(); - ctx.globalAlpha = rf; - ctx.beginPath(); ctx.arc(x, y, 2, 0, Math.PI*2); - ctx.fillStyle = PKT_CORE; ctx.fill(); - ctx.globalAlpha = 1; - } - - if (p.phase === 'pulse') { - p.pulse += dt * 22; - const alpha = Math.max(0, 1 - p.pulse / 18); - const rf = revealFactor(B.x, B.y); - ctx.beginPath(); ctx.arc(B.x, B.y, p.pulse, 0, Math.PI*2); - ctx.strokeStyle = `rgba(34,197,94,${(alpha * rf).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) { - const next = pickEdge(); - if (next) { p.fromIdx = next.a; p.toIdx = next.b; } - p.phase = 'travel'; p.t = 0; - } - } - - }; - - rafId = requestAnimationFrame(tick); - })); - }); - - return () => { - cancelled = true; - window.removeEventListener('mousemove', onMouseMove); - document.removeEventListener('mouseleave', onMouseLeave); - document.removeEventListener('visibilitychange', onVisibility); - observer.disconnect(); - if (rafId) cancelAnimationFrame(rafId); - if (effectRef.current) { effectRef.current.destroy(); effectRef.current = null; } - }; - }, []); - return ( -
-
-