diff --git a/next.config.ts b/next.config.ts
index f237835..ad0f8b3 100644
--- a/next.config.ts
+++ b/next.config.ts
@@ -15,6 +15,12 @@ const nextConfig: NextConfig = {
port: '',
pathname: '/**',
},
+ {
+ protocol: 'https',
+ hostname: 'framerusercontent.com',
+ port: '',
+ pathname: '/**',
+ },
],
},
reactCompiler: true,
diff --git a/package-lock.json b/package-lock.json
index a5717e0..05d272c 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -14,8 +14,9 @@
"@gsap/react": "^2.1.2",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
+ "@hugeicons-pro/core-duotone-rounded": "^3.1.0",
+ "@hugeicons-pro/core-solid-standard": "^3.1.0",
"@hugeicons-pro/core-stroke-standard": "^2.0.0",
- "@hugeicons-pro/core-twotone-rounded": "^2.0.0",
"@hugeicons/react": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
@@ -1452,18 +1453,24 @@
"react-hook-form": "^7.55.0"
}
},
+ "node_modules/@hugeicons-pro/core-duotone-rounded": {
+ "version": "3.1.0",
+ "resolved": "https://npm.hugeicons.com/@hugeicons-pro/core-duotone-rounded/-/core-duotone-rounded-3.1.0.tgz",
+ "integrity": "sha512-HLYuzitbxVHZlgIxxbCQZEkc4va/SACKVm4u9VrBRY9p5GbopXOizEZpOI450YnUueMt0Ddk2Nl0yqxg4RG/7g==",
+ "license": "MIT"
+ },
+ "node_modules/@hugeicons-pro/core-solid-standard": {
+ "version": "3.1.0",
+ "resolved": "https://npm.hugeicons.com/@hugeicons-pro/core-solid-standard/-/core-solid-standard-3.1.0.tgz",
+ "integrity": "sha512-zwVKOOFqBlfs+pvbBvBY4ZRvHepzDvs9DXx+8Doz0qgrnqMgy9KUPWbIn2SfLLD6m+q//hw8aIMgLQ8G+6H3rA==",
+ "license": "MIT"
+ },
"node_modules/@hugeicons-pro/core-stroke-standard": {
"version": "2.0.0",
"resolved": "https://npm.hugeicons.com/@hugeicons-pro/core-stroke-standard/-/core-stroke-standard-2.0.0.tgz",
"integrity": "sha512-lZJp3FcdsCKM4Le66oPxEVmqO5aBQ+OxSPVIH/A525WGqr89Q6ywZOWMuwH8T/TkDtp/o5JhKf2QgrTrNyjpPQ==",
"license": "MIT"
},
- "node_modules/@hugeicons-pro/core-twotone-rounded": {
- "version": "2.0.0",
- "resolved": "https://npm.hugeicons.com/@hugeicons-pro/core-twotone-rounded/-/core-twotone-rounded-2.0.0.tgz",
- "integrity": "sha512-B63M+E6VZDvNe8Qzv8rjTVFzkHe6QmlWYixO1Sgr9EhUXc4kerD0hDHeNZTgg5hC/ODzpuZdlF+ANuBAnfgsgg==",
- "license": "MIT"
- },
"node_modules/@hugeicons/react": {
"version": "1.1.4",
"resolved": "https://registry.npmjs.org/@hugeicons/react/-/react-1.1.4.tgz",
diff --git a/package.json b/package.json
index 4a12158..8bbd887 100644
--- a/package.json
+++ b/package.json
@@ -22,8 +22,9 @@
"@gsap/react": "^2.1.2",
"@heroicons/react": "^2.2.0",
"@hookform/resolvers": "^5.2.2",
+ "@hugeicons-pro/core-duotone-rounded": "^3.1.0",
+ "@hugeicons-pro/core-solid-standard": "^3.1.0",
"@hugeicons-pro/core-stroke-standard": "^2.0.0",
- "@hugeicons-pro/core-twotone-rounded": "^2.0.0",
"@hugeicons/react": "^1.1.1",
"@radix-ui/react-accordion": "^1.2.12",
"@radix-ui/react-avatar": "^1.1.11",
diff --git a/src/components/intro/LandingPageA.tsx b/src/components/intro/LandingPageA.tsx
index 241090e..b8000ab 100644
--- a/src/components/intro/LandingPageA.tsx
+++ b/src/components/intro/LandingPageA.tsx
@@ -3,15 +3,13 @@
import { useGSAP } from '@gsap/react'
import {
AlarmClockIcon,
- ArrowDownRight01Icon,
- ArrowRight02Icon,
} from '@hugeicons-pro/core-stroke-standard'
import {
ArtificialIntelligence04Icon,
Calendar03Icon,
DocumentAttachmentIcon,
Message02Icon,
-} from '@hugeicons-pro/core-twotone-rounded'
+} from '@hugeicons-pro/core-duotone-rounded'
import { HugeiconsIcon } from '@hugeicons/react'
import gsap from 'gsap'
import { SplitText } from 'gsap/SplitText'
@@ -20,11 +18,11 @@ import { useEffect, useRef, useState, useSyncExternalStore } from 'react'
import { HalftoneSwirl } from '@/components/effects/HalftoneSwirl'
import AnimatedPath from '@/components/intro/AnimatedPath'
-import Logo from '@/components/intro/Logo'
import SectionBand from '@/components/intro/SectionBand'
+import NavBar from '@/components/layout/NavBar'
import { LeadsPageWithProvider } from '@/components/leads-page/LeadsPageWithProvider'
import { PrivacyTermsLinks } from '@/components/ui/PrivacyTermsLinks'
-import ButtonPill from '@/components/ui/button-animated'
+import { ButtonPill } from '@/components/ui/ButtonPill'
import { Card } from '@/components/ui/card'
import { trackConversion, trackEngagement, trackView } from '@/lib/ab-testing/tracking'
@@ -266,14 +264,20 @@ export function LandingPageA({ chatbotId }: LandingPageAProps) {
return (
+ {/* NavBar */}
+
+
{/* Hero Section */}
-
-
-
@@ -309,17 +313,12 @@ export function LandingPageA({ chatbotId }: LandingPageAProps) {
- }
label="How it works"
variant="brightblue"
onClick={handleScrollToHowItWorks}
- >
+ endIconName="arrow-down-right-01"
+ showEndIcon
+ />
Takes 3-5 minutes
@@ -378,11 +377,10 @@ export function LandingPageA({ chatbotId }: LandingPageAProps) {
- }
variant="brightblue"
onClick={handleScrollToGetStarted}
+ endIconName="arrow-down-right-01"
+ showEndIcon
/>
@@ -436,9 +434,10 @@ export function LandingPageA({ chatbotId }: LandingPageAProps) {
}
variant="brightblue"
onClick={() => setShowChat(true)}
+ endIconName="arrow-right-02"
+ showEndIcon
/>
@@ -486,16 +485,11 @@ export function LandingPageA({ chatbotId }: LandingPageAProps) {
- }
variant="brightblue"
type="submit"
disabled={isLoading}
+ endIconName="arrow-right-02"
+ showEndIcon={!isLoading}
/>
diff --git a/src/components/intro/LandingPageB.tsx b/src/components/intro/LandingPageB.tsx
index 31671d8..28baa6c 100644
--- a/src/components/intro/LandingPageB.tsx
+++ b/src/components/intro/LandingPageB.tsx
@@ -51,7 +51,7 @@ import {
} from '@/components/ai-elements/prompt-input'
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
import Favicon from '@/components/intro/Favicon'
-import Logo from '@/components/intro/Logo'
+import NavBar from '@/components/layout/NavBar'
import {
type Citation,
MessageWithCitations,
@@ -59,6 +59,7 @@ import {
import { CaseStudyCard } from '@/components/ui/case-study-card'
import { PrivacyTermsLinks } from '@/components/ui/PrivacyTermsLinks'
import ScrollIndicator from '@/components/ui/ScrollIndicator'
+import { ButtonPill } from '@/components/ui/ButtonPill'
import { Card, CardAction, CardContent, CardHeader } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
@@ -453,13 +454,15 @@ export function HeroChat(props: HeroChatProps) {
/>
-
- {isSubmitting ? 'Starting...' : 'Start Conversation'}
-
+ endIconName="arrow-right-02"
+ showEndIcon={!isSubmitting}
+ style={{ width: '100%' }}
+ />
@@ -1034,14 +1037,19 @@ function LandingBInner({
return (
+ {/* NavBar */}
+
{showGlow &&
}
-
-
-
diff --git a/src/components/intro/LandingPageC.tsx b/src/components/intro/LandingPageC.tsx
index 14de66b..49ad4bd 100644
--- a/src/components/intro/LandingPageC.tsx
+++ b/src/components/intro/LandingPageC.tsx
@@ -3,15 +3,12 @@
import { useChat } from '@ai-sdk/react'
import { useGSAP } from '@gsap/react'
import {
- ArrowRight02Icon,
ArtificialIntelligence04Icon,
Copy01Icon,
DashboardSpeed01Icon,
File01Icon,
Idea01Icon,
Link01Icon,
- MaximizeScreenIcon,
- MinimizeScreenIcon,
PlusSignIcon,
Refresh01Icon,
SecurityCheckIcon,
@@ -55,14 +52,14 @@ import {
import { Suggestion, Suggestions } from '@/components/ai-elements/suggestion'
import AnimatedPath from '@/components/intro/AnimatedPath'
import Favicon from '@/components/intro/Favicon'
-import Logo from '@/components/intro/Logo'
+import NavBar from '@/components/layout/NavBar'
import '@/components/leads-page/LeadsPageView.css'
import {
type Citation,
MessageWithCitations,
} from '@/components/leads-page/MessageWithCitations'
+import { ButtonPill } from '@/components/ui/ButtonPill'
import { PrivacyTermsLinks } from '@/components/ui/PrivacyTermsLinks'
-import { Button } from '@/components/ui/button'
import { Card } from '@/components/ui/card'
import {
@@ -406,15 +403,19 @@ const AssessmentToolInner = ({ chatbotId }: AssessmentToolProps) => {
/>
{/* Modal Card */}
-
-
-
+ startIconName="minimize-screen"
+ showStartIcon
+ useVariant={false}
+ backgroundColor="transparent"
+ textColor="currentColor"
+ shapeMode="circle"
+ iconSize={20}
+ height={32}
+ />
{chatContent}
>,
@@ -427,15 +428,19 @@ const AssessmentToolInner = ({ chatbotId }: AssessmentToolProps) => {
{/* Inline card (when not expanded or intro step) */}
{step === 'chat' && !isExpanded && (
-
-
-
+ startIconName="maximize-screen"
+ showStartIcon
+ useVariant={false}
+ backgroundColor="transparent"
+ textColor="currentColor"
+ shapeMode="circle"
+ iconSize={20}
+ height={32}
+ />
)}
@@ -508,16 +513,15 @@ const AssessmentToolInner = ({ chatbotId }: AssessmentToolProps) => {
/>
-
- {isSubmitting ? 'Starting...' : 'Start Conversation'}
- {!isSubmitting && }
-
+ endIconName="arrow-right-02"
+ showEndIcon={!isSubmitting}
+ style={{ width: 'fit-content' }}
+ />
By continuing, you agree to our privacy policy. Your data is secure.
@@ -627,17 +631,26 @@ export function LandingPageC({ chatbotId }: LandingPageCProps) {
return (
+ {/* NavBar */}
+
{/* Hero Section */}
- {/* Logo */}
-
-
-
-
- Start Assessment
-
-
+
diff --git a/src/components/layout/NavBar.tsx b/src/components/layout/NavBar.tsx
new file mode 100644
index 0000000..23c48a1
--- /dev/null
+++ b/src/components/layout/NavBar.tsx
@@ -0,0 +1,1821 @@
+'use client'
+
+// NavBar.tsx
+// NavBar variant with internal scroll-based color changing using IntersectionObserver
+
+import * as React from 'react'
+import { motion, useAnimate, useMotionValue, useTransform } from 'motion/react'
+import { usePathname } from 'next/navigation'
+import { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react'
+import * as HugeIconsStroke from '@hugeicons-pro/core-stroke-standard'
+import * as HugeIconsDuotone from '@hugeicons-pro/core-duotone-rounded'
+
+import RMLogoDrawOn from '@/components/ui/RMLogoDrawOn'
+import { ButtonPill } from '@/components/ui/ButtonPill'
+import { CaseStudyCard } from '@/components/ui/case-study-card'
+
+const { useState, useEffect, useRef } = React
+
+// ============================================================================
+// DYNAMIC ICON HELPER
+// ============================================================================
+
+function getIconComponent(
+ iconName: string,
+ variant: 'stroke' | 'duotone' = 'stroke'
+) {
+ const pascalCase = iconName
+ .split('-')
+ .map((part: string) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join('')
+ const iconKey = `${pascalCase}Icon`
+ const iconSet =
+ variant === 'duotone' ? HugeIconsDuotone : HugeIconsStroke
+ return (iconSet as Record
)[iconKey] || null
+}
+
+function DynamicIcon({
+ name,
+ color = 'currentColor',
+ size = 20,
+ variant = 'stroke',
+}: {
+ name: string
+ color?: string
+ size?: number
+ variant?: 'stroke' | 'duotone'
+}) {
+ const IconComponent = getIconComponent(name, variant)
+ if (!IconComponent) return null
+ return
+}
+
+// ============================================================================
+// TYPES
+// ============================================================================
+
+type Props = {
+ boxShadow?: React.CSSProperties
+ border?: React.CSSProperties
+ openBorder?: React.CSSProperties
+ borderRadius?: number
+ showLinks?: boolean
+ showIcons?: boolean
+ showCTA?: boolean
+ showAI?: boolean
+ previewOpen?: boolean
+ previewMode?: "auto" | "desktop" | "tablet" | "mobile"
+ [key: string]: any // Allow other props for flexibility
+}
+
+// ============================================================================
+// STYLES FACTORY
+// ============================================================================
+
+const createStyles = ({
+ isPhone,
+ isTablet,
+ isOpen,
+ textColor,
+ rowColor,
+ CTABackgroundColor,
+ AiBackgroundColor,
+ finalTextColor,
+ gap,
+ outterPadding,
+ border,
+ openBorder,
+ menuPaddingInline,
+ menuPaddingBlock,
+ ctaCardHeightTablet,
+}: {
+ isPhone: boolean
+ isTablet: boolean
+ isOpen: boolean
+ textColor: string
+ rowColor: string
+ CTABackgroundColor: string
+ AiBackgroundColor: string
+ finalTextColor: string
+ gap: number
+ outterPadding: number
+ border: any
+ openBorder: any
+ menuPaddingInline: number
+ menuPaddingBlock: number
+ ctaCardHeightTablet?: number
+}) => ({
+ // Root container
+ root: {
+ width: "100%",
+ height: "fit-content",
+ pointerEvents: "auto" as const,
+ paddingInline: isPhone ? 0 : outterPadding,
+ display: "grid",
+ placeContent: "center stretch",
+ position: "fixed",
+ top: 0,
+ left: 0,
+ right: 0,
+ zIndex: 99999,
+ } as React.CSSProperties,
+
+ // Shell (main menu container)
+ shell: {
+ position: "relative",
+ width: "100%",
+ maxHeight: "98vh",
+ height: "fit-content",
+ overflow: isPhone ? "auto" : "visible",
+ ...(isOpen ? openBorder || {} : border || {}),
+ margin: "0 auto",
+ display: "grid",
+ placeContent: "start stretch",
+ gap: isOpen ? 40 : 0, // Animations handle open state
+ transitionProperty: "max-width",
+ transitionDuration: "300ms",
+ transitionTimingFunction: "cubic-bezier(0.76, 0, 0.24, 1)",
+ borderBottomLeftRadius: isOpen ? 8 : 0,
+ borderBottomRightRadius: isOpen ? 8 : 0,
+ } as React.CSSProperties,
+
+ // Header
+ header: {
+ color: finalTextColor,
+ display: "grid",
+ placeItems: "center start",
+ gridTemplateColumns: isPhone ? "1fr auto" : "auto 1fr auto",
+ placeContent: "center",
+ boxSizing: "border-box",
+ width: "100%",
+ paddingInlineEnd: 0,
+ borderBlockEnd: `1px solid color-mix(in srgb, ${textColor} 20%, transparent)`,
+ maxHeight: "calc(100vh - 12px)",
+ overflow: "visible",
+
+ } as React.CSSProperties,
+
+ // Logo container
+ logoContainer: {
+ width: "auto",
+ height: 80,
+ flexShrink: 0,
+ color: finalTextColor,
+ paddingInline: isPhone ? 30 : 24,
+ display: "grid",
+ placeItems: "center",
+ placeContent: "center",
+ transition: "color 0.2s ease-in-out, background-color 0.2s ease-in-out",
+ cursor: isPhone ? "initial" : "pointer",
+ } as React.CSSProperties,
+
+ // Desktop nav buttons container
+ navButtonsContainer: {
+ display: "grid",
+ placeContent: "center",
+ gridTemplateColumns: "repeat(4, min-content)",
+ justifyContent: "center",
+ borderInline: `1px solid color-mix(in srgb, ${textColor} 20%, transparent)`,
+ paddingInline: 20,
+ height: 80,
+ width: "100%",
+ gap: 8,
+ } as React.CSSProperties,
+
+ menu: {
+ width: "100%",
+ boxSizing: "border-box",
+ color: textColor,
+ // important: outer wrapper should hide collapsed inner content
+ overflow: "hidden",
+ paddingInline: menuPaddingInline,
+ paddingBlock: menuPaddingBlock,
+ display: "grid",
+
+ gridTemplateRows: "1fr",
+ } as React.CSSProperties,
+
+ // Menu inner wrapper (we'll animate maxHeight on this)
+ menuInner: {
+ minHeight: 0,
+ // overflow: "hidden",
+ transformOrigin: "top",
+ } as React.CSSProperties,
+
+ // Menu grid
+ menuGrid: {
+ display: "grid",
+ height: "100%",
+ overflow: "auto",
+ gridTemplateColumns: isPhone
+ ? "1fr"
+ : isTablet
+ ? "1fr 1fr"
+ : ".85fr 1.1fr 1.15fr",
+ gap: gap,
+ rowGap: isPhone ? 0 : isTablet ? 12 : gap,
+ placeContent: "start stretch",
+ paddingBlockEnd: 40,
+ } as React.CSSProperties,
+
+ // ListItem
+ listItem: {
+ minWidth: 0,
+ display: "grid",
+ gap: isPhone ? 0 : 12,
+ placeContent: "start stretch",
+ } as React.CSSProperties,
+
+ // Link list title
+ linkListTitle: {
+ fontSize: 11,
+ fontWeight: 700,
+ textTransform: "uppercase",
+ letterSpacing: "0.12em",
+ marginBottom: 0,
+ color: rowColor,
+ display: isPhone ? "none" : "block",
+ } as React.CSSProperties,
+
+ // Nav link
+ navLink: {
+ fontFamily: "DM Sans",
+ lineHeight: 1.1,
+ display: "grid",
+ gap: 2,
+ padding: isPhone ? "14px 0 14px" : "12px 0 10px",
+ fontSize: "clamp(16px, 2.8vw, 18px)",
+ fontWeight: 500,
+ letterSpacing: "-0.03em",
+ cursor: "pointer",
+ textDecoration: "none",
+ position: "relative",
+ minHeight: 40,
+ } as React.CSSProperties,
+
+ // Nav link label container
+ navLinkLabelContainer: {
+ display: "flex",
+ alignItems: "start",
+ marginBottom: "0.16em",
+ position: "relative",
+ gap: 8,
+ width: "100%",
+ minWidth: 0,
+ } as React.CSSProperties,
+
+ // Label text
+ labelText: {
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: "nowrap",
+ marginBlockEnd: 4,
+ display: "block",
+ minWidth: 0,
+ } as React.CSSProperties,
+
+ // Project number badge
+ projectNumberBadge: (isPhone: boolean, rowColor: string) =>
+ ({
+ fontSize: isPhone ? 10 : 12,
+ display: "grid",
+ placeContent: "center",
+ borderRadius: 999,
+ height: isPhone ? 18 : 20,
+ width: isPhone ? 18 : 20,
+ lineHeight: 0.9,
+ fontWeight: 700,
+ textAlign: "center",
+ color: rowColor,
+ boxShadow: `0px 0px 3px 1px ${rowColor}`,
+ }) as React.CSSProperties,
+
+ // Description text
+ descText: {
+ opacity: 0.85,
+ display: isPhone ? "none" : "block",
+ fontSize: "clamp(11px, 11px + 0.5vw, 14px)",
+ color: textColor,
+ letterSpacing: 0,
+ fontWeight: 400,
+ } as React.CSSProperties,
+
+ // CTA card
+ cta: (isPhone: boolean, isTablet: boolean) =>
+ ({
+ minWidth: 0,
+ display: "grid",
+ placeItems: isPhone ? "center start" : "end start",
+ placeContent: isPhone ? undefined : "end stretch",
+ gridTemplateColumns: isPhone ? "auto 1fr auto" : undefined,
+ gap: 8,
+ padding: isPhone ? 12 : "24px 16px",
+ borderRadius: isPhone ? 6 : 8,
+ textDecoration: "none",
+ cursor: "pointer",
+ overflow: "hidden",
+ transition: "all 0.2s ease",
+ height: isPhone ? "auto" : 200,
+ width: "100%",
+ position: "relative",
+ alignSelf: isPhone ? undefined : "end",
+ }) as React.CSSProperties,
+
+ // CTA arrow icon container
+ ctaArrow: {
+ position: "absolute",
+ top: 0,
+ right: 0,
+ padding: 12,
+ zIndex: 4,
+ } as React.CSSProperties,
+
+ ctaBgIcon: {
+ position: "absolute",
+ color: CTABackgroundColor || AiBackgroundColor,
+ zIndex: 0,
+ inset: -30,
+ opacity: 0.5,
+ transform: "rotate(-5deg)",
+ filter: "hue-rotate(90deg)",
+ mixBlendMode: "luminosity",
+ } as React.CSSProperties,
+
+ ctaText: {
+ zIndex: 2,
+ fontSize: isPhone ? 16 : 20,
+ fontWeight: isPhone ? 500 : 600,
+ fontFamily: "DM Sans",
+ letterSpacing: "-0.02em",
+ lineHeight: 1,
+ display: "block",
+ justifySelf: "stretch",
+ minWidth: 0,
+ overflow: "hidden",
+ textOverflow: "ellipsis",
+ whiteSpace: isPhone ? "nowrap" : "normal",
+ } as React.CSSProperties,
+
+ ctaDescrption: {
+ zIndex: 2,
+ fontSize: 14,
+ fontWeight: 600,
+ fontFamily: "DM Sans",
+ letterSpacing: "0.0em",
+ lineHeight: 1.2,
+ display: "block",
+ justifySelf: "stretch",
+ } as React.CSSProperties,
+
+ // Hamburger button
+ hamburgerButton: (textColor: string) =>
+ ({
+ display: "grid",
+ placeItems: "center",
+ gap: 8,
+ background: "transparent",
+ border: "none",
+ color: textColor,
+ cursor: "pointer",
+ fontFamily: "DM Sans, sans-serif",
+ fontSize: 18,
+ fontWeight: 500,
+ height: 80,
+ borderInlineStart: isOpen
+ ? "none"
+ : `1px solid color-mix(in srgb, ${textColor} 20%, transparent)`,
+ width: 80,
+ padding: 0,
+ }) as React.CSSProperties,
+
+ // Hamburger lines container
+ hamburgerLines: {
+ display: "inline-flex",
+ flexDirection: "column",
+ gap: 4,
+ } as React.CSSProperties,
+
+ // Hamburger line
+ hamburgerLine: (textColor: string, hidden = false) =>
+ ({
+ width: 20,
+ height: 2,
+ background: textColor,
+ display: hidden ? "none" : "block",
+ }) as React.CSSProperties,
+
+ // Spotlight container
+ spotlightContainer: (isPhone: boolean, isTablet: boolean) =>
+ ({
+ position: "relative",
+ isolation: "isolate",
+ height: 200,
+ width: "100%",
+ marginBlock: isPhone ? 16 : 0,
+ cursor: "pointer",
+ pointerEvents: "auto",
+ }) as React.CSSProperties,
+})
+
+// ============================================================================
+// HOOKS
+// ============================================================================
+
+// Hook to detect which section is currently at the nav position using scroll events
+function useScrollColorChange(
+ sectionIds: string[],
+ navHeight: number = 80,
+ enabled: boolean = true
+): boolean {
+ const [isInLightSection, setIsInLightSection] = useState(false)
+ const rafRef = useRef(null)
+ const intervalRef = useRef | null>(null)
+
+ useEffect(() => {
+ if (
+ !enabled ||
+ typeof window === "undefined" ||
+ sectionIds.length === 0
+ ) {
+ setIsInLightSection(false)
+ return
+ }
+
+ // Check if nav is currently over any of the specified sections
+ const checkSections = () => {
+ // The nav occupies the area from 0 to navHeight pixels from viewport top
+ const navBottom = navHeight
+
+ for (const sectionId of sectionIds) {
+ // Try standard getElementById first
+ let element = document.getElementById(sectionId)
+
+ // Fallback: try querySelector with data-framer-name or other attributes
+ if (!element) {
+ element = document.querySelector(
+ `[data-framer-name="${sectionId}"]`
+ ) as HTMLElement
+ }
+ if (!element) {
+ element = document.querySelector(
+ `[id*="${sectionId}"]`
+ ) as HTMLElement
+ }
+
+ if (!element) continue
+
+ const rect = element.getBoundingClientRect()
+
+ // Section overlaps nav if: section top is above nav bottom AND section bottom is below viewport top
+ if (rect.top < navBottom && rect.bottom > 0) {
+ setIsInLightSection(true)
+ return
+ }
+ }
+
+ setIsInLightSection(false)
+ }
+
+ // Throttled scroll handler using requestAnimationFrame
+ const handleScroll = () => {
+ if (rafRef.current) return
+ rafRef.current = requestAnimationFrame(() => {
+ checkSections()
+ rafRef.current = null
+ })
+ }
+
+ // Initial check
+ checkSections()
+
+ // In Framer, scroll might be on window OR on a parent container
+ // Listen to both window scroll and use a scroll capture on document
+ window.addEventListener("scroll", handleScroll, { passive: true })
+ document.addEventListener("scroll", handleScroll, {
+ passive: true,
+ capture: true,
+ })
+
+ // Fallback: poll every 250ms for Framer environments where scroll events don't bubble
+ intervalRef.current = setInterval(checkSections, 250)
+
+ return () => {
+ window.removeEventListener("scroll", handleScroll)
+ document.removeEventListener("scroll", handleScroll, {
+ capture: true,
+ })
+ if (rafRef.current) {
+ cancelAnimationFrame(rafRef.current)
+ }
+ if (intervalRef.current) {
+ clearInterval(intervalRef.current)
+ }
+ }
+ }, [sectionIds, navHeight, enabled])
+
+ return isInLightSection
+}
+
+// ============================================================================
+// UTILITIES
+// ============================================================================
+
+
+function normalizePath(href: string) {
+ if (!href || href === "#") return null
+ if (typeof window === "undefined") return href
+ try {
+ const u = new URL(href, window.location.origin)
+ return (u.pathname.replace(/\/+$/, "") || "/") + u.search
+ } catch {
+ return href
+ }
+}
+
+function isCurrentLink(currentPath: string, href: string) {
+ const a = (currentPath || "/").replace(/\/+$/, "") || "/"
+ const b = normalizePath(href)
+ if (!b) return false
+ return a === b
+}
+
+function resolveLink(v: any): string | null {
+ if (!v) return null
+ if (typeof v === "string") return v
+ if (typeof v === "object") {
+ return v.url || v.href || v.pathname || v.path || null
+ }
+ return null
+}
+
+// ============================================================================
+// SUB-COMPONENTS
+// ============================================================================
+
+interface NavLinkItem {
+ id?: string
+ label?: string
+ href?: string
+ icon?: string
+ description?: string
+ projectNumber?: number
+ highlight?: boolean
+}
+
+function LinkList({
+ items = [],
+ title,
+ listId = "list",
+ textColor = "#fff",
+ rowColor,
+ currentPath,
+ onItemClick,
+ highlightColor = "#FCF496",
+ isPhone,
+ showIcons = false,
+ styles,
+}: {
+ items?: NavLinkItem[]
+ title: string
+ listId?: string
+ textColor?: string
+ rowColor: string
+ currentPath: string
+ onItemClick: () => void
+ highlightColor?: string
+ isPhone: boolean
+ showIcons?: boolean
+ styles: Record
+ gridArea?: string
+ [key: string]: any
+}) {
+ const safeItems = Array.isArray(items) ? items : []
+
+ return (
+
+
+ {title}
+
+
+ {safeItems.map((item, i) => {
+ const href = resolveLink(item?.href) || "#"
+ const current = isCurrentLink(currentPath, href)
+ const stableId =
+ typeof item?.id === "string" ? item.id.trim() : ""
+ const key = stableId
+ ? `link:${listId}:${stableId}`
+ : `link:${listId}:${href}:${item?.label ?? ""}:${i}`
+ const projectNumber =
+ typeof item?.projectNumber === "number" &&
+ item.projectNumber > 0
+ ? item.projectNumber
+ : null
+
+ const linkColor = current
+ ? rowColor
+ : item.highlight
+ ? highlightColor
+ : textColor
+
+ return (
+
{
+ // Defer close so the click event bubbles to Framer's
+ // SPA router before DOM mutations from menu close
+ requestAnimationFrame(() => onItemClick?.())
+ }}
+ initial={{ color: linkColor, x: 0 }}
+ animate={{ color: linkColor, x: 0 }}
+ whileHover={{
+ color: rowColor,
+ x: 3,
+ transition: { duration: 0.3 },
+ }}
+ style={{
+ ...styles.navLink,
+ borderBottom: isPhone
+ ? "none"
+ : i !== safeItems.length - 1
+ ? `1px solid color-mix(in srgb, ${textColor} 20%, transparent)`
+ : "none",
+ }}
+ >
+
+ {showIcons && item.icon && (
+
+ )}
+
+
+ {item.label || "Link"}
+
+ {item.description && (
+
+ {item.description}
+
+ )}
+
+
+ {projectNumber !== null && (
+
+ {projectNumber}
+
+ )}
+
+
+ )
+ })}
+
+ )
+}
+
+function ListItem({ gridArea, children, styles, style, ...props }: {
+ gridArea: string
+ children: React.ReactNode
+ styles: Record
+ style?: React.CSSProperties
+ [key: string]: any
+}) {
+ return (
+
+ {children}
+
+ )
+}
+
+function CTA({
+ text = "Schedule A Conversation",
+ description = "",
+ icon = "calendar-01",
+ backgroundColor = "#fcf496",
+ borderColor = "#a998c9",
+ textColor = "#04242B",
+ href = "#",
+ isPhone,
+ isTablet,
+ styles,
+ onItemClick,
+ ...props
+}: {
+ text?: string
+ description?: string
+ icon?: string
+ backgroundColor?: string
+ borderColor?: string
+ textColor?: string
+ href?: string
+ isPhone: boolean
+ isTablet: boolean
+ styles: Record
+ onItemClick?: () => void
+ [key: string]: any
+}) {
+ const [isHovered, setIsHovered] = React.useState(false)
+
+ return (
+ {
+ requestAnimationFrame(() => onItemClick?.())
+ }}
+ onMouseEnter={() => setIsHovered(true)}
+ onMouseLeave={() => setIsHovered(false)}
+ whileTap={{ scale: 0.98 }}
+ >
+ {/* Grow overlay — circle clip-path reveal on hover */}
+
+
+ {!isPhone && (
+ <>
+
+
+
+
+
+
+
+
+
+
+ >
+ )}
+ {isPhone && (
+
+
+
+ )}
+ {text}
+ {description && !isPhone && (
+ {description}
+ )}
+ {isPhone && (
+
+
+
+ )}
+
+ )
+}
+
+function TriggerMenu({ textColor, iconOpen, toggleMenu, styles }: {
+ textColor: string
+ iconOpen: boolean
+ toggleMenu: (source: string) => void
+ styles: Record
+}) {
+ return (
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ toggleMenu("hamburger")
+ }}
+ style={styles.hamburgerButton(textColor)}
+ >
+
+
+
+
+
+
+ )
+}
+
+// ============================================================================
+// MAIN COMPONENT
+// ============================================================================
+
+export default function NavBar(props: Props) {
+ const {
+ homeLink = "https://rolemodelsoftware.com",
+ link1 = "https://rolemodelsoftware.com/services",
+ link2 = "https://rolemodelsoftware.com/portfolio",
+ link3 = "https://rolemodelsoftware.com/consultation",
+ link4 = "https://consult.rolemodelsoftware.com",
+ contactLabel = "Let's Talk",
+ logoTheme = "light",
+ openLogoTheme = "light",
+ bgColor = "var(--blue-green-900)",
+ openBgColor = "var(--blue-green-900)",
+ textColor = "#FFFFFF",
+ openTextColor = "#FFFFFF",
+ accentColor = "#2a84f8",
+ rowColor = "#87D4E9",
+ contactHoverColor = "#2C83F8",
+ ghostColor = "#CCCCCC",
+ border,
+ openBorder,
+ boxShadow,
+ showLinks = true,
+ showIcons = true,
+ showCTA = true,
+ showAi = false,
+ showSpotlight = true,
+ CTABackgroundColor = "#3B70B3",
+ CTABorderColor = "#87D4EA",
+ AiBackgroundColor = "#29464F",
+ AiBorderColor = "#FFFFFF",
+ AiTextColor = "#FFFFFF",
+ AiText = "A.I. Analysis",
+ AiDescription = "Find out if custom software is right for you.",
+ CTAText = "Schedule A Conversation",
+ CTADescription = "",
+ CTATextColor = "#FFFFFF",
+ CTAIcon = "calendar-01",
+ ctaCardHeightTablet = 200,
+ spotlightImageUrl = "https://framerusercontent.com/images/8nGc7fE31a5LiyhesQXL0kAAh48.webp?width=2400&height=1792",
+ logoImage = "https://framerusercontent.com/images/fq0QpZJihXpp1TCLYctWjKapX8.svg?width=189&height=88",
+ spotlightContent = "Branded Mobile PWA and Desktop Web application for business forum and executive coaching firm",
+ spotlightLink ="https://rolemodelsoftware.com/case-studies/c12-business-forums",
+ highlightColor = "#2A83F7",
+ gap = 32,
+ outterPadding = 0,
+ col1Title = "About Us",
+ col2Title = "Our Approach",
+ col3Title = "People",
+ col4Title = "Solutions",
+ col1Links = [
+ { id: "services", label: "Services", href: "https://rolemodelsoftware.com/services", description: "We craft custom software tailored to your business", icon: "wrench-01" },
+ { id: "about", label: "About", href: "https://rolemodelsoftware.com/about", description: "We're a world class software development team", icon: "target-01" },
+ { id: "work", label: "Our Work", href: "https://rolemodelsoftware.com/portfolio", description: "Case studies and results from our work.", icon: "briefcase-01" },
+ { id: "way", label: "The RoleModel Way", href: "https://rolemodelsoftware.com/rolemodel-way", description: "The principles and practices of how we work", icon: "puzzle" },
+ ],
+ col2Links = [
+ { id: "approach", label: "Our Approach", href: "https://rolemodelsoftware.com/focuses/expertise-amplification", description: "Our unique process that ensures project success.", icon: "car-01" },
+ { id: "expertise", label: "Expertise Amplification", href: "https://rolemodelsoftware.com/focuses/expertise-amplification", description: "Your domain expertise + our software craft.", icon: "brain-01" },
+ { id: "iterative", label: "Iterative Value", href: "https://rolemodelsoftware.com/focuses/iterative-value", description: "Ship value fast, then build on a solid foundation.", icon: "reload" },
+ { id: "process", label: "Process Scaling", href: "https://rolemodelsoftware.com/focuses/process-scaling", description: "Process first, software second\u2014scale your advantage.", icon: "arrow-up-right-02" },
+ ],
+ col3Links = [
+ { id: "careers", label: "Careers", href: "https://rolemodelsoftware.com/careers", description: "We're always looking for talent.", icon: "laptop-programming" },
+ { id: "academy", label: "Craftsmanship Academy", href: "https://rolemodelsoftware.com/academy", description: "Building future leaders in software development.", icon: "mortarboard-01" },
+ ],
+ col4Links = [
+ { id: "optics", label: "Optics", href: "https://rolemodelsoftware.com/optics", description: "Our design system for consistent, scalable UI.", icon: "circle" },
+ { id: "lightning", label: "LightningCAD", href: "https://rolemodelsoftware.com/lightningcad", description: "Rules-driven design tools.", icon: "zap" },
+ ],
+ previewOpen,
+ previewMode = "auto",
+ // Scroll-based color change props
+ enableScrollColorChange = false,
+ colorChangeSections = [],
+ altBgColor = "#FFFFFF",
+ altTextColor = "#000000",
+ altLogoTheme = "dark",
+ altAccentColor = "#2a84f8",
+ altBorder,
+ navHeight = 80,
+ menuPaddingInline: menuPaddingInlineProp = 40,
+ menuPaddingBlock: menuPaddingBlockProp = 0,
+ } = props
+
+ const isCanvas = false
+ const [isLogoHovered, setIsLogoHovered] = React.useState(false)
+ const [isSpotlightHovered, setIsSpotlightHovered] = React.useState(false)
+
+ // Parse section IDs from string or array
+ const sectionIdList = React.useMemo(() => {
+ if (!colorChangeSections) return []
+ if (Array.isArray(colorChangeSections)) {
+ return colorChangeSections.filter(Boolean)
+ }
+ // Support comma-separated string
+ if (typeof colorChangeSections === "string") {
+ return colorChangeSections
+ .split(",")
+ .map((s) => s.trim())
+ .filter(Boolean)
+ }
+ return []
+ }, [colorChangeSections])
+
+ // Use scroll color change hook
+ const isInAltSection = useScrollColorChange(
+ sectionIdList,
+ navHeight,
+ enableScrollColorChange && !isCanvas
+ )
+
+ // Breakpoint detection - direct width check (most reliable)
+ const MOBILE_MAX = 760
+ const TABLET_MAX = 1324
+
+ // Always initialize with server-safe defaults to prevent hydration mismatch.
+ // The useEffect below will immediately update to the real values on the client.
+ const [breakpointState, setBreakpointState] = React.useState({
+ isMobile: false,
+ isTablet: false,
+ })
+
+ React.useEffect(() => {
+ if (typeof window === "undefined") return
+
+ const updateBreakpoints = () => {
+ const w = window.innerWidth
+ const newMobile = w <= MOBILE_MAX
+ const newTablet = w > MOBILE_MAX && w <= TABLET_MAX
+
+ setBreakpointState((prev) => {
+ if (
+ prev.isMobile !== newMobile ||
+ prev.isTablet !== newTablet
+ ) {
+ return { isMobile: newMobile, isTablet: newTablet }
+ }
+ return prev
+ })
+ }
+
+ // Check immediately
+ updateBreakpoints()
+
+ // Listen to resize
+ window.addEventListener("resize", updateBreakpoints)
+
+ return () => {
+ window.removeEventListener("resize", updateBreakpoints)
+ }
+ }, [])
+
+ const { isMobile: isMobileViewport, isTablet: isTabletViewport } =
+ breakpointState
+
+ const isPhone = React.useMemo(() => {
+ if (previewMode === "mobile") return true
+ if (previewMode === "tablet") return false
+ if (previewMode === "desktop") return false
+ return isMobileViewport
+ }, [previewMode, isMobileViewport])
+
+ const isTablet = React.useMemo(() => {
+ if (previewMode === "tablet") return true
+ if (previewMode === "mobile") return false
+ if (previewMode === "desktop") return false
+ return isTabletViewport
+ }, [previewMode, isTabletViewport])
+
+ const [isOpen, setIsOpen] = useState(previewOpen || false)
+ const [iconOpen, setIconOpen] = useState(previewOpen || false)
+ const [activeMenuButton, setActiveMenuButton] = useState(
+ null
+ )
+ const [isClickLocked, setIsClickLocked] = useState(false)
+ const [suppressHover, setSuppressHover] = useState(false)
+ const hoverTimeoutRef = useRef | null>(null)
+ const openedViaClickAtRef = useRef(null)
+
+ // Determine current colors based on scroll position and open state
+ const currentBgColor = isOpen
+ ? openBgColor
+ : isInAltSection
+ ? altBgColor
+ : bgColor
+
+ const currentTextColor = isOpen
+ ? openTextColor
+ : isInAltSection
+ ? altTextColor
+ : textColor
+
+ const currentLogoTheme = isOpen
+ ? openLogoTheme
+ : isInAltSection
+ ? altLogoTheme
+ : logoTheme
+
+ const currentAccentColor = isInAltSection ? altAccentColor : accentColor
+
+ const currentBorder = isOpen
+ ? openBorder
+ : isInAltSection && altBorder
+ ? altBorder
+ : border
+
+ const finalTextColor = currentTextColor
+ const finalLogoTheme = currentLogoTheme
+
+ const pathname = usePathname()
+ const currentPath = pathname || "/"
+
+ // Menu padding from props (with mobile cap)
+ const menuPaddingInline = isPhone
+ ? Math.min(menuPaddingInlineProp, 24)
+ : menuPaddingInlineProp
+ const menuPaddingBlock = isPhone ? 0 : menuPaddingBlockProp
+
+ // Generate styles
+ const styles = React.useMemo(
+ () =>
+ createStyles({
+ isPhone,
+ isTablet,
+ isOpen,
+ textColor: currentTextColor,
+ rowColor,
+ CTABackgroundColor,
+ AiBackgroundColor,
+ finalTextColor,
+ gap,
+ outterPadding,
+ border,
+ openBorder,
+ menuPaddingInline,
+ menuPaddingBlock,
+ ctaCardHeightTablet,
+ }),
+ [
+ isPhone,
+ isTablet,
+ isOpen,
+ currentTextColor,
+ rowColor,
+ CTABackgroundColor,
+ AiBackgroundColor,
+ finalTextColor,
+ gap,
+ outterPadding,
+ border,
+ openBorder,
+ menuPaddingInline,
+ menuPaddingBlock,
+ ctaCardHeightTablet,
+ ]
+ )
+
+ const gridTemplateColumns = React.useMemo(() => {
+ if (isPhone) return "1fr"
+ // tablet + desktop: 6 equal columns
+ return "repeat(6, 1fr)"
+ }, [isPhone])
+
+ // Grid template areas based on showCTA, showAi, showSpotlight
+ const gridTemplateAreas = React.useMemo(() => {
+ // PHONE – single column stack
+ if (isPhone) {
+ const rows: string[] = ['"c1"', '"c2"', '"c3"']
+ if (showCTA) rows.push('"c5"')
+ if (showAi) rows.push('"c6"')
+ return rows.join("\n")
+ }
+
+ // TABLET + DESKTOP (6 columns layout)
+ const topRow = `"c1 c1 c2 c2 c3 c3"`
+
+ // Collect bottom-row items
+ const bottomItems: string[] = []
+ if (showSpotlight) bottomItems.push("c4")
+ if (showCTA) bottomItems.push("c5")
+ if (showAi) bottomItems.push("c6")
+
+ // No bottom items – top row only
+ if (bottomItems.length === 0) {
+ return topRow
+ }
+
+ // 1 item – full width bottom row
+ if (bottomItems.length === 1) {
+ const item = bottomItems[0]
+ return `${topRow}
+ "${item} ${item} ${item} ${item} ${item} ${item}"`
+ }
+
+ // 2 items – each spans 3 cols
+ if (bottomItems.length === 2) {
+ const [a, b] = bottomItems
+ return `${topRow}
+ "${a} ${a} ${a} ${b} ${b} ${b}"`
+ }
+
+ // 3 items – each spans 2 cols
+ const [a, b, c] = bottomItems
+ return `${topRow}
+ "${a} ${a} ${b} ${b} ${c} ${c}"`
+ }, [isPhone, showCTA, showAi, showSpotlight])
+
+ // Animations
+ const [scope, animate] = useAnimate()
+
+ // 0 = closed, 1 = open
+ const menuProgress = useMotionValue(isOpen ? 1 : 0)
+
+ // Translate progress (0–1) into a maxHeight for the inner menu
+ // Adjust 500 to the approximate max dropdown height you need
+ const menuMaxHeight = useTransform(menuProgress, (p) => `${p * 1500}px`)
+
+ // Ensure MV stays in sync with state (e.g. after navigation)
+ useEffect(() => {
+ menuProgress.set(isOpen ? 1 : 0)
+ }, [isOpen, menuProgress])
+
+ const easing: [number, number, number, number] = [0.76, 0, 0.24, 1]
+
+ // Instant close for link clicks (no animation, navigation wins)
+ const instantCloseMenu = () => {
+ const root = scope.current
+ if (root) {
+ const shell = root.querySelector(".shell") as HTMLElement | null
+ if (shell) {
+ shell.style.gap = "0px"
+ }
+ }
+ setIconOpen(false)
+ setActiveMenuButton(null)
+ setIsClickLocked(false)
+ setIsOpen(false)
+ menuProgress.set(0)
+ }
+
+ const openMenu = async (buttonId?: string) => {
+ const root = scope.current
+ if (!root) return
+
+ const shell = root.querySelector(".shell") as HTMLElement | null
+
+ setIsOpen(true)
+ setIconOpen(true)
+ if (buttonId) setActiveMenuButton(buttonId)
+
+ const openGap = isTablet ? 24 : isPhone ? 16 : gap
+ const duration = 0.3
+
+ if (shell) {
+ animate(shell, { gap: `${openGap}px` } as any, { duration, ease: easing as any })
+ }
+
+ // expand dropdown
+ await animate(menuProgress as any, 1, { duration, ease: easing as any })
+ }
+
+ const closeMenu = async () => {
+ if (!isOpen) return
+
+ const root = scope.current
+ if (!root) {
+ setIconOpen(false)
+ setIsOpen(false)
+ setActiveMenuButton(null)
+ setIsClickLocked(false)
+ menuProgress.set(0)
+ return
+ }
+
+ const shell = root.querySelector(".shell") as HTMLElement | null
+
+ setIconOpen(false)
+ setActiveMenuButton(null)
+ setIsClickLocked(false)
+
+ const duration = 0.25
+
+ if (shell) {
+ animate(shell, { gap: "0px" } as any, { duration, ease: easing as any })
+ }
+
+ // collapse dropdown
+ await animate(menuProgress as any, 0, { duration, ease: easing as any })
+
+ setIsOpen(false)
+
+ if (shell) {
+ shell.style.gap = ""
+ }
+ }
+
+ useEffect(() => {
+ if (isCanvas && previewOpen) {
+ const openGap = isTablet ? 20 : isPhone ? 0 : gap
+ const root = scope.current
+ if (root) {
+ const shell = root.querySelector(".shell") as HTMLElement | null
+ if (shell) {
+ shell.style.gap = `${openGap}px`
+ }
+ }
+ menuProgress.set(1)
+ setIsOpen(true)
+ setIconOpen(true)
+ }
+ }, [isCanvas, previewOpen, isPhone, isTablet, gap, menuProgress])
+
+ // Keep shell gap in sync with open state
+ React.useEffect(() => {
+ if (!isOpen) {
+ const root = scope.current
+ if (root) {
+ const shell = root.querySelector(".shell") as HTMLElement | null
+ if (shell) {
+ shell.style.gap = "0px"
+ }
+ }
+ }
+ }, [isOpen, scope])
+
+ React.useEffect(() => {
+ // Save original overflow style on mount
+ const originalStyle = window.getComputedStyle(document.body).overflow
+
+ if (isOpen) {
+ document.body.style.overflow = "hidden"
+ } else {
+ document.body.style.overflow = originalStyle
+ }
+
+ // Cleanup: restore original style on unmount or when isOpen changes
+ return () => {
+ document.body.style.overflow = originalStyle
+ }
+ }, [isOpen]) // Include isOpen in dependency array
+
+ const toggleMenu = (buttonId?: string) => {
+ if (isOpen && activeMenuButton === buttonId) {
+ // Clicking the same button again closes and unlocks
+ setIsClickLocked(false)
+ closeMenu()
+ } else if (isOpen && buttonId) {
+ // Switching to a different button, lock it
+ setActiveMenuButton(buttonId)
+ setIsClickLocked(true)
+ } else {
+ // Opening via click - lock it open
+ setIsClickLocked(true)
+ openedViaClickAtRef.current = Date.now()
+ openMenu(buttonId)
+ }
+ }
+
+ // Desktop hover handlers
+ const handleMenuButtonHover = (buttonId: string) => {
+ if (isPhone || isCanvas || isClickLocked) return
+ if (hoverTimeoutRef.current) {
+ clearTimeout(hoverTimeoutRef.current)
+ hoverTimeoutRef.current = null
+ }
+ if (!isOpen) {
+ openMenu(buttonId)
+ }
+ }
+
+ const handleShellMouseEnter = () => {
+ if (isPhone || isCanvas || isClickLocked) return
+ if (hoverTimeoutRef.current) {
+ clearTimeout(hoverTimeoutRef.current)
+ hoverTimeoutRef.current = null
+ }
+ }
+
+ const handleShellMouseLeave = () => {
+ if (isPhone || isCanvas || !isOpen || isClickLocked) return
+ // Simple delayed close - gives time to reach dropdown
+ hoverTimeoutRef.current = setTimeout(() => {
+ setSuppressHover(true)
+ closeMenu()
+ }, 300)
+ }
+
+ // Cleanup on unmount
+ React.useEffect(() => {
+ return () => {
+ if (hoverTimeoutRef.current) {
+ clearTimeout(hoverTimeoutRef.current)
+ }
+ }
+ }, [])
+
+ // Event listeners
+ React.useEffect(() => {
+ if (!isOpen || isCanvas) return
+
+ const handleEscape = (e: KeyboardEvent) => {
+ if (e.key === "Escape") closeMenu()
+ }
+ const handleClickOutside = (e: MouseEvent) => {
+ // Ignore delayed synthetic click from mobile tap (fires ~300ms later with stale target)
+ const openedAt = openedViaClickAtRef.current
+ if (openedAt && Date.now() - openedAt < 400) {
+ openedViaClickAtRef.current = null
+ return
+ }
+
+ const shell = scope.current?.querySelector(".shell")
+ if (shell && !shell.contains(e.target as Node)) closeMenu()
+ }
+
+ document.addEventListener("keydown", handleEscape)
+ const clickTimer = setTimeout(() => {
+ document.addEventListener("click", handleClickOutside)
+ }, 100)
+
+ return () => {
+ clearTimeout(clickTimer)
+ document.removeEventListener("keydown", handleEscape)
+ document.removeEventListener("click", handleClickOutside)
+ }
+ }, [isOpen, isCanvas])
+
+ // ============================================================================
+ // RENDER
+ // ============================================================================
+
+ return (
+
+
+
+
+ {/* Header */}
+
+ setIsLogoHovered(true)}
+ onMouseLeave={() => setIsLogoHovered(false)}
+ >
+
+
+
+ {!isPhone && showLinks && (
+ <>
+
+
+
{
+ if (!isClickLocked)
+ handleMenuButtonHover("company")
+ }}
+ onMouseLeave={() => {
+ if (!isClickLocked)
+ setSuppressHover(false)
+ }}
+ style={{ display: "inline-flex" }}
+ >
+ {
+ e.preventDefault()
+ e.stopPropagation()
+ toggleMenu("company")
+ }}
+ showEndIcon
+ endIconName="arrow-down-01"
+ isActive={
+ activeMenuButton === "company"
+ }
+ label="Company & Approach"
+ disableHoverAnimation={true}
+ />
+
+
+
+
+
+
+ >
+ )}
+
+ {isPhone && (
+
+ )}
+
+
+ {/* Menu Dropdown */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {!isPhone && showSpotlight && (
+
+
+ Partner Spotlight
+
+
+
+
+
+
+ )
+}
+
+NavBar.displayName = "NavBar"
diff --git a/src/components/ui/ButtonPill.tsx b/src/components/ui/ButtonPill.tsx
new file mode 100644
index 0000000..43d834f
--- /dev/null
+++ b/src/components/ui/ButtonPill.tsx
@@ -0,0 +1,659 @@
+'use client'
+
+import { motion } from 'motion/react'
+import { useEffect, useState, type CSSProperties, type ReactNode } from 'react'
+import { HugeiconsIcon, type IconSvgElement } from '@hugeicons/react'
+import * as HugeIconsStroke from '@hugeicons-pro/core-stroke-standard'
+import * as HugeIconsSolid from '@hugeicons-pro/core-solid-standard'
+import * as HugeIconsDuotone from '@hugeicons-pro/core-duotone-rounded'
+
+// Helper to get icon component by name
+function getIconComponent(
+ iconName: string,
+ variant: 'stroke' | 'solid' | 'duotone' = 'stroke'
+) {
+ // Convert kebab-case to PascalCase and add Icon suffix
+ const pascalCase = iconName
+ .split('-')
+ .map((part) => part.charAt(0).toUpperCase() + part.slice(1))
+ .join('')
+ const iconKey = `${pascalCase}Icon`
+
+ // Select the icon set based on variant
+ const iconSet =
+ variant === 'solid'
+ ? HugeIconsSolid
+ : variant === 'duotone'
+ ? HugeIconsDuotone
+ : HugeIconsStroke
+
+ return (iconSet as Record)[iconKey] || null
+}
+
+export type ButtonPillVariant =
+ | 'dark'
+ | 'light'
+ | 'blue'
+ | 'darkblue'
+ | 'rmbrightblue'
+ | 'brightblue'
+ | 'green'
+ | 'secondary'
+ | 'purple'
+ | 'lightpurple'
+ | 'bluegreen'
+ | 'yellow'
+ | 'ghost'
+ | 'default'
+
+export interface ButtonPillProps {
+ ariaLabel?: string
+ label?: string
+
+ // Color / variant
+ useVariant?: boolean
+ circle?: boolean
+ grow?: boolean
+ variant?: ButtonPillVariant
+ backgroundColor?: string
+ hoverBackground?: boolean
+ hoverbackgroundColor?: string
+ hoverColor?: string
+ boxShadow?: string
+ textColor?: string
+ hoverTextColor?: string
+
+ // Layout / font
+ iconStyle?: string
+ borderRadius?: number
+ paddingX?: number
+ font?: CSSProperties
+
+ // State
+ isActive?: boolean
+ disableHoverAnimation?: boolean
+
+ // Icons
+ showStartIcon?: boolean
+ showEndIcon?: boolean
+ startIconName?: string
+ endIconName?: string
+ startIconSvg?: string
+ endIconSvg?: string
+ iconGap?: number
+ iconSize?: number
+ circleSize?: number
+ iconFontWeight?: number
+ iconVariant?: 'stroke' | 'solid' | 'duotone'
+
+ // Shape / behavior
+ shapeMode?: 'auto' | 'circle'
+ disableGrowOverlay?: boolean
+ height?: number
+
+ // Misc
+ style?: CSSProperties
+ className?: string
+ href?: string
+ target?: string
+ rel?: string
+ onClick?: (e: React.MouseEvent) => void
+ onTap?: () => void
+ onMouseEnter?: () => void
+ onMouseLeave?: () => void
+ children?: ReactNode
+ type?: 'button' | 'submit' | 'reset'
+ disabled?: boolean
+}
+
+type VariantColors = {
+ bg: string
+ textColor: string
+ hoverTextColor: string
+ boxShadow?: string
+ hoverBoxShadow?: string
+ hoverbackgroundColor?: string
+}
+
+export function ButtonPill(props: ButtonPillProps) {
+ const {
+ ariaLabel = 'Button',
+ label = 'Button',
+
+ useVariant = true,
+ variant = 'blue',
+ backgroundColor,
+ textColor,
+ hoverTextColor,
+ circleSize = 50,
+ borderRadius = 8,
+ boxShadow,
+ paddingX = 20,
+ font,
+ circle,
+ hoverbackgroundColor,
+ disableHoverAnimation = false,
+
+ startIconName = '',
+ endIconName = '',
+ showStartIcon = false,
+ showEndIcon = false,
+ startIconSvg = '',
+ endIconSvg = '',
+ iconGap = 8,
+ iconSize = 18,
+ iconFontWeight = 500,
+ iconVariant = 'stroke',
+ style,
+ className,
+ isActive = false,
+ href,
+ target,
+ rel,
+ onClick,
+ onTap,
+ onMouseEnter: onMouseEnterProp,
+ onMouseLeave: onMouseLeaveProp,
+ shapeMode = 'auto',
+ disableGrowOverlay = false,
+ height,
+ children,
+ type = 'button',
+ disabled = false,
+ } = props
+
+ // Single source of truth for modes
+ const isCircleMode = shapeMode === 'circle' || (shapeMode === 'auto' && Boolean(circle))
+
+ // Ensure icons render only after hydration to avoid mismatch
+ const [isMounted, setIsMounted] = useState(false)
+ useEffect(() => { setIsMounted(true) }, [])
+
+ // Hover state for grow overlay
+ const [isHovered, setIsHovered] = useState(false)
+
+ const handleMouseEnter = () => {
+ if (isActive || disableHoverAnimation) return
+ setIsHovered(true)
+ onMouseEnterProp?.()
+ }
+
+ const handleMouseLeave = () => {
+ setIsHovered(false)
+ onMouseLeaveProp?.()
+ }
+
+ // =====================
+ // Variant color tokens
+ // =====================
+
+ const variants: Record = {
+ default: {
+ bg: 'transparent',
+ textColor: '#000000',
+ hoverTextColor: '#000000',
+ },
+ secondary: {
+ bg: '#FFFFFF',
+ textColor: '#181A18',
+ hoverTextColor: '#2A84F8',
+ boxShadow: '0 0 0 1px #CCCCCC inset',
+ hoverBoxShadow: '0 0 0 1px #CCCCCC inset',
+ },
+ dark: {
+ bg: '#04242B',
+ textColor: '#FFFFFF',
+ hoverTextColor: '#FFFFFF',
+ hoverbackgroundColor: '#000000',
+ },
+ light: {
+ bg: '#FFFFFF',
+ textColor: '#181A18',
+ hoverTextColor: '#181A18',
+ },
+ blue: {
+ bg: '#3A70B3',
+ textColor: '#FFFFFF',
+ hoverTextColor: '#FFFFFF',
+ },
+ darkblue: {
+ bg: '#193C64',
+ textColor: '#FFFFFF',
+ hoverTextColor: '#FFFFFF',
+ },
+ rmbrightblue: {
+ bg: '#2A84F8',
+ textColor: '#FFFFFF',
+ hoverTextColor: '#FFFFFF',
+ },
+ brightblue: {
+ bg: '#87D4E9',
+ textColor: '#000000',
+ hoverTextColor: '#000000',
+ },
+ green: {
+ bg: '#538C5E',
+ textColor: '#FFFFFF',
+ hoverTextColor: '#FFFFFF',
+ },
+ purple: {
+ bg: '#3C194A',
+ textColor: '#FFFFFF',
+ hoverTextColor: '#FFFFFF',
+ },
+ lightpurple: {
+ bg: '#A998C9',
+ textColor: '#181A18',
+ hoverTextColor: '#181A18',
+ },
+ bluegreen: {
+ bg: '#27434D',
+ textColor: '#FFFFFF',
+ hoverTextColor: '#FFFFFF',
+ },
+ yellow: {
+ bg: '#FCF496',
+ textColor: '#181A18',
+ hoverTextColor: '#181A18',
+ },
+ ghost: {
+ bg: 'transparent',
+ textColor: '#FFFFFF',
+ hoverTextColor: '#FFFFFF',
+ boxShadow: '0 0 0 1px #FFFFFF inset',
+ },
+ }
+
+ const variantColors = variants[variant] ?? variants.default
+
+ const colors: VariantColors = useVariant
+ ? {
+ ...variantColors,
+ textColor: textColor && textColor !== '' ? textColor : variantColors.textColor,
+ hoverTextColor:
+ hoverTextColor && hoverTextColor !== ''
+ ? hoverTextColor
+ : variantColors.hoverTextColor,
+ hoverbackgroundColor:
+ hoverbackgroundColor && hoverbackgroundColor !== ''
+ ? hoverbackgroundColor
+ : variantColors.hoverbackgroundColor,
+ }
+ : {
+ bg: backgroundColor || 'transparent',
+ textColor: textColor || '#000000',
+ hoverTextColor: hoverTextColor || '#000000',
+ hoverbackgroundColor: hoverbackgroundColor || backgroundColor || 'transparent',
+ }
+
+ // Grow overlay: only when there's a solid background
+ const growBgColor = colors.hoverbackgroundColor || colors.bg
+ const hasGrowBg = Boolean(growBgColor && growBgColor !== 'transparent')
+
+ // =====================
+ // Shadows
+ // =====================
+
+ const styleBoxShadow = style?.boxShadow as string | undefined
+
+ const isValidShadow = (shadow: string | undefined): shadow is string =>
+ Boolean(shadow && shadow !== 'none' && shadow.trim() !== '')
+
+ const computedShadow = isValidShadow(styleBoxShadow)
+ ? styleBoxShadow
+ : isValidShadow(boxShadow)
+ ? boxShadow
+ : useVariant
+ ? variants[variant]?.boxShadow || undefined
+ : undefined
+
+ const computedHoverShadow = isValidShadow(styleBoxShadow)
+ ? styleBoxShadow
+ : useVariant && variants[variant]?.hoverBoxShadow
+ ? variants[variant].hoverBoxShadow
+ : computedShadow
+
+ // =====================
+ // Motion variants
+ // =====================
+
+ const hasShadow = isValidShadow(computedShadow)
+ const hasHoverShadow = isValidShadow(computedHoverShadow)
+
+ const buttonVariants = {
+ initial: {
+ color: colors.textColor,
+ ...(hasShadow && { boxShadow: computedShadow }),
+ },
+ rest: {
+ color: colors.textColor,
+ ...(hasShadow && { boxShadow: computedShadow }),
+ filter: 'brightness(100%) saturate(100%)',
+ transition: { duration: 0.35, ease: 'easeInOut' as const },
+ },
+ hover: {
+ color: colors.hoverTextColor,
+ ...(hasHoverShadow && { boxShadow: computedHoverShadow }),
+ filter:
+ !disableGrowOverlay && hasGrowBg
+ ? 'brightness(100%) saturate(100%)'
+ : 'brightness(110%) saturate(134%)',
+ transition: { duration: 0.25, ease: 'easeInOut' as const },
+ },
+ tap: { scale: 0.95, transition: { duration: 0.5, ease: 'easeOut' as const } },
+ active: {
+ color: colors.textColor,
+ ...(hasShadow && { boxShadow: computedShadow }),
+ filter: 'brightness(110%) saturate(120%)',
+ transition: { duration: 0.25, ease: 'easeInOut' as const },
+ },
+ }
+
+ // =====================
+ // Helpers
+ // =====================
+
+ const computedLineHeight =
+ typeof font?.lineHeight === 'number'
+ ? `${font.lineHeight}px`
+ : font?.lineHeight || '1.3em'
+
+ const renderSvg = (raw: string) => {
+ if (!raw) return null
+
+ const sanitizeSvg = (svg: string): string => {
+ // Strip script tags and event handlers for XSS protection
+ let cleaned = svg
+ .replace(/