From babf5e73379f4cd80396bc4a371a03704ddaffe0 Mon Sep 17 00:00:00 2001 From: Pratyush Sharma <56130065+pratyush618@users.noreply.github.com> Date: Fri, 26 Jun 2026 18:06:42 +0530 Subject: [PATCH] feat: Midnight theme, IBM Plex, hero glow --- .vscode/settings.json | 6 + package.json | 1 - pnpm-lock.yaml | 44 +++++-- src/app/globals.css | 211 ++++++++++++++++++++++++++--- src/app/layout.tsx | 29 +++- src/components/docs-hero.tsx | 18 ++- src/components/hero-net.tsx | 220 +++++++++++++++++++++++++++++++ src/components/tools-section.tsx | 2 +- src/components/ui/section.tsx | 24 +++- 9 files changed, 513 insertions(+), 42 deletions(-) create mode 100644 .vscode/settings.json create mode 100644 src/components/hero-net.tsx diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..fb1f903 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,6 @@ +{ + "// Tailwind v4 at-rules (@custom-variant, @theme, @apply, …) are unknown to": "the built-in CSS validator; silence its false positives.", + "css.lint.unknownAtRules": "ignore", + "scss.lint.unknownAtRules": "ignore", + "less.lint.unknownAtRules": "ignore" +} diff --git a/package.json b/package.json index cb62929..32a8571 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,6 @@ "sync-ui": "bash scripts/sync-ui.sh" }, "dependencies": { - "geist": "^1.7.0", "lucide-react": "^1.8.0", "next": "16.2.3", "next-themes": "^0.4.6", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9ca944e..9c2104d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -8,9 +8,6 @@ importers: .: dependencies: - geist: - specifier: ^1.7.0 - version: 1.7.0(next@16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)) lucide-react: specifier: ^1.8.0 version: 1.8.0(react@19.2.5) @@ -77,24 +74,28 @@ packages: engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [musl] '@biomejs/cli-linux-arm64@2.4.11': resolution: {integrity: sha512-avdJaEElXrKceK0va9FkJ4P5ci3N01TGkc6ni3P8l3BElqbOz42Wg2IyX3gbh0ZLEd4HVKEIrmuVu/AMuSeFFA==} engines: {node: '>=14.21.3'} cpu: [arm64] os: [linux] + libc: [glibc] '@biomejs/cli-linux-x64-musl@2.4.11': resolution: {integrity: sha512-bexd2IklK7ZgPhrz6jXzpIL6dEAH9MlJU1xGTrypx+FICxrXUp4CqtwfiuoDKse+UlgAlWtzML3jrMqeEAHEhA==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [musl] '@biomejs/cli-linux-x64@2.4.11': resolution: {integrity: sha512-TagWV0iomp5LnEnxWFg4nQO+e52Fow349vaX0Q/PIcX6Zhk4GGBgp3qqZ8PVkpC+cuehRctMf3+6+FgQ8jCEFQ==} engines: {node: '>=14.21.3'} cpu: [x64] os: [linux] + libc: [glibc] '@biomejs/cli-win32-arm64@2.4.11': resolution: {integrity: sha512-RJhaTnY8byzxDt4bDVb7AFPHkPcjOPK3xBip4ZRTrN3TEfyhjLRm3r3mqknqydgVTB74XG8l4jMLwEACEeihVg==} @@ -141,89 +142,105 @@ packages: resolution: {integrity: sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-arm@1.2.4': resolution: {integrity: sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-ppc64@1.2.4': resolution: {integrity: sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-riscv64@1.2.4': resolution: {integrity: sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-s390x@1.2.4': resolution: {integrity: sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-libvips-linux-x64@1.2.4': resolution: {integrity: sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-libvips-linuxmusl-arm64@1.2.4': resolution: {integrity: sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-libvips-linuxmusl-x64@1.2.4': resolution: {integrity: sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-linux-arm64@0.34.5': resolution: {integrity: sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [glibc] '@img/sharp-linux-arm@0.34.5': resolution: {integrity: sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm] os: [linux] + libc: [glibc] '@img/sharp-linux-ppc64@0.34.5': resolution: {integrity: sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [ppc64] os: [linux] + libc: [glibc] '@img/sharp-linux-riscv64@0.34.5': resolution: {integrity: sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [riscv64] os: [linux] + libc: [glibc] '@img/sharp-linux-s390x@0.34.5': resolution: {integrity: sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [s390x] os: [linux] + libc: [glibc] '@img/sharp-linux-x64@0.34.5': resolution: {integrity: sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [glibc] '@img/sharp-linuxmusl-arm64@0.34.5': resolution: {integrity: sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [arm64] os: [linux] + libc: [musl] '@img/sharp-linuxmusl-x64@0.34.5': resolution: {integrity: sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} cpu: [x64] os: [linux] + libc: [musl] '@img/sharp-wasm32@0.34.5': resolution: {integrity: sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==} @@ -284,24 +301,28 @@ packages: engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [glibc] '@next/swc-linux-arm64-musl@16.2.3': resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] + libc: [musl] '@next/swc-linux-x64-gnu@16.2.3': resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [glibc] '@next/swc-linux-x64-musl@16.2.3': resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] + libc: [musl] '@next/swc-win32-arm64-msvc@16.2.3': resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} @@ -356,24 +377,28 @@ packages: engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-arm64-musl@4.2.2': resolution: {integrity: sha512-oCfG/mS+/+XRlwNjnsNLVwnMWYH7tn/kYPsNPh+JSOMlnt93mYNCKHYzylRhI51X+TbR+ufNhhKKzm6QkqX8ag==} engines: {node: '>= 20'} cpu: [arm64] os: [linux] + libc: [musl] '@tailwindcss/oxide-linux-x64-gnu@4.2.2': resolution: {integrity: sha512-rTAGAkDgqbXHNp/xW0iugLVmX62wOp2PoE39BTCGKjv3Iocf6AFbRP/wZT/kuCxC9QBh9Pu8XPkv/zCZB2mcMg==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [glibc] '@tailwindcss/oxide-linux-x64-musl@4.2.2': resolution: {integrity: sha512-XW3t3qwbIwiSyRCggeO2zxe3KWaEbM0/kW9e8+0XpBgyKU4ATYzcVSMKteZJ1iukJ3HgHBjbg9P5YPRCVUxlnQ==} engines: {node: '>= 20'} cpu: [x64] os: [linux] + libc: [musl] '@tailwindcss/oxide-wasm32-wasi@4.2.2': resolution: {integrity: sha512-eKSztKsmEsn1O5lJ4ZAfyn41NfG7vzCg496YiGtMDV86jz1q/irhms5O0VrY6ZwTUkFy/EKG3RfWgxSI3VbZ8Q==} @@ -439,11 +464,6 @@ packages: resolution: {integrity: sha512-Qohcme7V1inbAfvjItgw0EaxVX5q2rdVEZHRBrEQdRZTssLDGsL8Lwrznl8oQ/6kuTJONLaDcGjkNP247XEhcA==} engines: {node: '>=10.13.0'} - geist@1.7.0: - resolution: {integrity: sha512-ZaoiZwkSf0DwwB1ncdLKp+ggAldqxl5L1+SXaNIBGkPAqcu+xjVJLxlf3/S8vLt9UHx1xu5fz3lbzKCj5iOVdQ==} - peerDependencies: - next: '>=13.2.0' - graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} @@ -486,24 +506,28 @@ packages: engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [glibc] lightningcss-linux-arm64-musl@1.32.0: resolution: {integrity: sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==} engines: {node: '>= 12.0.0'} cpu: [arm64] os: [linux] + libc: [musl] lightningcss-linux-x64-gnu@1.32.0: resolution: {integrity: sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [glibc] lightningcss-linux-x64-musl@1.32.0: resolution: {integrity: sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==} engines: {node: '>= 12.0.0'} cpu: [x64] os: [linux] + libc: [musl] lightningcss-win32-arm64-msvc@1.32.0: resolution: {integrity: sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==} @@ -914,10 +938,6 @@ snapshots: graceful-fs: 4.2.11 tapable: 2.3.2 - geist@1.7.0(next@16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5)): - dependencies: - next: 16.2.3(react-dom@19.2.5(react@19.2.5))(react@19.2.5) - graceful-fs@4.2.11: {} jiti@2.6.1: {} diff --git a/src/app/globals.css b/src/app/globals.css index 903d330..908d077 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -2,22 +2,29 @@ @custom-variant dark (&:where(.dark, .dark *)); +/* ============================================================ + Theme — Midnight (palette shared with byteveda.org) + Warm-paper light · near-black + electric green dark · IBM Plex. + ============================================================ */ + +/* light — warm paper */ :root { - --background: #ffffff; - --foreground: #0a0a0a; - --muted: #f4f4f5; - --muted-foreground: #52525b; - --border: #e4e4e7; - --accent: #10b981; + --background: #f6f4ee; + --foreground: #1b1a15; + --muted: #f2efe7; + --muted-foreground: #615d52; + --border: rgba(45, 38, 18, 0.1); + --accent: #10a152; } +/* dark — Midnight */ .dark { - --background: #09090b; - --foreground: #fafafa; - --muted: #18181b; - --muted-foreground: #a1a1aa; - --border: #27272a; - --accent: #34d399; + --background: #08080c; + --foreground: #ececf4; + --muted: #13131d; + --muted-foreground: #8c8ca3; + --border: rgba(255, 255, 255, 0.08); + --accent: #1f9d54; } @theme inline { @@ -27,8 +34,8 @@ --color-muted-foreground: var(--muted-foreground); --color-border: var(--border); --color-accent: var(--accent); - --font-sans: var(--font-geist-sans); - --font-mono: var(--font-geist-mono); + --font-sans: var(--font-plex-sans); + --font-mono: var(--font-plex-mono); } html { @@ -38,14 +45,14 @@ html { body { background: var(--background); color: var(--foreground); - font-family: var(--font-geist-sans), ui-sans-serif, system-ui, sans-serif; + font-family: var(--font-plex-sans), ui-sans-serif, system-ui, sans-serif; -webkit-font-smoothing: antialiased; text-rendering: optimizeLegibility; } ::selection { background: var(--accent); - color: #0a0a0a; + color: #fff; } .dot-grid { @@ -53,6 +60,178 @@ body { background-size: 24px 24px; } +/* ---------- hero ambiance (green glow · blueprint grid · constellation) ---------- */ +:root { + --grid-line: rgba(16, 161, 82, 0.06); +} +.dark { + --grid-line: rgba(31, 157, 84, 0.07); +} + +.aurora { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; +} +.aurora b { + position: absolute; + display: block; + border-radius: 50%; + filter: blur(82px); + mix-blend-mode: multiply; + background: radial-gradient(circle at 50% 50%, var(--accent), transparent 66%); + will-change: transform; +} +.dark .aurora b { + mix-blend-mode: screen; + filter: blur(72px); +} +.aurora .a1 { + width: 52vw; + height: 52vw; + left: -8vw; + top: -18vw; + opacity: 0.14; +} +.aurora .a2 { + width: 40vw; + height: 40vw; + right: -6vw; + top: 4vh; + opacity: 0.1; +} +.aurora .a3 { + width: 34vw; + height: 34vw; + left: 26vw; + bottom: -22vw; + opacity: 0.08; +} +.dark .aurora .a1 { + opacity: 0.42; +} +.dark .aurora .a2 { + opacity: 0.3; +} +.dark .aurora .a3 { + opacity: 0.2; +} + +.blueprint { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(var(--grid-line) 1px, transparent 1px), + linear-gradient(90deg, var(--grid-line) 1px, transparent 1px); + background-size: 46px 46px; + mask-image: radial-gradient(120% 100% at 50% 0%, #000 30%, transparent 78%); +} +.hero-net { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; +} + +/* subtler ambient glow for content sections (behind cards) */ +.section-glow { + position: absolute; + inset: 0; + z-index: 0; + pointer-events: none; + overflow: hidden; +} +.section-glow b { + position: absolute; + display: block; + border-radius: 50%; + filter: blur(90px); + mix-blend-mode: multiply; + background: radial-gradient(circle at 50% 50%, var(--accent), transparent 64%); + will-change: transform; +} +.dark .section-glow b { + mix-blend-mode: screen; +} +.section-glow .g1 { + width: 40vw; + height: 40vw; + left: -8vw; + top: 6%; + opacity: 0.07; +} +.section-glow .g2 { + width: 36vw; + height: 36vw; + right: -10vw; + bottom: -12%; + opacity: 0.06; +} +.dark .section-glow .g1 { + opacity: 0.16; +} +.dark .section-glow .g2 { + opacity: 0.13; +} +@media (prefers-reduced-motion: no-preference) { + .section-glow .g1 { + animation: bloom-2 24s ease-in-out infinite alternate; + } + .section-glow .g2 { + animation: bloom-3 19s ease-in-out infinite alternate; + } +} + +@media (prefers-reduced-motion: no-preference) { + .aurora .a1 { + animation: bloom-1 17s ease-in-out infinite alternate; + } + .aurora .a2 { + animation: bloom-2 21s ease-in-out infinite alternate; + } + .aurora .a3 { + animation: bloom-3 15s ease-in-out infinite alternate; + } + .blueprint { + animation: grid-drift 36s linear infinite; + } +} +@keyframes bloom-1 { + from { + transform: translate(-40px, -30px) scale(1); + } + to { + transform: translate(90px, 60px) scale(1.18); + } +} +@keyframes bloom-2 { + from { + transform: translate(50px, 30px) scale(1.1); + } + to { + transform: translate(-60px, -40px) scale(1); + } +} +@keyframes bloom-3 { + from { + transform: translate(-30px, 40px) scale(1.05); + } + to { + transform: translate(50px, -50px) scale(1.2); + } +} +@keyframes grid-drift { + to { + background-position: + 46px 46px, + 46px 46px; + } +} + @keyframes fade-up { from { opacity: 0; diff --git a/src/app/layout.tsx b/src/app/layout.tsx index ba35cfd..b532790 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,10 +1,24 @@ -import { GeistMono } from "geist/font/mono"; -import { GeistSans } from "geist/font/sans"; -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; +import { IBM_Plex_Mono, IBM_Plex_Sans } from "next/font/google"; import { site } from "@/lib/site"; import { ThemeProvider } from "@/providers/theme-provider"; import "./globals.css"; +const plexSans = IBM_Plex_Sans({ + subsets: ["latin"], + weight: ["400", "500", "600", "700"], + style: ["normal", "italic"], + variable: "--font-plex-sans", + display: "swap", +}); + +const plexMono = IBM_Plex_Mono({ + subsets: ["latin"], + weight: ["400", "500"], + variable: "--font-plex-mono", + display: "swap", +}); + export const metadata: Metadata = { metadataBase: new URL(site.url), title: { @@ -41,12 +55,19 @@ export const metadata: Metadata = { alternates: { canonical: site.url }, }; +export const viewport: Viewport = { + themeColor: [ + { media: "(prefers-color-scheme: light)", color: "#f6f4ee" }, + { media: "(prefers-color-scheme: dark)", color: "#08080c" }, + ], +}; + export default function RootLayout({ children }: Readonly<{ children: React.ReactNode }>) { return ( diff --git a/src/components/docs-hero.tsx b/src/components/docs-hero.tsx index a931659..b1fe7ad 100644 --- a/src/components/docs-hero.tsx +++ b/src/components/docs-hero.tsx @@ -1,11 +1,17 @@ +import { HeroNet } from "./hero-net"; + export function DocsHero() { return ( -
-
-
+
+
+ + + +
+
+ + +

Documentation diff --git a/src/components/hero-net.tsx b/src/components/hero-net.tsx new file mode 100644 index 0000000..250225e --- /dev/null +++ b/src/components/hero-net.tsx @@ -0,0 +1,220 @@ +"use client"; + +import { useEffect, useRef } from "react"; + +const LINK = 132; // px distance at which nodes connect +const MOUSE_LINK = 168; // px distance for cursor links + +type Node = { x: number; y: number; vx: number; vy: number; r: number }; + +function hexToRgb(hex: string): [number, number, number] | null { + let h = hex.trim().replace("#", ""); + if (h.length === 3) h = h.replace(/(.)/g, "$1$1"); + const n = Number.parseInt(h, 16); + if (Number.isNaN(n) || h.length < 6) return null; + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; +} + +/** + * Hero connective network: drifting nodes + proximity links + gentle cursor + * pull. Accent- and theme-aware (reads `--accent`, re-reads on `.dark` toggle), + * pauses off-screen / on hidden tab, honours `prefers-reduced-motion`. + */ +export function HeroNet() { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + const hero = canvas?.closest("[data-hero-fx]") ?? canvas?.parentElement ?? null; + const ctx = canvas?.getContext("2d"); + if (!canvas || !hero || !ctx) return; + + const root = document.documentElement; + const reduceMQ = window.matchMedia("(prefers-reduced-motion: reduce)"); + const dpr = Math.max(1, Math.min(window.devicePixelRatio || 1, 2)); + + let W = 0; + let H = 0; + let nodes: Node[] = []; + let rgb: [number, number, number] = [31, 157, 84]; + let raf: number | null = null; + let onScreen = true; + const mouse = { x: -9999, y: -9999, on: false }; + + const readAccent = () => { + const parsed = hexToRgb(getComputedStyle(root).getPropertyValue("--accent")); + if (parsed) rgb = parsed; + }; + const rgba = (a: number) => `rgba(${rgb[0]},${rgb[1]},${rgb[2]},${a})`; + + const build = () => { + const r = hero.getBoundingClientRect(); + W = Math.max(1, r.width); + H = Math.max(1, r.height); + canvas.width = Math.round(W * dpr); + canvas.height = Math.round(H * dpr); + canvas.style.width = `${W}px`; + canvas.style.height = `${H}px`; + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + const count = Math.round(Math.min(60, Math.max(18, (W * H) / 19000))); + nodes = []; + for (let i = 0; i < count; i++) { + nodes.push({ + x: Math.random() * W, + y: Math.random() * H, + vx: (Math.random() - 0.5) * 0.22, + vy: (Math.random() - 0.5) * 0.22, + r: 1.1 + Math.random() * 1.6, + }); + } + }; + + const draw = () => { + ctx.clearRect(0, 0, W, H); + + for (let i = 0; i < nodes.length; i++) { + const a = nodes[i]; + for (let j = i + 1; j < nodes.length; j++) { + const b = nodes[j]; + const dx = a.x - b.x; + const dy = a.y - b.y; + const d2 = dx * dx + dy * dy; + if (d2 < LINK * LINK) { + const d = Math.sqrt(d2); + ctx.strokeStyle = rgba((1 - d / LINK) * 0.22); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(b.x, b.y); + ctx.stroke(); + } + } + } + + if (mouse.on) { + for (const a of nodes) { + const dx = a.x - mouse.x; + const dy = a.y - mouse.y; + const d2 = dx * dx + dy * dy; + if (d2 < MOUSE_LINK * MOUSE_LINK) { + const d = Math.sqrt(d2); + ctx.strokeStyle = rgba((1 - d / MOUSE_LINK) * 0.5); + ctx.lineWidth = 1; + ctx.beginPath(); + ctx.moveTo(a.x, a.y); + ctx.lineTo(mouse.x, mouse.y); + ctx.stroke(); + a.vx += (dx > 0 ? -1 : 1) * 0.0009 * (1 - d / MOUSE_LINK); + a.vy += (dy > 0 ? -1 : 1) * 0.0009 * (1 - d / MOUSE_LINK); + } + } + } + + ctx.fillStyle = rgba(0.55); + for (const a of nodes) { + ctx.beginPath(); + ctx.arc(a.x, a.y, a.r, 0, Math.PI * 2); + ctx.fill(); + } + }; + + const step = () => { + for (const a of nodes) { + a.x += a.vx; + a.y += a.vy; + a.vx *= 0.992; + a.vy *= 0.992; + if (a.vx > -0.04 && a.vx < 0.04) a.vx += (Math.random() - 0.5) * 0.01; + if (a.vy > -0.04 && a.vy < 0.04) a.vy += (Math.random() - 0.5) * 0.01; + if (a.x < -10) a.x = W + 10; + else if (a.x > W + 10) a.x = -10; + if (a.y < -10) a.y = H + 10; + else if (a.y > H + 10) a.y = -10; + } + draw(); + raf = requestAnimationFrame(step); + }; + + const start = () => { + if (raf || reduceMQ.matches || !onScreen) return; + raf = requestAnimationFrame(step); + }; + const stop = () => { + if (raf) { + cancelAnimationFrame(raf); + raf = null; + } + }; + + let resizeT: ReturnType | null = null; + const onResize = () => { + if (resizeT) clearTimeout(resizeT); + resizeT = setTimeout(() => { + build(); + draw(); + }, 150); + }; + const onMove = (e: PointerEvent) => { + const r = hero.getBoundingClientRect(); + mouse.x = e.clientX - r.left; + mouse.y = e.clientY - r.top; + mouse.on = true; + }; + const onLeave = () => { + mouse.on = false; + mouse.x = -9999; + mouse.y = -9999; + }; + const onVisibility = () => (document.hidden ? stop() : start()); + const onReduceChange = () => { + if (reduceMQ.matches) { + stop(); + draw(); + } else { + start(); + } + }; + + window.addEventListener("resize", onResize, { passive: true }); + hero.addEventListener("pointermove", onMove, { passive: true }); + hero.addEventListener("pointerleave", onLeave); + document.addEventListener("visibilitychange", onVisibility); + reduceMQ.addEventListener("change", onReduceChange); + + const screenIO = new IntersectionObserver( + (entries) => { + onScreen = entries[0].isIntersecting; + if (onScreen) start(); + else stop(); + }, + { threshold: 0 }, + ); + screenIO.observe(hero); + + const accentMO = new MutationObserver(() => { + readAccent(); + if (reduceMQ.matches) draw(); + }); + accentMO.observe(root, { attributes: true, attributeFilter: ["class", "style"] }); + + readAccent(); + build(); + if (reduceMQ.matches) draw(); + else start(); + + return () => { + stop(); + if (resizeT) clearTimeout(resizeT); + window.removeEventListener("resize", onResize); + hero.removeEventListener("pointermove", onMove); + hero.removeEventListener("pointerleave", onLeave); + document.removeEventListener("visibilitychange", onVisibility); + reduceMQ.removeEventListener("change", onReduceChange); + screenIO.disconnect(); + accentMO.disconnect(); + }; + }, []); + + return ; +} diff --git a/src/components/tools-section.tsx b/src/components/tools-section.tsx index ee7abf4..d4b9de4 100644 --- a/src/components/tools-section.tsx +++ b/src/components/tools-section.tsx @@ -4,7 +4,7 @@ import { Section, SectionHeader } from "./ui/section"; export function ToolsSection() { return ( -

+
& { containerClassName?: string; children: ReactNode; bordered?: boolean; + /** Render a subtle ambient accent glow behind the content. */ + glow?: boolean; }; export function Section({ @@ -14,15 +16,33 @@ export function Section({ containerClassName, children, bordered = true, + glow = false, ...props }: SectionProps) { return (
-
+ {glow && ( +
+ + +
+ )} +
{children}