Initial commit: MycoStack.xyz website

Epic single-page scrolling site merging Commons Stack ethos with mycelial
design principles. Features scroll-driven dark-to-light color transitions,
procedural mycelial canvas animation, anastomosis SVG animation, and an
interactive network map of connected domains.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-02-16 17:40:34 -07:00
commit 4ec0f386f1
25 changed files with 2846 additions and 0 deletions

37
.gitignore vendored Normal file
View File

@ -0,0 +1,37 @@
# dependencies
/node_modules
/.pnp
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/versions
# testing
/coverage
# next.js
/.next/
/out/
# production
/build
# misc
.DS_Store
*.pem
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.pnpm-debug.log*
# env files
.env*.local
.env
# typescript
*.tsbuildinfo
next-env.d.ts

14
Dockerfile Normal file
View File

@ -0,0 +1,14 @@
FROM node:20-alpine AS builder
WORKDIR /app
RUN corepack enable && corepack prepare pnpm@latest --activate
COPY package.json pnpm-lock.yaml* ./
RUN pnpm install --frozen-lockfile
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN pnpm build
FROM nginx:alpine
COPY nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=builder /app/out /usr/share/nginx/html
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

249
app/globals.css Normal file
View File

@ -0,0 +1,249 @@
@import "tailwindcss";
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
:root {
/* Scroll-driven dynamic colors (set by JS) */
--scroll-bg: oklch(0.08 0.02 30);
--scroll-fg: oklch(0.85 0.03 80);
--scroll-accent: oklch(0.45 0.12 60);
--scroll-depth: 0;
/* Static palette tokens */
--soil-black: oklch(0.08 0.02 30);
--earth-brown: oklch(0.12 0.04 40);
--compost-amber: oklch(0.55 0.15 45);
--forest-deep: oklch(0.16 0.05 150);
--mycelium-green: oklch(0.55 0.18 155);
--mycelium-glow: oklch(0.72 0.20 135);
--undernet-dark: oklch(0.22 0.04 200);
--undernet-teal: oklch(0.50 0.14 220);
--emergence-gold: oklch(0.60 0.16 90);
--canopy-light: oklch(0.94 0.02 110);
--mycopunk-orange: oklch(0.60 0.16 55);
--mycopunk-warm: oklch(0.50 0.13 40);
/* shadcn-compatible tokens */
--background: var(--scroll-bg);
--foreground: var(--scroll-fg);
--primary: var(--scroll-accent);
--primary-foreground: oklch(0.98 0.01 80);
--muted: oklch(0.20 0.02 30);
--muted-foreground: oklch(0.60 0.02 80);
--border: oklch(0.25 0.03 30);
--input: oklch(0.25 0.03 30);
--ring: var(--scroll-accent);
--radius: 0.5rem;
}
@theme inline {
--font-sans: var(--font-geist-sans), system-ui, sans-serif;
--font-serif: var(--font-crimson-pro), Georgia, serif;
--font-mono: var(--font-geist-mono), monospace;
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-soil-black: var(--soil-black);
--color-earth-brown: var(--earth-brown);
--color-compost-amber: var(--compost-amber);
--color-forest-deep: var(--forest-deep);
--color-mycelium-green: var(--mycelium-green);
--color-mycelium-glow: var(--mycelium-glow);
--color-undernet-dark: var(--undernet-dark);
--color-undernet-teal: var(--undernet-teal);
--color-emergence-gold: var(--emergence-gold);
--color-canopy-light: var(--canopy-light);
--color-mycopunk-orange: var(--mycopunk-orange);
--color-mycopunk-warm: var(--mycopunk-warm);
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
background-color: var(--scroll-bg, oklch(0.08 0.02 30));
color: var(--scroll-fg, oklch(0.85 0.03 80));
transition: background-color 0.05s linear, color 0.05s linear;
overflow-x: hidden;
}
html {
scroll-behavior: smooth;
}
}
/* Section scroll-reveal */
.section-reveal {
opacity: 0;
transform: translateY(40px);
transition: opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1),
transform 0.9s cubic-bezier(0.16, 1, 0.3, 1);
}
.section-reveal.visible {
opacity: 1;
transform: translateY(0);
}
/* Noise texture overlay */
.noise-overlay {
position: relative;
}
.noise-overlay::after {
content: "";
position: absolute;
inset: 0;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
opacity: 0.03;
pointer-events: none;
mix-blend-mode: overlay;
z-index: 1;
}
/* Hero title emergence animation */
@keyframes emerge-letter {
from {
opacity: 0;
transform: translateY(24px);
filter: blur(6px);
}
to {
opacity: 1;
transform: translateY(0);
filter: blur(0);
}
}
.emerge-letter {
animation: emerge-letter 1s cubic-bezier(0.16, 1, 0.3, 1) forwards;
opacity: 0;
}
/* Scroll indicator pulse */
@keyframes scroll-hint {
0%,
100% {
opacity: 0.3;
transform: translateY(0);
}
50% {
opacity: 0.7;
transform: translateY(10px);
}
}
.scroll-hint {
animation: scroll-hint 2.5s ease-in-out infinite;
}
/* Mycelial divider between sections */
.mycelial-divider {
width: 2px;
height: 80px;
margin: 0 auto;
background: linear-gradient(
to bottom,
transparent,
var(--scroll-accent),
transparent
);
opacity: 0.35;
}
/* Anastomosis SVG line drawing */
.draw-line {
stroke-dasharray: 600;
stroke-dashoffset: 600;
}
.draw-line.animate {
animation: draw-path 2.5s cubic-bezier(0.16, 1, 0.3, 1) forwards;
}
@keyframes draw-path {
to {
stroke-dashoffset: 0;
}
}
/* Merge pulse */
@keyframes merge-pulse {
0%,
40% {
opacity: 0;
r: 0;
}
60% {
opacity: 1;
r: 10;
}
100% {
opacity: 0.7;
r: 7;
}
}
.merge-pulse {
animation: merge-pulse 3s cubic-bezier(0.16, 1, 0.3, 1) forwards;
animation-delay: 2s;
}
/* Network node hover glow */
.network-node {
transition: all 0.3s ease;
}
.network-node:hover {
filter: drop-shadow(0 0 12px var(--scroll-accent));
transform: scale(1.05);
}
/* Terminal cursor blink */
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.cursor-blink {
animation: blink 1s step-end infinite;
}
/* Card glass effect */
.glass-card {
background: oklch(0.5 0 0 / 0.08);
backdrop-filter: blur(8px);
border: 1px solid oklch(0.5 0 0 / 0.12);
border-radius: var(--radius-lg);
}
/* Link style for domain easter eggs */
a.domain-link {
text-decoration: underline;
text-decoration-style: dotted;
text-underline-offset: 3px;
transition: all 0.2s ease;
}
a.domain-link:hover {
text-decoration-style: solid;
color: var(--scroll-accent);
}

54
app/layout.tsx Normal file
View File

@ -0,0 +1,54 @@
import type { Metadata } from "next"
import { GeistSans } from "geist/font/sans"
import { GeistMono } from "geist/font/mono"
import { Crimson_Pro } from "next/font/google"
import { ScrollProvider } from "@/components/scroll-provider"
import "./globals.css"
const crimsonPro = Crimson_Pro({
weight: ["400", "500", "600", "700"],
subsets: ["latin"],
variable: "--font-crimson-pro",
display: "swap",
})
export const metadata: Metadata = {
title: "MycoStack — Technology-Augmented Commons",
description:
"A reboot of the Commons Stack, merging technology with mycelial principles for regenerative systems. Growing from beneath the surface.",
metadataBase: new URL("https://mycostack.xyz"),
icons: {
icon: "data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><text y=%22.9em%22 font-size=%2290%22>🍄</text></svg>",
},
openGraph: {
title: "MycoStack — Technology-Augmented Commons",
description:
"Growing regenerative systems from beneath the surface. Commons governance meets mycelial design.",
url: "https://mycostack.xyz",
siteName: "MycoStack",
locale: "en_US",
type: "website",
},
twitter: {
card: "summary_large_image",
title: "MycoStack — Technology-Augmented Commons",
description:
"Growing regenerative systems from beneath the surface.",
},
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en" className="dark">
<body
className={`${GeistSans.variable} ${GeistMono.variable} ${crimsonPro.variable} font-sans antialiased`}
>
<ScrollProvider>{children}</ScrollProvider>
</body>
</html>
)
}

27
app/page.tsx Normal file
View File

@ -0,0 +1,27 @@
import { MycelialCanvas } from "@/components/mycelial-canvas"
import { HeroSection } from "@/components/hero-section"
import { CompostSection } from "@/components/compost-section"
import { MyceliumSection } from "@/components/mycelium-section"
import { UndernetSection } from "@/components/undernet-section"
import { AnastomosisSection } from "@/components/anastomosis-section"
import { EmergenceSection } from "@/components/emergence-section"
import { NetworkMapSection } from "@/components/network-map-section"
import { Footer } from "@/components/footer"
export default function Home() {
return (
<div className="min-h-screen">
<MycelialCanvas />
<main className="relative z-10">
<HeroSection />
<CompostSection />
<MyceliumSection />
<UndernetSection />
<AnastomosisSection />
<EmergenceSection />
<NetworkMapSection />
</main>
<Footer />
</div>
)
}

View File

@ -0,0 +1,239 @@
"use client"
import { useEffect, useRef } from "react"
import { useSectionReveal } from "@/hooks/use-section-reveal"
export function AnastomosisSection() {
const sectionRef = useSectionReveal()
const svgRef = useRef<SVGSVGElement>(null)
useEffect(() => {
const svg = svgRef.current
if (!svg) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
svg.querySelectorAll(".draw-line").forEach((el) => {
el.classList.add("animate")
})
svg.querySelector(".merge-dot")?.classList.add("merge-pulse")
}
})
},
{ threshold: 0.3 }
)
observer.observe(svg)
return () => observer.disconnect()
}, [])
return (
<section ref={sectionRef} className="relative py-32 px-6 noise-overlay">
<div className="mycelial-divider mb-20" />
<div className="max-w-4xl mx-auto space-y-16">
<div className="section-reveal space-y-6 text-center">
<h2 className="font-serif text-4xl sm:text-5xl md:text-6xl font-bold">
Anastomosis
</h2>
<p className="font-mono text-sm opacity-40">
/uh-nas-tuh-MOH-sis/
</p>
<p className="text-lg sm:text-xl opacity-70 max-w-2xl mx-auto leading-relaxed">
When separate mycelial networks discover each other and merge,
forming new connections. The moment distinct systems recognize their
shared purpose and become one.
</p>
</div>
{/* SVG Animation: Two networks merging */}
<div className="section-reveal">
<svg
ref={svgRef}
viewBox="0 0 800 300"
className="w-full max-w-3xl mx-auto"
fill="none"
>
{/* Left network */}
<g>
{/* Main trunk */}
<path
d="M60,150 Q120,130 180,145 Q240,155 290,140"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="2"
strokeLinecap="round"
style={{ animationDelay: "0s" }}
/>
{/* Branch up */}
<path
d="M140,138 Q160,100 200,85 Q230,75 260,90"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1.5"
strokeLinecap="round"
style={{ animationDelay: "0.3s" }}
/>
{/* Branch down */}
<path
d="M180,145 Q200,180 240,195 Q270,205 300,190"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1.5"
strokeLinecap="round"
style={{ animationDelay: "0.5s" }}
/>
{/* Sub-branch */}
<path
d="M200,85 Q220,60 250,55"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1"
strokeLinecap="round"
style={{ animationDelay: "0.7s" }}
/>
{/* Small nodes */}
<circle cx="60" cy="150" r="3" fill="var(--scroll-accent)" opacity="0.5" />
<circle cx="180" cy="145" r="2.5" fill="var(--scroll-accent)" opacity="0.4" />
<circle cx="200" cy="85" r="2" fill="var(--scroll-accent)" opacity="0.4" />
<circle cx="240" cy="195" r="2" fill="var(--scroll-accent)" opacity="0.4" />
</g>
{/* Right network */}
<g>
{/* Main trunk */}
<path
d="M740,150 Q680,135 620,148 Q560,155 510,142"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="2"
strokeLinecap="round"
style={{ animationDelay: "0.2s" }}
/>
{/* Branch up */}
<path
d="M660,140 Q640,105 600,90 Q570,78 540,92"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1.5"
strokeLinecap="round"
style={{ animationDelay: "0.4s" }}
/>
{/* Branch down */}
<path
d="M620,148 Q600,182 560,198 Q530,208 500,192"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1.5"
strokeLinecap="round"
style={{ animationDelay: "0.6s" }}
/>
{/* Sub-branch */}
<path
d="M600,90 Q580,62 550,58"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1"
strokeLinecap="round"
style={{ animationDelay: "0.8s" }}
/>
{/* Small nodes */}
<circle cx="740" cy="150" r="3" fill="var(--scroll-accent)" opacity="0.5" />
<circle cx="620" cy="148" r="2.5" fill="var(--scroll-accent)" opacity="0.4" />
<circle cx="600" cy="90" r="2" fill="var(--scroll-accent)" opacity="0.4" />
<circle cx="560" cy="198" r="2" fill="var(--scroll-accent)" opacity="0.4" />
</g>
{/* Merge connections (appear last) */}
<g>
<path
d="M290,140 Q340,145 400,150"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1.5"
strokeLinecap="round"
style={{ animationDelay: "1.8s" }}
/>
<path
d="M510,142 Q460,146 400,150"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1.5"
strokeLinecap="round"
style={{ animationDelay: "1.8s" }}
/>
<path
d="M260,90 Q330,95 400,120"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
style={{ animationDelay: "2.2s" }}
/>
<path
d="M540,92 Q470,95 400,120"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
style={{ animationDelay: "2.2s" }}
/>
<path
d="M300,190 Q350,185 400,175"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
style={{ animationDelay: "2.4s" }}
/>
<path
d="M500,192 Q450,185 400,175"
className="draw-line"
stroke="var(--scroll-accent)"
strokeWidth="1"
strokeLinecap="round"
opacity="0.5"
style={{ animationDelay: "2.4s" }}
/>
</g>
{/* Center merge point */}
<circle
cx="400"
cy="150"
r="0"
className="merge-dot"
fill="var(--scroll-accent)"
/>
</svg>
</div>
<div className="section-reveal space-y-6 text-center max-w-2xl mx-auto">
<p className="text-lg leading-relaxed opacity-75">
We are the connections between movements. Commons Stack, MycoFi, the
Undernet separate networks finding each other, merging, growing
stronger together. The boundaries between projects dissolve. What
remains is the shared mycelium.
</p>
<p className="text-base opacity-50">
A space that belongs to its communities, not its platforms. Find{" "}
<a
href="https://yourspace.online"
target="_blank"
rel="noopener noreferrer"
className="domain-link"
>
(you)rSpace.online
</a>{" "}
and start anastomosing.
</p>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,72 @@
"use client"
import { useSectionReveal } from "@/hooks/use-section-reveal"
const CARDS = [
{
title: "Break Down",
body: "Old systems don't disappear — they decompose. Capitalism's waste becomes the substrate for what grows next. Every collapsing institution releases nutrients back into the commons.",
},
{
title: "Transform",
body: "Mycelium turns death into life. We turn extractive protocols into regenerative ones. The same energy that powered exploitation can power mutual aid — if we know how to compost it.",
},
{
title: "Nourish",
body: "What's composted feeds what's growing. Every broken system contains the nutrients for its successor. The question isn't whether the old world will decompose — it's what we grow in its place.",
},
]
export function CompostSection() {
const sectionRef = useSectionReveal()
return (
<section ref={sectionRef} className="relative py-32 px-6 noise-overlay">
<div className="mycelial-divider mb-20" />
<div className="max-w-5xl mx-auto space-y-16">
<div className="section-reveal space-y-4 text-center">
<h2 className="font-serif text-4xl sm:text-5xl md:text-6xl font-bold">
The Compost Layer
</h2>
<p className="text-lg sm:text-xl opacity-70 max-w-2xl mx-auto">
Decomposing extractive systems into nutrients for regeneration
</p>
</div>
<div className="grid gap-8 md:grid-cols-3">
{CARDS.map((card, i) => (
<div
key={card.title}
className="section-reveal glass-card p-6 space-y-3"
style={{ transitionDelay: `${i * 0.15}s` }}
>
<div
className="w-full h-0.5 mb-4"
style={{ background: "var(--compost-amber)" }}
/>
<h3 className="font-serif text-xl font-semibold">
{card.title}
</h3>
<p className="text-sm leading-relaxed opacity-75">{card.body}</p>
</div>
))}
</div>
<p className="section-reveal text-center text-base opacity-60 max-w-xl mx-auto">
This is{" "}
<a
href="https://compostcapitalism.xyz"
target="_blank"
rel="noopener noreferrer"
className="domain-link"
>
compost capitalism
</a>{" "}
the art of breaking down what no longer serves, so that what comes
next can thrive.
</p>
</div>
</section>
)
}

View File

@ -0,0 +1,96 @@
"use client"
import { useSectionReveal } from "@/hooks/use-section-reveal"
const CARDS = [
{
title: "Regenerative Economics",
body: "New currencies that decompose when hoarded. Mutual credit that flows like nutrients through soil. Quadratic funding that amplifies the grassroots. Economics that serves life instead of extracting from it.",
},
{
title: "Sovereign Technology",
body: "Community-owned servers. Open protocols. Software that serves its users. Hardware you can repair. Networks no corporation can capture. The tools of liberation, maintained by the communities that depend on them.",
},
{
title: "Living Commons",
body: "Knowledge, tools, and infrastructure that belong to everyone. Not static archives but living, growing resources — tended by communities, enriched by participation, and freely shared across the mycelial web.",
},
]
export function EmergenceSection() {
const sectionRef = useSectionReveal()
return (
<section ref={sectionRef} className="relative py-32 px-6 noise-overlay">
<div className="mycelial-divider mb-20" />
<div className="max-w-5xl mx-auto space-y-16">
<div className="section-reveal space-y-6 text-center">
<h2 className="font-serif text-4xl sm:text-5xl md:text-6xl font-bold">
Emergence
</h2>
<p className="text-lg sm:text-xl opacity-70 max-w-2xl mx-auto">
What grows underground eventually breaks the surface.
</p>
</div>
{/* Quote block */}
<blockquote className="section-reveal max-w-2xl mx-auto text-center">
<p className="font-serif text-xl sm:text-2xl italic leading-relaxed opacity-80">
&ldquo;The post-capitalist future is not a utopian fantasy. It is
already growing, underground, in the networks we are building
today.&rdquo;
</p>
</blockquote>
<div className="grid gap-8 md:grid-cols-3">
{CARDS.map((card, i) => (
<div
key={card.title}
className="section-reveal glass-card p-6 space-y-3"
style={{ transitionDelay: `${i * 0.15}s` }}
>
<div
className="w-full h-0.5 mb-4"
style={{ background: "var(--emergence-gold)" }}
/>
<h3 className="font-serif text-xl font-semibold">
{card.title}
</h3>
<p className="text-sm leading-relaxed opacity-75">{card.body}</p>
</div>
))}
</div>
<div className="section-reveal text-center space-y-4 max-w-xl mx-auto">
<p className="text-base opacity-60">
Building the{" "}
<a
href="https://post-appitalist.app"
target="_blank"
rel="noopener noreferrer"
className="domain-link"
>
post-appitalist.app
</a>
lication layer for a regenerative economy. Tools that serve
communities, not shareholders.
</p>
<p className="text-sm opacity-40">
Sometimes the best way to see the future is to change your
perspective. Stop{" "}
<a
href="https://trippinballs.lol"
target="_blank"
rel="noopener noreferrer"
className="domain-link"
>
trippinballs.lol
</a>{" "}
and start building.
</p>
</div>
</div>
</section>
)
}

57
components/footer.tsx Normal file
View File

@ -0,0 +1,57 @@
"use client"
const LINKS = [
{ name: "MycoFi", url: "https://mycofi.earth" },
{ name: "Mycopunk", url: "https://mycopunk.xyz" },
{ name: "Undernet", url: "https://undernet.earth" },
{ name: "Psilo-Cyber", url: "https://psilo-cyber.net" },
{ name: "Compost Capitalism", url: "https://compostcapitalism.xyz" },
{ name: "Post-Appitalism", url: "https://post-appitalist.app" },
{ name: "(You)rSpace", url: "https://yourspace.online" },
{ name: "Trippin Balls", url: "https://trippinballs.lol" },
]
export function Footer() {
return (
<footer className="relative z-10 py-20 px-6 border-t border-current/10">
<div className="max-w-4xl mx-auto space-y-10">
{/* Network links */}
<div className="flex flex-wrap justify-center gap-x-6 gap-y-2">
{LINKS.map((link) => (
<a
key={link.url}
href={link.url}
target="_blank"
rel="noopener noreferrer"
className="font-mono text-xs opacity-40 hover:opacity-70 transition-opacity"
>
{link.name}
</a>
))}
</div>
{/* Tagline */}
<div className="text-center space-y-3">
<p className="font-serif text-lg opacity-50">
Built with compost and code.
</p>
<p className="font-mono text-xs opacity-30">
MycoStack &mdash; technology-augmented commons growing from beneath
</p>
<p className="font-mono text-xs opacity-20">
<a
href="https://creativecommons.org/licenses/by-sa/4.0/"
target="_blank"
rel="noopener noreferrer"
className="hover:opacity-50 transition-opacity"
>
CC BY-SA 4.0
</a>
{" "}&mdash;{" "}
the more we share, the more we have
</p>
</div>
</div>
</footer>
)
}

View File

@ -0,0 +1,57 @@
"use client"
import { useSectionReveal } from "@/hooks/use-section-reveal"
import { ChevronDown } from "lucide-react"
const TITLE = "MycoStack"
export function HeroSection() {
const sectionRef = useSectionReveal()
return (
<section
ref={sectionRef}
className="relative min-h-[110vh] flex flex-col items-center justify-center px-6 noise-overlay"
>
<div className="section-reveal max-w-4xl mx-auto text-center space-y-8">
{/* Title with staggered emergence */}
<h1 className="font-serif text-6xl sm:text-7xl md:text-8xl lg:text-9xl font-bold tracking-tight">
{TITLE.split("").map((letter, i) => (
<span
key={i}
className="emerge-letter inline-block"
style={{ animationDelay: `${0.3 + i * 0.08}s` }}
>
{letter}
</span>
))}
</h1>
{/* Subtitle */}
<p
className="emerge-letter text-xl sm:text-2xl md:text-3xl font-serif font-light tracking-wide opacity-80"
style={{ animationDelay: "1.2s" }}
>
Technology-augmented commons.
<br />
Growing from beneath.
</p>
{/* Terminal tagline */}
<div
className="emerge-letter font-mono text-sm sm:text-base opacity-60"
style={{ animationDelay: "1.8s" }}
>
<span className="opacity-50">&gt;</span> composting capitalism,
growing alternatives
<span className="cursor-blink ml-0.5">_</span>
</div>
</div>
{/* Scroll indicator */}
<div className="absolute bottom-12 left-1/2 -translate-x-1/2 scroll-hint">
<ChevronDown className="w-6 h-6 opacity-40" />
</div>
</section>
)
}

View File

@ -0,0 +1,208 @@
"use client"
import { useEffect, useRef } from "react"
interface Hypha {
x: number
y: number
angle: number
speed: number
age: number
maxAge: number
parentX: number
parentY: number
depth: number
branchCount: number
}
export function MycelialCanvas() {
const canvasRef = useRef<HTMLCanvasElement>(null)
const hyphaeRef = useRef<Hypha[]>([])
const frameRef = useRef<number>(0)
const trailCanvasRef = useRef<HTMLCanvasElement | null>(null)
useEffect(() => {
const canvas = canvasRef.current
if (!canvas) return
const ctx = canvas.getContext("2d")
if (!ctx) return
// Create offscreen trail canvas for persistence
const trailCanvas = document.createElement("canvas")
const trailCtx = trailCanvas.getContext("2d")!
trailCanvasRef.current = trailCanvas
const resize = () => {
const dpr = Math.min(window.devicePixelRatio || 1, 2)
const w = window.innerWidth
const h = window.innerHeight
canvas.width = w * dpr
canvas.height = h * dpr
canvas.style.width = `${w}px`
canvas.style.height = `${h}px`
ctx.scale(dpr, dpr)
trailCanvas.width = w * dpr
trailCanvas.height = h * dpr
trailCtx.scale(dpr, dpr)
}
resize()
window.addEventListener("resize", resize)
const w = () => canvas.width / (Math.min(window.devicePixelRatio || 1, 2))
const h = () => canvas.height / (Math.min(window.devicePixelRatio || 1, 2))
const createHypha = (
x: number,
y: number,
angle: number,
depth: number
): Hypha => ({
x,
y,
angle,
speed: 1.2 + Math.random() * 0.8 - depth * 0.15,
age: 0,
maxAge: 200 + Math.random() * 150 - depth * 20,
parentX: x,
parentY: y,
depth,
branchCount: 0,
})
// Seed initial hyphae from bottom
const seedCount = Math.max(3, Math.floor(w() / 250))
for (let i = 0; i < seedCount; i++) {
const x =
(w() / (seedCount + 1)) * (i + 1) + (Math.random() - 0.5) * 80
hyphaeRef.current.push(
createHypha(
x,
h() + 10,
-Math.PI / 2 + (Math.random() - 0.5) * 0.5,
0
)
)
}
const getAccentColor = () => {
return (
getComputedStyle(document.documentElement)
.getPropertyValue("--scroll-accent")
.trim() || "oklch(0.55 0.18 155)"
)
}
let lastSeed = 0
const animate = () => {
const width = w()
const height = h()
// Clear main canvas
ctx.clearRect(0, 0, width, height)
// Fade the trail canvas slowly
trailCtx.fillStyle = "rgba(10, 8, 5, 0.008)"
trailCtx.fillRect(0, 0, width, height)
const accent = getAccentColor()
const alive: Hypha[] = []
for (const hypha of hyphaeRef.current) {
hypha.age++
if (hypha.age >= hypha.maxAge) continue
// Save previous position
const prevX = hypha.x
const prevY = hypha.y
// Random walk with upward bias
hypha.angle += (Math.random() - 0.5) * 0.12
// Gentle gravitropism (slightly toward vertical)
hypha.angle += (-Math.PI / 2 - hypha.angle) * 0.003
hypha.x += Math.cos(hypha.angle) * hypha.speed
hypha.y += Math.sin(hypha.angle) * hypha.speed
// Boundary wrapping
if (hypha.x < -20) hypha.x = width + 20
if (hypha.x > width + 20) hypha.x = -20
// Opacity based on age and depth
const ageRatio = hypha.age / hypha.maxAge
const opacity = Math.max(
0.05,
(1 - ageRatio * 0.9) * (0.6 - hypha.depth * 0.08)
)
const lineWidth = Math.max(0.3, 2.5 - hypha.depth * 0.4 - ageRatio)
// Draw on trail canvas for persistence
trailCtx.strokeStyle = accent.replace(")", ` / ${opacity * 0.5})`)
trailCtx.lineWidth = lineWidth
trailCtx.lineCap = "round"
trailCtx.beginPath()
trailCtx.moveTo(prevX, prevY)
trailCtx.lineTo(hypha.x, hypha.y)
trailCtx.stroke()
// Draw tip glow on main canvas
if (ageRatio < 0.7) {
ctx.fillStyle = accent.replace(")", ` / ${opacity * 0.4})`)
ctx.beginPath()
ctx.arc(hypha.x, hypha.y, lineWidth + 1, 0, Math.PI * 2)
ctx.fill()
}
// Branching
if (
hypha.age > 25 &&
hypha.age < hypha.maxAge * 0.7 &&
hypha.branchCount < 3 &&
hypha.depth < 5 &&
Math.random() > 0.975
) {
const branchAngle =
hypha.angle + (Math.random() > 0.5 ? 1 : -1) * (0.4 + Math.random() * 0.5)
alive.push(createHypha(hypha.x, hypha.y, branchAngle, hypha.depth + 1))
hypha.branchCount++
}
alive.push(hypha)
}
// Draw trail canvas behind
ctx.drawImage(trailCanvas, 0, 0, width, height)
// Cap and manage
hyphaeRef.current = alive.length > 500 ? alive.slice(-400) : alive
// Periodically seed new roots
const now = Date.now()
if (now - lastSeed > 3000 && alive.length < 350) {
lastSeed = now
const x = Math.random() * width
hyphaeRef.current.push(
createHypha(x, height + 10, -Math.PI / 2 + (Math.random() - 0.5) * 0.6, 0)
)
}
frameRef.current = requestAnimationFrame(animate)
}
animate()
return () => {
window.removeEventListener("resize", resize)
cancelAnimationFrame(frameRef.current)
}
}, [])
return (
<canvas
ref={canvasRef}
className="fixed inset-0 pointer-events-none"
style={{ zIndex: 0, opacity: 0.55 }}
/>
)
}

View File

@ -0,0 +1,119 @@
"use client"
import { useSectionReveal } from "@/hooks/use-section-reveal"
const PRINCIPLES = [
{
title: "Nutrient Cycling",
body: "Resources flow where they're needed, not where they're hoarded. Mycelial currencies route value like fungi route nutrients — sensing scarcity, bridging gaps, feeding the weak to strengthen the whole. This is the economics of the forest floor.",
icon: (
<svg viewBox="0 0 48 48" className="w-12 h-12" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="24" cy="24" r="4" />
<path d="M24 8v8M24 32v8M8 24h8M32 24h8" />
<path d="M14 14l5 5M29 29l5 5M14 34l5-5M29 19l5-5" />
<circle cx="24" cy="8" r="2" />
<circle cx="24" cy="40" r="2" />
<circle cx="8" cy="24" r="2" />
<circle cx="40" cy="24" r="2" />
</svg>
),
},
{
title: "Mutual Aid",
body: "Every node strengthens the network. Every network strengthens each node. In a mycelial system, there are no freeloaders and no extractors — only participants in a web of reciprocal support. This is the peer-for-peer protocol.",
icon: (
<svg viewBox="0 0 48 48" className="w-12 h-12" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="16" cy="16" r="6" />
<circle cx="32" cy="16" r="6" />
<circle cx="24" cy="32" r="6" />
<path d="M20 20l4 6M28 20l-4 6" />
<path d="M16 22v4M32 22v4" />
</svg>
),
},
{
title: "Distributed Intelligence",
body: "No central brain. No single point of failure. Intelligence emerges from connection, from the ten thousand chemical conversations happening simultaneously across the network. The wisdom is in the web, not the node.",
icon: (
<svg viewBox="0 0 48 48" className="w-12 h-12" fill="none" stroke="currentColor" strokeWidth="1.5">
<circle cx="24" cy="12" r="3" />
<circle cx="12" cy="24" r="3" />
<circle cx="36" cy="24" r="3" />
<circle cx="18" cy="36" r="3" />
<circle cx="30" cy="36" r="3" />
<path d="M24 15v6M15 24h6M33 24h-6M20 34l4-10M28 34l-4-10M15 26l3 8M33 26l-3 8" />
</svg>
),
},
]
export function MyceliumSection() {
const sectionRef = useSectionReveal()
return (
<section ref={sectionRef} className="relative py-32 px-6 noise-overlay">
<div className="mycelial-divider mb-20" />
<div className="max-w-4xl mx-auto space-y-20">
<div className="section-reveal space-y-4 text-center">
<h2 className="font-serif text-4xl sm:text-5xl md:text-6xl font-bold">
The Mycelial Network
</h2>
<p className="text-lg sm:text-xl opacity-70 max-w-2xl mx-auto">
Three principles from the forest floor, applied to human systems
</p>
</div>
{PRINCIPLES.map((principle, i) => (
<div
key={principle.title}
className="section-reveal flex gap-8 items-start"
style={{ transitionDelay: `${i * 0.12}s` }}
>
<div className="shrink-0 opacity-50 hidden sm:block mt-1">
{principle.icon}
</div>
<div className="space-y-3">
<h3 className="font-serif text-2xl sm:text-3xl font-semibold">
{principle.title}
</h3>
<p className="text-base leading-relaxed opacity-75">
{principle.body}
</p>
</div>
{i < PRINCIPLES.length - 1 && (
<div className="hidden" />
)}
</div>
))}
{/* Connecting line between principles - visual only */}
<div className="section-reveal text-center space-y-3 pt-4">
<p className="text-sm opacity-50 max-w-lg mx-auto">
These are the protocols of{" "}
<a
href="https://mycofi.earth"
target="_blank"
rel="noopener noreferrer"
className="domain-link"
>
mycofi.earth
</a>
. The economics of interconnection, first practiced by fungi four
hundred million years before capitalism. Read more about the
philosophy at{" "}
<a
href="https://mycopunk.xyz"
target="_blank"
rel="noopener noreferrer"
className="domain-link"
>
mycopunk.xyz
</a>
.
</p>
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,237 @@
"use client"
import { useState } from "react"
import { useSectionReveal } from "@/hooks/use-section-reveal"
import { ExternalLink } from "lucide-react"
interface NetworkNode {
name: string
domain: string
description: string
x: number
y: number
primary?: boolean
}
const NODES: NetworkNode[] = [
{
name: "MycoStack",
domain: "mycostack.xyz",
description: "Technology-augmented commons",
x: 50,
y: 45,
primary: true,
},
{
name: "MycoFi",
domain: "mycofi.earth",
description: "Mycoeconomics & regenerative currencies",
x: 25,
y: 20,
},
{
name: "Mycopunk",
domain: "mycopunk.xyz",
description: "Building from beneath the surface",
x: 75,
y: 15,
},
{
name: "Compost Capitalism",
domain: "compostcapitalism.xyz",
description: "Decomposing extractive systems",
x: 12,
y: 50,
},
{
name: "The Undernet",
domain: "undernet.earth",
description: "Community-owned infrastructure",
x: 88,
y: 45,
},
{
name: "Post-Appitalism",
domain: "post-appitalist.app",
description: "Tools beyond extractive platforms",
x: 22,
y: 78,
},
{
name: "(You)rSpace",
domain: "yourspace.online",
description: "Spaces that belong to communities",
x: 60,
y: 80,
},
{
name: "Psilo-Cyber",
domain: "psilo-cyber.net",
description: "Encrypted mesh networks",
x: 82,
y: 72,
},
{
name: "Trippin Balls",
domain: "trippinballs.lol",
description: "Expand your perspective",
x: 42,
y: 88,
},
]
const CONNECTIONS: [string, string][] = [
["mycostack.xyz", "mycofi.earth"],
["mycostack.xyz", "undernet.earth"],
["mycostack.xyz", "compostcapitalism.xyz"],
["mycostack.xyz", "yourspace.online"],
["mycofi.earth", "mycopunk.xyz"],
["undernet.earth", "psilo-cyber.net"],
["compostcapitalism.xyz", "post-appitalist.app"],
["post-appitalist.app", "yourspace.online"],
["yourspace.online", "trippinballs.lol"],
["mycopunk.xyz", "psilo-cyber.net"],
["mycofi.earth", "post-appitalist.app"],
["mycopunk.xyz", "undernet.earth"],
]
function getNode(domain: string) {
return NODES.find((n) => n.domain === domain)
}
export function NetworkMapSection() {
const sectionRef = useSectionReveal()
const [hovered, setHovered] = useState<string | null>(null)
const isConnected = (domain: string) => {
if (!hovered) return false
return CONNECTIONS.some(
([a, b]) =>
(a === hovered && b === domain) || (b === hovered && a === domain)
)
}
const getLineOpacity = (a: string, b: string) => {
if (!hovered) return 0.15
if (
(a === hovered || b === hovered) &&
(isConnected(a) || isConnected(b) || a === hovered || b === hovered)
)
return 0.5
return 0.06
}
return (
<section ref={sectionRef} className="relative py-32 px-6 noise-overlay">
<div className="mycelial-divider mb-20" />
<div className="max-w-5xl mx-auto space-y-16">
<div className="section-reveal space-y-4 text-center">
<h2 className="font-serif text-4xl sm:text-5xl md:text-6xl font-bold">
The Network of Networks
</h2>
<p className="text-lg sm:text-xl opacity-70 max-w-2xl mx-auto">
Every node strengthens the whole. Every connection multiplies
possibility.
</p>
</div>
{/* Desktop: SVG network map */}
<div className="section-reveal hidden md:block">
<div className="relative" style={{ paddingBottom: "60%" }}>
<svg
viewBox="0 0 1000 600"
className="absolute inset-0 w-full h-full"
fill="none"
>
{/* Connection lines */}
{CONNECTIONS.map(([a, b]) => {
const nodeA = getNode(a)
const nodeB = getNode(b)
if (!nodeA || !nodeB) return null
return (
<line
key={`${a}-${b}`}
x1={nodeA.x * 10}
y1={nodeA.y * 6}
x2={nodeB.x * 10}
y2={nodeB.y * 6}
stroke="var(--scroll-accent)"
strokeWidth="1"
opacity={getLineOpacity(a, b)}
style={{ transition: "opacity 0.3s ease" }}
/>
)
})}
</svg>
{/* Node cards */}
{NODES.map((node) => {
const isActive = hovered === node.domain
const connected = isConnected(node.domain)
const dimmed = hovered && !isActive && !connected
return (
<a
key={node.domain}
href={`https://${node.domain}`}
target="_blank"
rel="noopener noreferrer"
className="network-node absolute -translate-x-1/2 -translate-y-1/2"
style={{
left: `${node.x}%`,
top: `${node.y}%`,
opacity: dimmed ? 0.3 : 1,
transition: "opacity 0.3s ease, transform 0.3s ease, filter 0.3s ease",
}}
onMouseEnter={() => setHovered(node.domain)}
onMouseLeave={() => setHovered(null)}
>
<div
className={`glass-card px-4 py-3 text-center space-y-1 ${
node.primary ? "ring-1 ring-current/20" : ""
}`}
>
<div className="font-serif text-sm font-semibold whitespace-nowrap">
{node.name}
</div>
<div className="font-mono text-xs opacity-50 whitespace-nowrap flex items-center gap-1 justify-center">
{node.domain}
<ExternalLink className="w-2.5 h-2.5" />
</div>
{isActive && (
<div className="text-xs opacity-60 max-w-[160px]">
{node.description}
</div>
)}
</div>
</a>
)
})}
</div>
</div>
{/* Mobile: Simple card list */}
<div className="section-reveal md:hidden grid gap-3 grid-cols-1 sm:grid-cols-2">
{NODES.map((node, i) => (
<a
key={node.domain}
href={`https://${node.domain}`}
target="_blank"
rel="noopener noreferrer"
className="glass-card p-4 space-y-1 hover:opacity-80 transition-opacity"
style={{ transitionDelay: `${i * 0.05}s` }}
>
<div className="font-serif text-base font-semibold flex items-center gap-2">
{node.name}
<ExternalLink className="w-3 h-3 opacity-40" />
</div>
<div className="font-mono text-xs opacity-40">{node.domain}</div>
<div className="text-xs opacity-60">{node.description}</div>
</a>
))}
</div>
</div>
</section>
)
}

View File

@ -0,0 +1,58 @@
"use client"
import { useScrollProgress } from "@/hooks/use-scroll-progress"
import { useEffect } from "react"
// Color stops: [scrollPos, bg-L, bg-C, bg-H, fg-L, fg-C, fg-H, accent-L, accent-C, accent-H]
const COLOR_STOPS: number[][] = [
[0.0, 0.08, 0.02, 30, 0.85, 0.03, 80, 0.45, 0.12, 60],
[0.15, 0.12, 0.04, 40, 0.82, 0.04, 80, 0.55, 0.15, 45],
[0.3, 0.16, 0.05, 150, 0.88, 0.05, 145, 0.55, 0.18, 155],
[0.5, 0.22, 0.04, 200, 0.9, 0.03, 200, 0.5, 0.14, 220],
[0.65, 0.45, 0.06, 140, 0.15, 0.03, 150, 0.65, 0.18, 145],
[0.8, 0.85, 0.04, 110, 0.15, 0.04, 100, 0.6, 0.16, 90],
[0.92, 0.94, 0.02, 110, 0.12, 0.03, 100, 0.55, 0.18, 155],
]
function lerp(a: number, b: number, t: number) {
return a + (b - a) * t
}
function lerpHue(a: number, b: number, t: number) {
let diff = b - a
if (diff > 180) diff -= 360
if (diff < -180) diff += 360
return ((a + diff * t) % 360 + 360) % 360
}
function interpolateColors(progress: number) {
let i = 0
for (; i < COLOR_STOPS.length - 1; i++) {
if (progress <= COLOR_STOPS[i + 1][0]) break
}
const stop = COLOR_STOPS[i]
const next = COLOR_STOPS[Math.min(i + 1, COLOR_STOPS.length - 1)]
const range = next[0] - stop[0]
const t = range > 0 ? Math.min(1, Math.max(0, (progress - stop[0]) / range)) : 0
return {
bg: `oklch(${lerp(stop[1], next[1], t).toFixed(3)} ${lerp(stop[2], next[2], t).toFixed(3)} ${lerpHue(stop[3], next[3], t).toFixed(1)})`,
fg: `oklch(${lerp(stop[4], next[4], t).toFixed(3)} ${lerp(stop[5], next[5], t).toFixed(3)} ${lerpHue(stop[6], next[6], t).toFixed(1)})`,
accent: `oklch(${lerp(stop[7], next[7], t).toFixed(3)} ${lerp(stop[8], next[8], t).toFixed(3)} ${lerpHue(stop[9], next[9], t).toFixed(1)})`,
}
}
export function ScrollProvider({ children }: { children: React.ReactNode }) {
const progress = useScrollProgress()
useEffect(() => {
const { bg, fg, accent } = interpolateColors(progress)
const root = document.documentElement
root.style.setProperty("--scroll-bg", bg)
root.style.setProperty("--scroll-fg", fg)
root.style.setProperty("--scroll-accent", accent)
root.style.setProperty("--scroll-depth", String(progress))
}, [progress])
return <>{children}</>
}

View File

@ -0,0 +1,117 @@
"use client"
import { useSectionReveal } from "@/hooks/use-section-reveal"
const ASCII_NETWORK = ` [node-01] ──── [node-02]
[node-03] [node-04]
[mesh-05]
[node-06] [node-07]`
export function UndernetSection() {
const sectionRef = useSectionReveal()
return (
<section ref={sectionRef} className="relative py-32 px-6 noise-overlay">
<div className="mycelial-divider mb-20" />
<div className="max-w-5xl mx-auto space-y-16">
<div className="section-reveal space-y-4 text-center">
<h2 className="font-serif text-4xl sm:text-5xl md:text-6xl font-bold">
The Undernet
</h2>
<div className="font-mono text-sm opacity-50">
<span className="opacity-40">&gt;</span> connecting nodes...
building resilience...
<span className="cursor-blink ml-0.5">_</span>
</div>
</div>
<div className="grid gap-12 lg:grid-cols-2 items-start">
{/* Description */}
<div className="section-reveal space-y-6">
<h3 className="font-serif text-2xl font-semibold">
Community-Owned Infrastructure
</h3>
<div className="space-y-4 text-base leading-relaxed opacity-75">
<p>
Beneath the extractive platforms, a different kind of
infrastructure is growing. Self-provisioned. Privacy-first.
Data sovereign. Locally resilient.
</p>
<p>
Community servers. Encrypted mesh networks. Open protocols that
no corporation can shut down. Hardware owned by the people who
depend on it. Software that serves its users instead of
surveilling them.
</p>
<p>
This is the{" "}
<a
href="https://undernet.earth"
target="_blank"
rel="noopener noreferrer"
className="domain-link"
>
undernet.earth
</a>{" "}
the network beneath the network. Where the{" "}
<a
href="https://psilo-cyber.net"
target="_blank"
rel="noopener noreferrer"
className="domain-link"
>
psilo-cyber.net
</a>
work grows, encrypted and entangled, through the substrate of
the old world.
</p>
</div>
<div className="glass-card p-4 space-y-2 font-mono text-xs opacity-60">
<div>
<span className="opacity-50">protocol:</span> fog computing
</div>
<div>
<span className="opacity-50">governance:</span> mycological
consensus
</div>
<div>
<span className="opacity-50">ownership:</span> community
</div>
<div>
<span className="opacity-50">surveillance:</span>{" "}
<span style={{ color: "var(--mycelium-green)" }}>none</span>
</div>
</div>
</div>
{/* ASCII Network Diagram */}
<div
className="section-reveal glass-card p-6 overflow-x-auto"
style={{ transitionDelay: "0.2s" }}
>
<div className="font-mono text-xs sm:text-sm leading-relaxed opacity-60 whitespace-pre">
{ASCII_NETWORK}
</div>
<p className="mt-4 font-mono text-xs opacity-40">
// every node is sovereign
<br />
// every connection is encrypted
<br />// the network has no center
</p>
</div>
</div>
</div>
</section>
)
}

32
docker-compose.yml Normal file
View File

@ -0,0 +1,32 @@
services:
mycostack-website:
build: .
container_name: mycostack-website
restart: unless-stopped
security_opt:
- no-new-privileges:true
cap_drop:
- ALL
cap_add:
- NET_BIND_SERVICE
- CHOWN
- SETGID
- SETUID
- DAC_OVERRIDE
read_only: true
tmpfs:
- /tmp
- /var/cache/nginx
- /var/run
networks:
- traefik-public
labels:
- "traefik.enable=true"
- "traefik.http.routers.mycostack.rule=Host(`mycostack.xyz`) || Host(`www.mycostack.xyz`)"
- "traefik.http.routers.mycostack.entrypoints=web"
- "traefik.http.services.mycostack.loadbalancer.server.port=80"
- "traefik.docker.network=traefik-public"
networks:
traefik-public:
external: true

View File

@ -0,0 +1,33 @@
"use client"
import { useState, useEffect, useCallback } from "react"
export function useScrollProgress() {
const [progress, setProgress] = useState(0)
const handleScroll = useCallback(() => {
const scrollTop = window.scrollY
const docHeight =
document.documentElement.scrollHeight - window.innerHeight
const scrollPercent = docHeight > 0 ? scrollTop / docHeight : 0
setProgress(Math.min(1, Math.max(0, scrollPercent)))
}, [])
useEffect(() => {
let ticking = false
const onScroll = () => {
if (!ticking) {
requestAnimationFrame(() => {
handleScroll()
ticking = false
})
ticking = true
}
}
window.addEventListener("scroll", onScroll, { passive: true })
handleScroll()
return () => window.removeEventListener("scroll", onScroll)
}, [handleScroll])
return progress
}

View File

@ -0,0 +1,30 @@
"use client"
import { useEffect, useRef } from "react"
export function useSectionReveal() {
const ref = useRef<HTMLElement>(null)
useEffect(() => {
const el = ref.current
if (!el) return
const observer = new IntersectionObserver(
(entries) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("visible")
}
})
},
{ threshold: 0.15, rootMargin: "0px 0px -50px 0px" }
)
const revealElements = el.querySelectorAll(".section-reveal")
revealElements.forEach((child) => observer.observe(child))
return () => observer.disconnect()
}, [])
return ref
}

6
lib/utils.ts Normal file
View File

@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

9
next.config.mjs Normal file
View File

@ -0,0 +1,9 @@
/** @type {import('next').NextConfig} */
const nextConfig = {
output: 'export',
eslint: { ignoreDuringBuilds: true },
typescript: { ignoreBuildErrors: true },
images: { unoptimized: true },
}
export default nextConfig

23
nginx.conf Normal file
View File

@ -0,0 +1,23 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ $uri.html =404;
}
error_page 404 /404.html;
location /_next/static/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
gzip on;
gzip_vary on;
gzip_proxied any;
gzip_types text/plain text/css application/json application/javascript text/xml application/xml text/javascript image/svg+xml;
gzip_min_length 1000;
}

31
package.json Normal file
View File

@ -0,0 +1,31 @@
{
"name": "mycostack-website",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
},
"dependencies": {
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"geist": "^1.3.1",
"lucide-react": "^0.454.0",
"next": "^15.3.3",
"react": "^19.1.0",
"react-dom": "^19.1.0",
"tailwind-merge": "^2.5.5"
},
"devDependencies": {
"@tailwindcss/postcss": "^4.1.9",
"@types/node": "^22.15.0",
"@types/react": "^19.1.0",
"@types/react-dom": "^19.1.0",
"postcss": "^8.5.4",
"tailwindcss": "^4.1.9",
"tw-animate-css": "^1.3.3",
"typescript": "^5.8.3"
}
}

1011
pnpm-lock.yaml Normal file

File diff suppressed because it is too large Load Diff

7
postcss.config.mjs Normal file
View File

@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
}
export default config

23
tsconfig.json Normal file
View File

@ -0,0 +1,23 @@
{
"compilerOptions": {
"target": "ES2017",
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "preserve",
"incremental": true,
"plugins": [{ "name": "next" }],
"paths": {
"@/*": ["./*"]
}
},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
"exclude": ["node_modules"]
}