diff --git a/src/components/layout/Header.tsx b/src/components/layout/Header.tsx index dd4453c..dfb4d49 100644 --- a/src/components/layout/Header.tsx +++ b/src/components/layout/Header.tsx @@ -460,7 +460,7 @@ export default function Header() { handleNavClick(event, "/", "home")}>Home
  • - Doormile Wings + Wings
  • handleNavClick(event, "/miletruth", "miletruth", true)}>MileTruth™ AI diff --git a/src/components/sections/WingsMileTruth.tsx b/src/components/sections/WingsMileTruth.tsx index a47a482..a8defbc 100644 --- a/src/components/sections/WingsMileTruth.tsx +++ b/src/components/sections/WingsMileTruth.tsx @@ -1,6 +1,7 @@ "use client"; import { useEffect, useRef } from "react"; +import gsap from "gsap"; import { ScrollReveal, StaggerChildren } from "@/animations/Reveal"; const CAPABILITIES = [ @@ -64,6 +65,114 @@ const CAPABILITIES = [ }, ]; +function TiltCard({ children, className }: { children: React.ReactNode, className: string }) { + const cardRef = useRef(null); + const glareRef = useRef(null); + + useEffect(() => { + const card = cardRef.current; + const glare = glareRef.current; + if (!card || !glare) return; + const reduced = window.matchMedia?.("(prefers-reduced-motion: reduce)").matches; + if (reduced) return; + + const handleMouseMove = (e: MouseEvent) => { + const rect = card.getBoundingClientRect(); + const x = e.clientX - rect.left; + const y = e.clientY - rect.top; + + const centerX = rect.width / 2; + const centerY = rect.height / 2; + + // Softer, more premium 3D tilt + const rotateX = ((y - centerY) / centerY) * -3; + const rotateY = ((x - centerX) / centerX) * 3; + + gsap.to(card, { + duration: 0.4, + rotateX, + rotateY, + y: -4, + transformPerspective: 1000, + ease: "power2.out" + }); + + // Dynamic glare tracking + gsap.set(glare, { + "--mx": `${x}px`, + "--my": `${y}px`, + }); + gsap.to(glare, { + duration: 0.3, + opacity: 1, + ease: "power2.out" + }); + + // Z-axis pop + gsap.to(card.querySelector('.wmt-card-icon'), { + duration: 0.4, + z: 25, + ease: "power2.out" + }); + gsap.to(card.querySelector('.wmt-card-title'), { + duration: 0.4, + z: 15, + ease: "power2.out" + }); + }; + + const handleMouseLeave = () => { + gsap.to(card, { + duration: 0.8, + rotateX: 0, + rotateY: 0, + y: 0, + ease: "power3.out" + }); + gsap.to(glare, { + duration: 0.5, + opacity: 0, + ease: "power3.out" + }); + gsap.to(card.querySelectorAll('.wmt-card-icon, .wmt-card-title'), { + duration: 0.8, + z: 0, + ease: "power3.out" + }); + }; + + card.addEventListener("mousemove", handleMouseMove); + card.addEventListener("mouseleave", handleMouseLeave); + + return () => { + card.removeEventListener("mousemove", handleMouseMove); + card.removeEventListener("mouseleave", handleMouseLeave); + }; + }, []); + + return ( +
    +
    +
    + {children} +
    +
    + ); +} + export default function WingsMileTruth() { const canvasRef = useRef(null); @@ -238,11 +347,12 @@ export default function WingsMileTruth() { border: 1px solid rgba(255, 255, 255, 0.07); border-radius: 18px; padding: 26px 22px; - transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.5s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.5s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1); + transition: border-color 0.5s cubic-bezier(0.16, 1, 0.3, 1), background-color 0.5s cubic-bezier(0.16, 1, 0.3, 1), box-shadow 0.5s cubic-bezier(0.16, 1, 0.3, 1); + transform-style: preserve-3d; + will-change: transform; } .wmt-card:hover { - transform: translateY(-6px); border-color: rgba(255, 255, 255, 0.16); background-color: rgba(255, 255, 255, 0.055); box-shadow: 0 20px 40px rgba(0, 0, 0, 0.35); @@ -370,11 +480,11 @@ export default function WingsMileTruth() { {CAPABILITIES.map((c) => ( -
    +

    {c.title}

    {c.desc}

    -
    + ))}
    diff --git a/src/components/sections/WingsWhyGrid.tsx b/src/components/sections/WingsWhyGrid.tsx index 98dcbc6..2e6722b 100644 --- a/src/components/sections/WingsWhyGrid.tsx +++ b/src/components/sections/WingsWhyGrid.tsx @@ -25,6 +25,14 @@ const MOBILE_BQ = 600; const AIRCRAFT_ROTATION_OFFSET = 90; // glyph's rest pose points "up" const WAVE_AMPLITUDE_MIN = 20; const WAVE_AMPLITUDE_MAX = 38; +// Row-wrapped grid layouts only (e.g. the 601-1024px 2-column tablet +// breakpoint) — single-row layouts (desktop, mobile) never use these. +const ROW_Y_TOLERANCE = 12; // px tolerance for clustering card anchors into the same row +const ROW_CLEARANCE_PADDING = 6; // safety margin between a crest and the row above's card bottom +// Exact max of buildWavePoints' ampFactor (1 + 0.28 + 0.15), used to clamp a +// row's wave amplitude so no crest can ever reach into the row above it, +// regardless of segment index/phase. +const MAX_WAVE_AMP_FACTOR = 1 + 0.28 + 0.15; const FEATURES = [ { @@ -79,6 +87,20 @@ interface Point { y: number; } +// A non-mobile card anchor, carrying its source card's full box (wrap- +// relative) alongside the {x,y} route point — top/bottom are needed to +// group anchors into rows and clamp a row's wave amplitude against the row +// above it; left/right are needed to find the empty column gutter (the +// icon's own x sits near the left edge of its card, not the card's true +// horizontal center, so card edges — not icon x — must drive that +// calculation). Never leaves measure(); only plain Point[] is returned. +interface CardAnchor extends Point { + top: number; + bottom: number; + left: number; + right: number; +} + function buildPathD(points: Point[]): string { if (points.length < 2) return ""; let d = `M ${points[0].x} ${points[0].y}`; @@ -126,6 +148,87 @@ function buildWavePoints(points: Point[], amplitude: number): Point[] { return waved; } +// Clusters card anchors into visual rows by their shared top edge. CSS grid +// auto-flow is row-major and `align-items: stretch` (the grid default) makes +// same-row cards share cardRect.top exactly, so a single-pass walk against +// the current row's reference y (with a small tolerance for sub-pixel +// getBoundingClientRect noise) is sufficient — no need for full clustering. +function groupIntoRows(anchors: CardAnchor[]): CardAnchor[][] { + const rows: CardAnchor[][] = []; + for (const a of anchors) { + const row = rows[rows.length - 1]; + if (row && Math.abs(row[0].top - a.top) <= ROW_Y_TOLERANCE) { + row.push(a); + } else { + rows.push([a]); + } + } + return rows; +} + +// The x midpoint of the empty strip between two adjacent columns in a row — +// i.e. a real, always-card-free gutter, derived from actual card edges (not +// icon x, which sits near each card's left padding rather than its true +// horizontal center). `side` picks which internal gutter: "right" is +// between the two rightmost columns, "left" between the two leftmost — for +// the current 2-column layout these are the same single gutter, but this +// stays correct if a wider grid (3+ columns) ever wraps into multiple rows. +function computeColumnGutterX(row: CardAnchor[], side: "left" | "right"): number { + const sorted = [...row].sort((a, b) => a.left - b.left); + if (sorted.length < 2) return (sorted[0].left + sorted[0].right) / 2; + return side === "right" + ? (sorted[sorted.length - 2].right + sorted[sorted.length - 1].left) / 2 + : (sorted[0].right + sorted[1].left) / 2; +} + +// Builds a safe serpentine (boustrophedon) route across a row-wrapped grid: +// each row is traversed in alternating direction so consecutive rows' ends +// line up on the same side, and the vertical drop between rows is routed +// through the always-empty column gutter on that side rather than through +// either column's own x — the only way to guarantee the connector never +// crosses a card, since a card anchor sits only ~10px below the card above +// it in the same column. Each row's own wave amplitude is clamped to the +// measured clearance above it so no crest can reach into the row above. +function buildMultiRowRoute(rows: CardAnchor[][], baseAmplitude: number): Point[] { + const out: Point[] = []; + let prevRowBottom: number | null = null; + + rows.forEach((row, ri) => { + const ordered = ri % 2 === 0 ? row : [...row].reverse(); + const rowAnchorY = ordered[0].y; + + const amplitude = + prevRowBottom === null + ? baseAmplitude + : Math.max( + 0, + Math.min(baseAmplitude, (rowAnchorY - prevRowBottom - ROW_CLEARANCE_PADDING) / MAX_WAVE_AMP_FACTOR) + ); + + const waved = buildWavePoints( + ordered.map(({ x, y }) => ({ x, y })), + amplitude + ); + + if (out.length > 0) { + const prevPoint = out[out.length - 1]; + const nextPoint = waved[0]; + // Row (ri-1) ends at its right extreme when it traveled left-to-right + // (even index, unreversed), left extreme when it traveled + // right-to-left (odd index, reversed) — the gutter must sit on that + // same side so the dogleg never has to cross back over a card. + const side: "left" | "right" = (ri - 1) % 2 === 0 ? "right" : "left"; + const gutterX = computeColumnGutterX(rows[ri - 1], side); + out.push({ x: gutterX, y: prevPoint.y }, { x: gutterX, y: nextPoint.y }); + } + out.push(...waved); + + prevRowBottom = Math.max(...row.map((a) => a.bottom)); + }); + + return out; +} + export default function WingsWhyGrid() { const wrapRef = useRef(null); const svgRef = useRef(null); @@ -198,24 +301,43 @@ export default function WingsWhyGrid() { const wrapRect = wrap.getBoundingClientRect(); const isMobile = isMobileLayout(); - const points = cardRefs.current + // Card box (wrap-relative) is captured alongside x/y for every card, + // mobile included, so the shape is uniform — only the non-mobile + // branch below actually reads top/bottom/left/right (to group into + // rows, clamp wave amplitude against the row above, and find the + // empty column gutter for inter-row transitions). + const anchors = cardRefs.current .map((card, i) => { const icon = iconRefs.current[i]; if (!card || !icon) return null; const cardRect = card.getBoundingClientRect(); const iconRect = icon.getBoundingClientRect(); + const top = cardRect.top - wrapRect.top; + const bottom = cardRect.bottom - wrapRect.top; + const left = cardRect.left - wrapRect.left; + const right = cardRect.right - wrapRect.left; if (isMobile) { return { x: 12, y: iconRect.top + iconRect.height / 2 - wrapRect.top, + top, + bottom, + left, + right, }; } return { x: iconRect.left + iconRect.width / 2 - wrapRect.left, y: cardRect.top - wrapRect.top - 14, + top, + bottom, + left, + right, }; }) - .filter((p): p is Point => p !== null); + .filter((p): p is CardAnchor => p !== null); + + const points: Point[] = anchors.map(({ x, y }) => ({ x, y })); svg.setAttribute("width", String(wrapRect.width)); svg.setAttribute("height", String(wrapRect.height)); @@ -228,7 +350,18 @@ export default function WingsWhyGrid() { const amplitude = isMobile ? 0 : Math.max(WAVE_AMPLITUDE_MIN, Math.min(WAVE_AMPLITUDE_MAX, wrapRect.width / 20)); - const routePoints = isMobile ? points : buildWavePoints(points, amplitude); + + let routePoints: Point[]; + if (isMobile) { + routePoints = points; + } else { + const rows = groupIntoRows(anchors); + // A single row (desktop, always 4-up) takes the exact original + // path — no behavior change there. Only a row-wrapped layout (the + // tablet/small-laptop 2-column breakpoint) uses the row-aware + // serpentine route. + routePoints = rows.length <= 1 ? buildWavePoints(points, amplitude) : buildMultiRowRoute(rows, amplitude); + } const d = buildPathD(routePoints); path.setAttribute("d", d); illum.setAttribute("d", d);