feat(docs): refine product showcase and branding

This commit is contained in:
2026-07-16 18:11:06 +02:00
parent d61d6f3c5b
commit 0a185077af
20 changed files with 1658 additions and 663 deletions

View File

@@ -2,6 +2,8 @@
import type { CSSProperties, PointerEvent } from "react";
import { useRef } from "react";
import { motion, useReducedMotion } from "motion/react";
import { BrandMark } from "@/components/brand";
import { Icon } from "@/components/icons";
type StageStyle = CSSProperties & {
@@ -9,12 +11,16 @@ type StageStyle = CSSProperties & {
"--pointer-y": string;
};
const cycle = 10.5;
const rest = 1.2;
export function HeroIllustration() {
const stageRef = useRef<HTMLDivElement>(null);
const reduceMotion = useReducedMotion();
const updateParallax = (event: PointerEvent<HTMLDivElement>) => {
const stage = stageRef.current;
if (!stage || window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
if (!stage || reduceMotion) return;
const bounds = stage.getBoundingClientRect();
const x = ((event.clientX - bounds.left) / bounds.width - 0.5) * 2;
const y = ((event.clientY - bounds.top) / bounds.height - 0.5) * 2;
@@ -27,6 +33,8 @@ export function HeroIllustration() {
stageRef.current?.style.setProperty("--pointer-y", "0");
};
const repeating = { duration: cycle, repeat: Infinity, repeatDelay: rest };
return (
<figure className="hero-visual">
<div
@@ -37,179 +45,341 @@ export function HeroIllustration() {
onPointerLeave={resetParallax}
aria-hidden="true"
>
<div className="hero-grid" />
<div className="hero-glow hero-glow-one" />
<div className="hero-glow hero-glow-two" />
<div className="hero-status hero-status-secure">
<span className="status-icon">
<Icon name="lock" />
</span>
<span>
<strong>End-to-end encrypted</strong>
<small>Authenticated connection</small>
</span>
<div className="hero-stage-grid" />
<div className="hero-stage-glow" />
<span className="stage-coordinate stage-coordinate-top">NODE / SEND · 01</span>
<span className="stage-coordinate stage-coordinate-side">SECURE CHANNEL</span>
<div className="desktop-product-shell">
<div className="desktop-product-window">
<div className="desktop-window-bar">
<span className="window-controls"><i /><i /><i /></span>
<span className="window-title">VniDrop · Send</span>
<span className="window-security"><Icon name="lock" /> Encrypted</span>
</div>
<div className="desktop-app-layout">
<aside className="desktop-sidebar">
<BrandMark className="product-brand-mark" />
<span className="desktop-sidebar-item is-active"><Icon name="file" /></span>
<span className="desktop-sidebar-item"><Icon name="devices" /></span>
<span className="desktop-sidebar-item"><Icon name="shield" /></span>
<span className="desktop-sidebar-spacer" />
<span className="desktop-profile">AH</span>
</aside>
<div className="desktop-transfer-view">
<div className="desktop-view-heading">
<div>
<small>OUTGOING TRANSFER</small>
<strong>Summer archive</strong>
</div>
<span className="sharing-pill"><i /> Sharing</span>
</div>
<div className="file-collection-card">
<div className="file-collage" aria-hidden="true">
<span /><span /><span /><span />
</div>
<div className="file-collection-copy">
<strong>summer-photos</strong>
<span>42 files · 486 MB</span>
<small><Icon name="folder" /> Structure preserved</small>
</div>
<span className="collection-menu"></span>
</div>
<div className="receiver-section-heading">
<span>Receivers</span>
<small>1 connected</small>
</div>
<div className="desktop-receiver-row">
<span className="receiver-avatar">M</span>
<span className="receiver-identity">
<strong>Maya&apos;s iPhone</strong>
<small>iOS · nearby device</small>
</span>
<span className="receiver-state-stack">
<motion.span
className="receiver-row-state is-waiting"
animate={
reduceMotion
? { opacity: 0 }
: { opacity: [1, 1, 0, 0] }
}
transition={{ ...repeating, times: [0, 0.4, 0.48, 1] }}
>
Approval required
</motion.span>
<motion.span
className="receiver-row-state is-sending"
animate={
reduceMotion
? { opacity: 0 }
: { opacity: [0, 0, 1, 1, 0, 0] }
}
transition={{ ...repeating, times: [0, 0.43, 0.5, 0.71, 0.78, 1] }}
>
Sending securely
</motion.span>
<motion.span
className="receiver-row-state is-complete"
animate={
reduceMotion
? { opacity: 1 }
: { opacity: [0, 0, 1, 1] }
}
transition={{ ...repeating, times: [0, 0.72, 0.79, 1] }}
>
<Icon name="verified" /> Verified
</motion.span>
</span>
<div className="receiver-progress-track">
<motion.span
animate={
reduceMotion
? { scaleX: 1 }
: { scaleX: [0, 0, 0.04, 0.72, 1, 1] }
}
transition={{ ...repeating, times: [0, 0.46, 0.51, 0.67, 0.76, 1] }}
/>
</div>
</div>
<div className="desktop-transfer-foot">
<span><Icon name="shield" /> Approval by default</span>
<span><Icon name="lock" /> End-to-end encrypted</span>
</div>
</div>
</div>
<motion.div
className="approval-knock-card"
animate={
reduceMotion
? { opacity: 0, y: 8, scale: 0.98 }
: {
opacity: [0, 0, 1, 1, 0, 0],
y: [12, 12, 0, 0, -5, -5],
scale: [0.97, 0.97, 1, 1, 0.985, 0.985],
}
}
transition={{ ...repeating, times: [0, 0.24, 0.3, 0.42, 0.49, 1], ease: "easeInOut" }}
>
<div className="approval-knock-top">
<span className="receiver-avatar">M</span>
<span>
<small>NEW RECEIVER</small>
<strong>Maya&apos;s iPhone</strong>
</span>
<span className="approval-lock"><Icon name="lock" /></span>
</div>
<p>Wants to receive <strong>summer-photos</strong></p>
<div className="approval-knock-actions">
<span>Refuse</span>
<motion.span
className="approval-accept"
animate={reduceMotion ? undefined : { scale: [1, 1, 1.055, 1, 1] }}
transition={{ ...repeating, times: [0, 0.38, 0.42, 0.46, 1] }}
>
<Icon name="check" /> Approve
</motion.span>
</div>
</motion.div>
</div>
</div>
<div className="hero-status hero-status-route">
<span className="status-pulse" />
Direct route found
</div>
<svg className="hero-scene" viewBox="0 0 760 620">
<svg className="hero-conduit" viewBox="0 0 760 620" fill="none">
<defs>
<linearGradient id="hero-purple" x1="240" y1="90" x2="620" y2="520">
<stop stopColor="#C16BFF" />
<stop offset="0.48" stopColor="#A855F7" />
<stop offset="1" stopColor="#6F27E9" />
<linearGradient id="conduit-gradient" x1="410" y1="340" x2="650" y2="390" gradientUnits="userSpaceOnUse">
<stop stopColor="#C084FC" />
<stop offset="1" stopColor="#7C2AEF" />
</linearGradient>
<linearGradient id="hero-screen" x1="88" y1="118" x2="342" y2="345">
<stop stopColor="#25202C" />
<stop offset="1" stopColor="#151319" />
</linearGradient>
<linearGradient id="hero-phone" x1="540" y1="160" x2="692" y2="465">
<stop stopColor="#2A2530" />
<stop offset="1" stopColor="#141217" />
</linearGradient>
<radialGradient id="hero-orb">
<stop stopColor="#E3BDFF" stopOpacity="0.9" />
<stop offset="0.45" stopColor="#A855F7" stopOpacity="0.45" />
<stop offset="1" stopColor="#A855F7" stopOpacity="0" />
</radialGradient>
<filter id="hero-shadow" x="-30%" y="-30%" width="160%" height="180%">
<feDropShadow dx="0" dy="20" stdDeviation="18" floodColor="#3F2454" floodOpacity="0.16" />
<filter id="conduit-blur" x="-30%" y="-80%" width="160%" height="260%">
<feGaussianBlur stdDeviation="8" />
</filter>
<filter id="hero-glow-filter" x="-100%" y="-100%" width="300%" height="300%">
<feGaussianBlur stdDeviation="12" />
</filter>
<clipPath id="laptop-screen-clip">
<rect x="82" y="132" width="264" height="176" rx="12" />
</clipPath>
<clipPath id="phone-screen-clip">
<rect x="548" y="171" width="136" height="279" rx="25" />
</clipPath>
</defs>
<g className="scene-orbits">
<ellipse cx="385" cy="325" rx="315" ry="216" fill="none" stroke="#B969F8" strokeOpacity="0.14" />
<ellipse cx="385" cy="325" rx="245" ry="169" fill="none" stroke="#B969F8" strokeOpacity="0.12" strokeDasharray="8 14" />
<circle cx="670" cy="215" r="5" fill="#A855F7" />
<circle cx="117" cy="455" r="4" fill="#C98AFD" />
<path d="m704 345 4 10 10 4-10 4-4 10-4-10-10-4 10-4Z" fill="#D9AEFF" />
</g>
<g className="scene-laptop" filter="url(#hero-shadow)">
<rect x="69" y="117" width="290" height="207" rx="19" fill="#E8E4EB" stroke="#D6CFDA" strokeWidth="2" />
<rect x="80" y="129" width="268" height="183" rx="13" fill="url(#hero-screen)" />
<g clipPath="url(#laptop-screen-clip)">
<circle cx="208" cy="235" r="108" fill="#A855F7" opacity="0.07" />
<path d="M80 278c62-45 99-24 141-60 39-34 70-39 127-31v125H80Z" fill="#A855F7" opacity="0.09" />
<rect x="101" y="151" width="101" height="13" rx="6.5" fill="#3E3745" />
<rect x="101" y="175" width="67" height="7" rx="3.5" fill="#5E5366" />
<g className="laptop-files">
<rect x="118" y="205" width="76" height="79" rx="10" fill="#F8F2FC" stroke="#C98AFD" />
<path d="M173 205h21v21Z" fill="#E3C3FA" />
<rect x="129" y="219" width="24" height="24" rx="6" fill="#E8D0FB" />
<path d="m135 238 7-8 5 5 4-4 6 7Z" fill="#A855F7" opacity="0.75" />
<rect x="129" y="253" width="46" height="6" rx="3" fill="#C3B6CB" />
<rect x="129" y="265" width="34" height="5" rx="2.5" fill="#DDD4E2" />
<rect x="181" y="223" width="82" height="68" rx="10" fill="#2F2935" stroke="#574D5E" />
<path d="M242 223h21v21Z" fill="#4F4557" />
<circle cx="205" cy="245" r="9" fill="#A855F7" opacity="0.75" />
<rect x="195" y="264" width="49" height="6" rx="3" fill="#63586A" />
<rect x="195" y="276" width="34" height="5" rx="2.5" fill="#4D4453" />
</g>
</g>
<path d="M46 324h336l-20 23a20 20 0 0 1-15 6H81a20 20 0 0 1-15-7Z" fill="#D9D3DD" stroke="#CAC1CE" strokeWidth="2" />
<path d="M183 324h62l-7 12h-48Z" fill="#C1B8C6" />
</g>
<g className="transfer-conduit">
<path
className="conduit-glow"
d="M284 358c23 134 225 151 279 35"
fill="none"
stroke="#B65DF9"
strokeOpacity="0.2"
strokeWidth="34"
strokeLinecap="round"
filter="url(#hero-glow-filter)"
/>
<path
d="M284 358c23 134 225 151 279 35"
fill="none"
stroke="#ECE3F1"
strokeWidth="19"
strokeLinecap="round"
/>
<path
className="conduit-energy"
d="M284 358c23 134 225 151 279 35"
fill="none"
stroke="url(#hero-purple)"
strokeWidth="7"
strokeLinecap="round"
strokeDasharray="2 17"
/>
<circle cx="284" cy="358" r="25" fill="#F8F2FC" stroke="#C98AFD" strokeWidth="2" />
<circle cx="284" cy="358" r="13" fill="#A855F7" opacity="0.15" />
<path d="m276 358 7 7 11-14" fill="none" stroke="#9333EA" strokeWidth="3" strokeLinecap="round" />
</g>
<g className="approval-gate" filter="url(#hero-shadow)">
<rect x="365" y="414" width="151" height="58" rx="18" fill="#FEFCFF" stroke="#DFCDEB" />
<circle cx="393" cy="443" r="15" fill="#F0DDFE" />
<path d="M393 433.5 385.5 437v5.3c0 4.5 3.2 7.6 7.5 8.9 4.3-1.3 7.5-4.4 7.5-8.9V437Z" fill="#A855F7" />
<path d="m389.5 442 2.2 2.2 4.5-4.6" fill="none" stroke="white" strokeWidth="1.8" strokeLinecap="round" />
<text x="418" y="439" fill="#201B24" fontSize="12" fontWeight="700">Approved</text>
<text x="418" y="455" fill="#74697B" fontSize="10">Mayas phone</text>
</g>
<g className="scene-phone" filter="url(#hero-shadow)">
<rect x="536" y="157" width="160" height="309" rx="36" fill="#E4DFE7" stroke="#CFC6D3" strokeWidth="2" />
<rect x="546" y="168" width="140" height="285" rx="28" fill="url(#hero-phone)" />
<rect x="587" y="177" width="58" height="8" rx="4" fill="#0B090D" />
<g clipPath="url(#phone-screen-clip)">
<circle cx="616" cy="271" r="76" fill="#A855F7" opacity="0.1" />
<circle cx="616" cy="275" r="49" fill="url(#hero-orb)" opacity="0.55" />
<g className="received-file">
<rect x="579" y="224" width="74" height="92" rx="12" fill="#FCF9FD" stroke="#C98AFD" strokeWidth="1.5" />
<path d="M630 224h23v23Z" fill="#DEC0F6" />
<rect x="590" y="239" width="26" height="26" rx="7" fill="#E9D2FA" />
<path d="m595 259 8-9 5 5 5-5 6 9Z" fill="#9E47EF" />
<rect x="590" y="278" width="46" height="6" rx="3" fill="#C6BACD" />
<rect x="590" y="291" width="34" height="5" rx="2.5" fill="#DFD6E4" />
</g>
<rect x="564" y="337" width="104" height="42" rx="12" fill="#2F2935" stroke="#4B4251" />
<circle cx="581" cy="358" r="8" fill="#A855F7" />
<path d="m577.5 358 2.2 2.2 4.5-4.6" fill="none" stroke="white" strokeWidth="1.5" />
<text x="595" y="355" fill="#FCF9FD" fontSize="10" fontWeight="700">Received</text>
<text x="595" y="368" fill="#A89EAF" fontSize="8">Verified safely</text>
<rect x="564" y="393" width="104" height="5" rx="2.5" fill="#3F3745" />
<rect x="564" y="393" width="104" height="5" rx="2.5" fill="#A855F7" />
</g>
</g>
<g className="transfer-packets">
<g className="packet packet-one">
<rect x="0" y="0" width="28" height="34" rx="6" fill="#FCF9FE" stroke="#A855F7" />
<path d="M19 0h9v9Z" fill="#DDBCF7" />
</g>
<g className="packet packet-two">
<rect x="0" y="0" width="22" height="27" rx="5" fill="#EEE0F9" stroke="#B869F4" />
<path d="M14 0h8v8Z" fill="#CAA0E9" />
</g>
<circle className="packet-spark packet-spark-one" cx="0" cy="0" r="5" fill="#D795FF" />
<circle className="packet-spark packet-spark-two" cx="0" cy="0" r="3" fill="#A855F7" />
</g>
<path
d="M414 342C480 344 508 392 571 390C606 389 624 373 646 351"
stroke="#D9D0DE"
strokeWidth="14"
strokeLinecap="round"
/>
<motion.path
d="M414 342C480 344 508 392 571 390C606 389 624 373 646 351"
stroke="#A855F7"
strokeOpacity="0.24"
strokeWidth="28"
strokeLinecap="round"
filter="url(#conduit-blur)"
animate={
reduceMotion
? { opacity: 0.55, pathLength: 1 }
: { opacity: [0.05, 0.05, 0.55, 0.55, 0.15], pathLength: [0, 0, 1, 1, 1] }
}
transition={{ ...repeating, times: [0, 0.46, 0.7, 0.82, 1], ease: "easeInOut" }}
/>
<motion.path
d="M414 342C480 344 508 392 571 390C606 389 624 373 646 351"
stroke="url(#conduit-gradient)"
strokeWidth="6"
strokeLinecap="round"
animate={
reduceMotion
? { pathLength: 1, opacity: 0.85 }
: { pathLength: [0, 0, 1, 1, 1], opacity: [0.25, 0.25, 1, 1, 0.45] }
}
transition={{ ...repeating, times: [0, 0.46, 0.7, 0.82, 1], ease: "easeInOut" }}
/>
<motion.circle
r="8"
fill="#FCF9FF"
stroke="#A855F7"
strokeWidth="3"
animate={
reduceMotion
? { cx: 646, cy: 351, opacity: 0 }
: {
cx: [414, 414, 448, 505, 570, 620, 646, 646],
cy: [342, 342, 348, 382, 390, 372, 351, 351],
opacity: [0, 0, 1, 1, 1, 1, 0, 0],
scale: [0.7, 0.7, 1, 1, 1, 1, 0.7, 0.7],
}
}
transition={{ ...repeating, times: [0, 0.49, 0.53, 0.59, 0.65, 0.7, 0.74, 1], ease: "easeInOut" }}
/>
<motion.circle
r="5"
fill="#D8B4FE"
animate={
reduceMotion
? { cx: 646, cy: 351, opacity: 0 }
: {
cx: [414, 414, 448, 505, 570, 620, 646, 646],
cy: [342, 342, 348, 382, 390, 372, 351, 351],
opacity: [0, 0, 1, 1, 1, 1, 0, 0],
}
}
transition={{ ...repeating, times: [0, 0.56, 0.6, 0.66, 0.72, 0.77, 0.81, 1], ease: "easeInOut" }}
/>
</svg>
<div className="hero-file-label">
<span className="file-type-icon">JPG</span>
<div className="receiver-phone-shell">
<span className="phone-side-button phone-side-button-one" />
<span className="phone-side-button phone-side-button-two" />
<div className="receiver-phone-frame">
<div className="phone-screen">
<div className="phone-status-bar">
<span>9:41</span>
<span className="phone-island" />
<span className="phone-signal"> </span>
</div>
<div className="phone-app-bar">
<BrandMark className="phone-brand-mark" />
<strong>Receive</strong>
<span><Icon name="shield" /></span>
</div>
<div className="phone-transfer-card">
<div className="phone-file-art"><Icon name="folder" /></div>
<small>INCOMING FROM ALEX</small>
<strong>summer-photos</strong>
<span>42 files · 486 MB</span>
</div>
<div className="phone-state-area">
<motion.div
className="phone-state is-waiting"
animate={
reduceMotion
? { opacity: 0 }
: { opacity: [1, 1, 0, 0] }
}
transition={{ ...repeating, times: [0, 0.43, 0.5, 1] }}
>
<span className="state-symbol"><Icon name="lock" /></span>
<strong>Waiting for approval</strong>
<small>The sender stays in control.</small>
</motion.div>
<motion.div
className="phone-state is-transferring"
animate={
reduceMotion
? { opacity: 0 }
: { opacity: [0, 0, 1, 1, 0, 0] }
}
transition={{ ...repeating, times: [0, 0.45, 0.51, 0.71, 0.78, 1] }}
>
<span className="transfer-percentage">68<span>%</span></span>
<strong>Receiving securely</strong>
<small>Direct path · 38 MB/s</small>
</motion.div>
<motion.div
className="phone-state is-verified"
animate={
reduceMotion
? { opacity: 1, scale: 1 }
: { opacity: [0, 0, 1, 1], scale: [0.92, 0.92, 1, 1] }
}
transition={{ ...repeating, times: [0, 0.72, 0.79, 1], ease: "easeOut" }}
>
<span className="state-symbol is-success"><Icon name="verified" /></span>
<strong>Received &amp; verified</strong>
<small>Saved to Downloads</small>
</motion.div>
</div>
<div className="phone-home-indicator" />
</div>
</div>
</div>
<motion.div
className="hero-route-card"
animate={
reduceMotion
? { opacity: 1, y: 0 }
: { opacity: [0, 1, 1, 1], y: [-8, 0, 0, 0] }
}
transition={{ ...repeating, times: [0, 0.08, 0.92, 1], ease: "easeOut" }}
>
<span className="route-pulse" />
<span>
<strong>summer-photos</strong>
<small>42 files · structure preserved</small>
<small>ROUTE</small>
<strong>Direct path established</strong>
</span>
<span className="route-latency">18 ms</span>
</motion.div>
<motion.div
className="hero-verified-card"
animate={
reduceMotion
? { opacity: 1, y: 0, scale: 1 }
: {
opacity: [0, 0, 1, 1],
y: [8, 8, 0, 0],
scale: [0.96, 0.96, 1, 1],
}
}
transition={{ ...repeating, times: [0, 0.74, 0.81, 1], ease: "easeOut" }}
>
<span><Icon name="verified" /></span>
<span>
<strong>Content verified</strong>
<small>42 / 42 files match</small>
</span>
</motion.div>
<div className="hero-privacy-rail">
<span><Icon name="lock" /> E2E encrypted</span>
<i />
<span>No hosted copy</span>
<i />
<span>Approval required</span>
</div>
</div>
<figcaption className="sr-only">
An illustration of encrypted files moving from a laptop to a phone after receiver approval.
A VniDrop transfer moves from a desktop sender to a phone after receiver approval, then is
verified on arrival.
</figcaption>
</figure>
);