website: Next.js premium marketing app — landing v1 (SSR, light/dark, 3D hero)

Standalone Next 16 app at frontend/apps/artcraft-website-next (excluded from
the npm workspace like artcraft-dashboard; React 19 + R3F 9 + GSAP + Lenis +
Tailwind 4). Branched from design/prototype0; the Marathon-style navbar is
ported to next/link with theme-aware tokens plus a light/dark toggle.

- Design system: hairline-grid 'instrument panel' language; light (blueprint
  paper) + dark (viewport terminal) themes via CSS tokens, system-pref default
  with persisted override applied pre-paint
- Landing: hero, capability ticker, feature grid (real product footage),
  ownership, made-with (click-to-load YouTube), CTA, footer — all crawlable
  SSR HTML; GSAP reveals are progressive enhancement only
- 3D hero: interactive blocking↔render comparator — wireframe R3F set clipped
  over the product reel by a cursor-driven scan line; degrades to plain video
  on reduced-motion/coarse-pointer/WebGL failure
- SEO: metadata + OG, sitemap.ts, robots.ts, SoftwareApplication JSON-LD
This commit is contained in:
Tanish Manku committed 2026-08-17 14:07:12 -07:00
1 parent 9444d347af
commit dbac21c44c
34 files changed
+4962 -1

No files matched your search

@@ -0,0 +1,4 @@
node_modules/
.next/
next-env.d.ts
*.tsbuildinfo
@@ -0,0 +1,7 @@
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
};
export default nextConfig;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,38 @@
{
"name": "@frontend/artcraft-website-next",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-navigation-menu": "^1.2.22",
"@react-three/drei": "10.7.7",
"@react-three/fiber": "9.6.1",
"@react-three/postprocessing": "3.0.4",
"gsap": "3.15.0",
"lenis": "1.3.23",
"lucide-react": "^1.31.0",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"tailwind-merge": "^3.6.0",
"three": "0.184.0",
"zustand": "5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "0.184.0",
"tailwindcss": "4.3.0",
"typescript": "^5"
}
}
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
<defs>
<style>
.cls-1 {
fill: #4e7bfb;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

@@ -0,0 +1,211 @@
@import "tailwindcss";
/*
* ArtCraft marketing site — design tokens.
*
* Two committed themes sharing one structural language (hairline rules, hard
* edges, mono micro-labels, single blue accent):
* dark — "viewport": near-black terminal surfaces, white ink
* light — "blueprint": warm paper surfaces, near-black ink
*
* Light is the :root base; dark applies via explicit [data-theme="dark"] or
* system preference when no explicit choice is stored. `--accent-ink` is the
* AA-contrast-safe accent for text/small glyphs; `--accent` is the raw brand
* blue for surfaces, glows, and large shapes only.
*/
:root {
--bg: #f2f1ee;
--bg-raised: #faf9f7;
--bg-sunken: #e9e8e4;
--ink: #101014;
--ink-strong: #000000;
--muted: rgba(16, 16, 20, 0.62);
--faint: rgba(16, 16, 20, 0.42);
--line: rgba(16, 16, 20, 0.16);
--line-strong: rgba(16, 16, 20, 0.4);
--accent: #2d81ff;
--accent-ink: #1659c4;
--invert-bg: #101014;
--invert-fg: #f2f1ee;
--danger: #b3261e;
--scanline: rgba(16, 16, 20, 0.03);
color-scheme: light;
}
:root[data-theme="dark"] {
--bg: #0b0b0c;
--bg-raised: #101014;
--bg-sunken: #060607;
--ink: #f2f1ee;
--ink-strong: #ffffff;
--muted: rgba(242, 241, 238, 0.6);
--faint: rgba(242, 241, 238, 0.38);
--line: rgba(242, 241, 238, 0.15);
--line-strong: rgba(242, 241, 238, 0.4);
--accent: #2d81ff;
--accent-ink: #74aaff;
--invert-bg: #ffffff;
--invert-fg: #0b0b0c;
--danger: #ff6259;
--scanline: rgba(255, 255, 255, 0.025);
color-scheme: dark;
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0b0b0c;
--bg-raised: #101014;
--bg-sunken: #060607;
--ink: #f2f1ee;
--ink-strong: #ffffff;
--muted: rgba(242, 241, 238, 0.6);
--faint: rgba(242, 241, 238, 0.38);
--line: rgba(242, 241, 238, 0.15);
--line-strong: rgba(242, 241, 238, 0.4);
--accent: #2d81ff;
--accent-ink: #74aaff;
--invert-bg: #ffffff;
--invert-fg: #0b0b0c;
--danger: #ff6259;
--scanline: rgba(255, 255, 255, 0.025);
color-scheme: dark;
}
}
@theme inline {
--color-bg: var(--bg);
--color-bg-raised: var(--bg-raised);
--color-bg-sunken: var(--bg-sunken);
--color-ink: var(--ink);
--color-ink-strong: var(--ink-strong);
--color-muted: var(--muted);
--color-faint: var(--faint);
--color-line: var(--line);
--color-line-strong: var(--line-strong);
--color-accent: var(--accent);
--color-accent-ink: var(--accent-ink);
--color-invert-bg: var(--invert-bg);
--color-invert-fg: var(--invert-fg);
--color-danger: var(--danger);
--font-display: var(--font-outfit), system-ui, sans-serif;
--font-serif: var(--font-instrument), Georgia, serif;
--font-body: var(--font-source-sans), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
/* No rounded corners anywhere in this design language. */
--radius-*: initial;
}
html {
background: var(--bg);
}
body {
background: var(--bg);
color: var(--ink);
font-family: var(--font-body);
-webkit-font-smoothing: antialiased;
overflow-x: clip;
}
::selection {
background: var(--accent);
color: #ffffff;
}
:focus-visible {
outline: 2px solid var(--accent-ink);
outline-offset: 2px;
}
/* Signature interaction: label blocks invert on hover/active. */
.invert-block {
transition: background-color 120ms linear, color 120ms linear;
}
.invert-block:hover,
.invert-block[data-active="true"] {
background: var(--invert-bg);
color: var(--invert-fg);
}
/* Renders the brand mark in the current theme's ink color. */
.themed-logo {
filter: brightness(0);
}
:root[data-theme="dark"] .themed-logo {
filter: brightness(0) invert(1);
}
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) .themed-logo {
filter: brightness(0) invert(1);
}
}
/* Mono HUD micro-label. */
.hud-label {
font-family: var(--font-mono);
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.15em;
text-transform: uppercase;
}
/* Crosshair tick straddling a grid intersection. */
.tick {
position: absolute;
width: 11px;
height: 11px;
pointer-events: none;
}
.tick::before,
.tick::after {
content: "";
position: absolute;
background: var(--line-strong);
}
.tick::before {
left: 5px;
top: 0;
bottom: 0;
width: 1px;
}
.tick::after {
top: 5px;
left: 0;
right: 0;
height: 1px;
}
/* Mono capability marquee. Duplicated track scrolls -50%; the reduced-motion
* blanket below freezes it in place. */
.marquee {
display: flex;
overflow: hidden;
}
.marquee-track {
display: flex;
flex-shrink: 0;
min-width: 100%;
animation: marquee-scroll 36s linear infinite;
}
@keyframes marquee-scroll {
to {
transform: translateX(-100%);
}
}
@media (prefers-reduced-motion: reduce) {
*,
*::before,
*::after {
animation-duration: 0.01ms !important;
animation-iteration-count: 1 !important;
transition-duration: 0.01ms !important;
scroll-behavior: auto !important;
}
}
@@ -0,0 +1,109 @@
import type { Metadata, Viewport } from "next";
import { Outfit, Instrument_Serif, Source_Sans_3, Geist_Mono } from "next/font/google";
import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import MotionProvider from "@/components/motion-provider";
import "./globals.css";
const SITE_URL = "https://getartcraft.com";
const outfit = Outfit({
subsets: ["latin"],
variable: "--font-outfit",
display: "swap",
});
const instrumentSerif = Instrument_Serif({
subsets: ["latin"],
weight: "400",
style: ["normal", "italic"],
variable: "--font-instrument",
display: "swap",
});
const sourceSans = Source_Sans_3({
subsets: ["latin"],
variable: "--font-source-sans",
display: "swap",
});
const geistMono = Geist_Mono({
subsets: ["latin"],
variable: "--font-geist-mono",
display: "swap",
});
export const metadata: Metadata = {
metadataBase: new URL(SITE_URL),
title: {
default: "ArtCraft — Controllable AI for Artists",
template: "%s — ArtCraft",
},
description:
"ArtCraft is the open-source desktop app for generating AI video and images — built for artists who want real control.",
openGraph: {
type: "website",
url: SITE_URL,
siteName: "ArtCraft",
title: "ArtCraft — Controllable AI for Artists",
description:
"ArtCraft is the open-source desktop app for generating AI video and images — built for artists who want real control.",
images: [{ url: "/images/og-image.png", width: 1200, height: 630 }],
},
twitter: {
card: "summary_large_image",
},
icons: {
icon: "/artcraft-icon.svg",
},
};
export const viewport: Viewport = {
themeColor: [
{ media: "(prefers-color-scheme: light)", color: "#f2f1ee" },
{ media: "(prefers-color-scheme: dark)", color: "#0b0b0c" },
],
};
const JSON_LD = {
"@context": "https://schema.org",
"@type": "SoftwareApplication",
name: "ArtCraft",
applicationCategory: "DesignApplication",
operatingSystem: "macOS, Windows, Web",
description:
"Open-source desktop app for generating AI video and images — built for artists who want real control.",
url: SITE_URL,
offers: { "@type": "Offer", price: "0", priceCurrency: "USD" },
publisher: { "@type": "Organization", name: "ArtCraft", url: SITE_URL },
};
// Applies the stored theme before first paint so neither theme flashes.
// System preference is the default; an explicit user choice is persisted
// as "light" | "dark" under this key by the navbar toggle.
const THEME_SCRIPT = `(function(){try{var t=localStorage.getItem("artcraft-theme");if(t==="light"||t==="dark"){document.documentElement.setAttribute("data-theme",t);}}catch(e){}})();`;
export default function RootLayout({
children,
}: Readonly<{ children: React.ReactNode }>) {
return (
<html lang="en" suppressHydrationWarning>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
</head>
<body
className={`${outfit.variable} ${instrumentSerif.variable} ${sourceSans.variable} ${geistMono.variable}`}
>
<script
type="application/ld+json"
dangerouslySetInnerHTML={{ __html: JSON.stringify(JSON_LD) }}
/>
<MotionProvider>
<SiteNav />
<main>{children}</main>
<SiteFooter />
</MotionProvider>
</body>
</html>
);
}
@@ -0,0 +1,21 @@
import RevealManager from "@/components/reveal-manager";
import Hero from "@/components/landing/hero";
import CapabilityTicker from "@/components/landing/capability-ticker";
import Features from "@/components/landing/features";
import Ownership from "@/components/landing/ownership";
import MadeWith from "@/components/landing/made-with";
import FinalCta from "@/components/landing/final-cta";
export default function Home() {
return (
<>
<RevealManager />
<Hero />
<CapabilityTicker />
<Features />
<Ownership />
<MadeWith />
<FinalCta />
</>
);
}
@@ -0,0 +1,8 @@
import type { MetadataRoute } from "next";
export default function robots(): MetadataRoute.Robots {
return {
rules: { userAgent: "*", allow: "/" },
sitemap: "https://getartcraft.com/sitemap.xml",
};
}
@@ -0,0 +1,15 @@
import type { MetadataRoute } from "next";
const SITE_URL = "https://getartcraft.com";
// Only routes this app actually serves. Grows as marketing pages migrate
// over from the Vite site.
export default function sitemap(): MetadataRoute.Sitemap {
return [
{
url: `${SITE_URL}/`,
changeFrequency: "weekly",
priority: 1,
},
];
}
@@ -0,0 +1,13 @@
// Brand glyphs lucide no longer ships. Path data from Simple Icons (CC0).
export function GitHubIcon({ className }: { className?: string }) {
return (
<svg
viewBox="0 0 24 24"
fill="currentColor"
aria-hidden
className={className}
>
<path d="M12 .297c-6.63 0-12 5.373-12 12 0 5.303 3.438 9.8 8.205 11.385.6.113.82-.258.82-.577 0-.285-.01-1.04-.015-2.04-3.338.724-4.042-1.61-4.042-1.61C4.422 18.07 3.633 17.7 3.633 17.7c-1.087-.744.084-.729.084-.729 1.205.084 1.838 1.236 1.838 1.236 1.07 1.835 2.809 1.305 3.495.998.108-.776.417-1.305.76-1.605-2.665-.3-5.466-1.332-5.466-5.93 0-1.31.465-2.38 1.235-3.22-.135-.303-.54-1.523.105-3.176 0 0 1.005-.322 3.3 1.23.96-.267 1.98-.399 3-.405 1.02.006 2.04.138 3 .405 2.28-1.552 3.285-1.23 3.285-1.23.645 1.653.24 2.873.12 3.176.765.84 1.23 1.91 1.23 3.22 0 4.61-2.805 5.625-5.475 5.92.42.36.81 1.096.81 2.22 0 1.606-.015 2.896-.015 3.286 0 .315.21.69.825.57C20.565 22.092 24 17.592 24 12.297c0-6.627-5.373-12-12-12" />
</svg>
);
}
@@ -0,0 +1,33 @@
import { TICKER_ITEMS } from "@/lib/landing-data";
function Track({ hidden = false }: { hidden?: boolean }) {
return (
<ul
aria-hidden={hidden || undefined}
className="marquee-track items-center"
>
{TICKER_ITEMS.map((item) => (
<li
key={item}
className="hud-label flex items-center whitespace-nowrap px-6 py-3 text-muted"
>
<span aria-hidden className="mr-6 text-accent-ink">
▪
</span>
{item}
</li>
))}
</ul>
);
}
// Full-bleed mono marquee of models and capabilities. The duplicate track
// exists purely for the seamless loop and is hidden from assistive tech.
export default function CapabilityTicker() {
return (
<div className="marquee border-t border-line">
<Track />
<Track hidden />
</div>
);
}
@@ -0,0 +1,37 @@
import type { ReactNode } from "react";
import Link from "next/link";
const BASE =
"hud-label inline-flex h-12 items-center justify-center gap-2 px-6 font-bold transition-opacity";
const VARIANTS = {
solid: "bg-invert-bg text-invert-fg hover:opacity-80",
outline:
"invert-block border border-line-strong text-ink hover:border-transparent",
} as const;
export default function CtaLink({
href,
variant = "solid",
external = false,
children,
}: {
href: string;
variant?: keyof typeof VARIANTS;
external?: boolean;
children: ReactNode;
}) {
const className = `${BASE} ${VARIANTS[variant]}`;
if (external || /^https?:\/\//.test(href)) {
return (
<a href={href} className={className}>
{children}
</a>
);
}
return (
<Link href={href} className={className}>
{children}
</Link>
);
}
@@ -0,0 +1,86 @@
import { FEATURES } from "@/lib/landing-data";
import LazyVideo from "@/components/lazy-video";
import { SectionShell, SectionEyebrow } from "./section-shell";
// Feature grid: hairline-separated cells (gap-px over the line color), the
// first feature spanning the full row. Every cell is index + label + real
// product footage + copy — no illustration, only the actual tool.
export default function Features() {
const [lead, ...rest] = FEATURES;
return (
<SectionShell id="features">
<SectionEyebrow
index="01"
label="Crafting features"
annotation="Real footage — captured in ArtCraft"
/>
<div className="px-6 py-14 md:px-10 md:py-20">
<h2
data-reveal
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
>
The control that mere{" "}
<span className="font-serif italic font-normal">words</span> cannot
buy.
</h2>
<p data-reveal className="mt-5 max-w-xl text-lg leading-relaxed text-muted">
Text prompting is neat, but artists crave control. Compose the shot
yourself — then let the model render it.
</p>
</div>
<div className="grid gap-px border-t border-line bg-line md:grid-cols-2">
<FeatureCell feature={lead} lead />
{rest.map((feature) => (
<FeatureCell key={feature.index} feature={feature} />
))}
{/* Filler cell keeps the hairline grid rectangular with 7 items. */}
<div
aria-hidden
className="hidden items-end justify-between bg-bg p-6 md:flex md:p-8"
>
<p className="hud-label text-faint">More in the app</p>
<p className="hud-label text-faint">07 / 07</p>
</div>
</div>
</SectionShell>
);
}
function FeatureCell({
feature,
lead = false,
}: {
feature: (typeof FEATURES)[number];
lead?: boolean;
}) {
return (
<article className={`bg-bg ${lead ? "md:col-span-2" : ""}`}>
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5 md:px-8">
<p className="hud-label text-muted">{feature.label}</p>
<p className="hud-label text-faint">{feature.index}</p>
</div>
<div
className={`relative w-full overflow-hidden bg-bg-sunken ${
lead ? "aspect-[21/9]" : "aspect-video"
}`}
>
<LazyVideo
src={feature.video}
label={`${feature.title} demo`}
className="absolute inset-0 h-full w-full object-cover"
/>
</div>
<div className="px-6 py-6 md:px-8">
<h3 className="font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
{feature.title}
</h3>
<p className="mt-2 max-w-xl leading-relaxed text-muted">
{feature.description}
</p>
</div>
</article>
);
}
@@ -0,0 +1,30 @@
import { WEBAPP_URL } from "@/lib/links";
import { SectionShell } from "./section-shell";
import CtaLink from "./cta-link";
export default function FinalCta() {
return (
<SectionShell>
<div className="flex flex-col items-center px-6 py-20 text-center md:py-28">
<p className="hud-label text-faint">Free to start · No subscription</p>
<h2
data-reveal
className="mt-6 max-w-4xl font-display text-5xl font-medium leading-[0.98] tracking-[-0.04em] text-ink-strong sm:text-6xl md:text-7xl"
>
Start{" "}
<span className="font-serif italic font-normal">crafting</span>.
</h2>
<p className="mt-6 max-w-md text-lg leading-relaxed text-muted">
Download the studio, or run it in your browser. Your first renders
are minutes away.
</p>
<div className="mt-10 flex flex-wrap items-center justify-center gap-3">
<CtaLink href="/download">Download for desktop</CtaLink>
<CtaLink href={WEBAPP_URL} variant="outline">
Launch in browser
</CtaLink>
</div>
</div>
</SectionShell>
);
}
@@ -0,0 +1,156 @@
"use client";
import { useMemo, useRef } from "react";
import { Canvas, useFrame } from "@react-three/fiber";
import { Grid } from "@react-three/drei";
import * as THREE from "three";
// The "blocking" side of the hero comparator: a film set sketched in
// wireframe — backdrop, blocked props, a character stand-in, and a virtual
// camera gizmo aimed at the set. Everything a director sees before the
// AI render exists. Colors come from the live theme tokens via props.
export type SceneColors = {
line: string;
lineStrong: string;
accent: string;
ink: string;
};
export default function HeroScene({
colors,
pointer,
}: {
colors: SceneColors;
// Normalized pointer position over the hero figure, -1..1 on both axes.
// Mutated externally; read per frame without re-rendering React.
pointer: React.RefObject<{ x: number; y: number }>;
}) {
return (
<Canvas
aria-hidden
tabIndex={-1}
dpr={[1, 2]}
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
camera={{ fov: 34, position: [0, 2.1, 9.2], near: 0.1, far: 60 }}
style={{ pointerEvents: "none" }}
>
<Rig pointer={pointer}>
<Grid
position={[0, 0, 0]}
args={[30, 30]}
cellSize={0.6}
cellThickness={0.6}
cellColor={colors.line}
sectionSize={3}
sectionThickness={1}
sectionColor={colors.lineStrong}
fadeDistance={26}
fadeStrength={2.4}
infiniteGrid
/>
<Set colors={colors} />
</Rig>
</Canvas>
);
}
// Slow establishing drift plus pointer parallax, critically damped so it
// never overshoots or jitters.
function Rig({
pointer,
children,
}: {
pointer: React.RefObject<{ x: number; y: number }>;
children: React.ReactNode;
}) {
const group = useRef<THREE.Group>(null);
const current = useRef({ x: 0, y: 0 });
useFrame((state, delta) => {
const g = group.current;
if (!g) return;
const target = pointer.current ?? { x: 0, y: 0 };
const k = 1 - Math.exp(-4 * delta);
current.current.x += (target.x - current.current.x) * k;
current.current.y += (target.y - current.current.y) * k;
const t = state.clock.elapsedTime;
g.rotation.y = Math.sin(t * 0.08) * 0.1 + current.current.x * 0.14;
g.rotation.x = current.current.y * 0.05;
});
return <group ref={group}>{children}</group>;
}
function Set({ colors }: { colors: SceneColors }) {
return (
<group>
{/* Backdrop flat */}
<Wire geometry="box" args={[8.4, 4.6, 0.12]} position={[0, 2.3, -3.6]} color={colors.line} />
{/* Blocked props */}
<Wire geometry="box" args={[1.5, 1.5, 1.5]} position={[-2.5, 0.75, -0.8]} color={colors.lineStrong} />
<Wire geometry="box" args={[1, 1, 1]} position={[-1.35, 0.5, 0.6]} color={colors.line} />
<Wire geometry="cylinder" args={[0.55, 0.55, 1.4, 12]} position={[2.6, 0.7, -1.2]} color={colors.lineStrong} />
<Wire geometry="sphere" args={[0.62, 16, 12]} position={[1.5, 0.62, 0.9]} color={colors.line} />
{/* Character stand-in */}
<Wire geometry="capsule" args={[0.34, 1.05, 4, 10]} position={[0.1, 0.86, -0.2]} color={colors.accent} />
{/* Virtual camera gizmo, aimed back at the set */}
<CameraGizmo colors={colors} />
</group>
);
}
const GEOMETRIES = {
box: THREE.BoxGeometry,
cylinder: THREE.CylinderGeometry,
sphere: THREE.SphereGeometry,
capsule: THREE.CapsuleGeometry,
} as const;
// Edge-line rendering (not triangle wireframe) keeps the blocking sketch
// legible: silhouettes and creases only, like a viewport's wireframe mode.
function Wire({
geometry,
args,
position,
color,
rotation,
}: {
geometry: keyof typeof GEOMETRIES;
args: number[];
position: [number, number, number];
color: string;
rotation?: [number, number, number];
}) {
const edges = useMemo(() => {
const Ctor = GEOMETRIES[geometry] as new (
...a: number[]
) => THREE.BufferGeometry;
const geo = new Ctor(...args);
const result = new THREE.EdgesGeometry(geo, 18);
geo.dispose();
return result;
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [geometry, ...args]);
return (
<lineSegments geometry={edges} position={position} rotation={rotation}>
<lineBasicMaterial color={color} transparent opacity={0.9} />
</lineSegments>
);
}
function CameraGizmo({ colors }: { colors: SceneColors }) {
return (
<group position={[3.4, 1.5, 3]} rotation={[0, Math.PI / 4.6, 0]}>
<Wire geometry="box" args={[0.55, 0.42, 0.8]} position={[0, 0, 0]} color={colors.accent} />
<Wire
geometry="cylinder"
args={[0.34, 0.06, 0.55, 4]}
position={[0, 0, -0.68]}
rotation={[-Math.PI / 2, Math.PI / 4, 0]}
color={colors.accent}
/>
</group>
);
}
@@ -0,0 +1,232 @@
"use client";
import dynamic from "next/dynamic";
import { Component, useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import LazyVideo from "@/components/lazy-video";
import type { SceneColors } from "./hero-scene";
// Plain code-split dynamic import — NOT `ssr: false`. It never renders during
// SSR (gated by `ready`, false on the server), so WebGL stays off the server
// without a client-side-rendering bailout around the hero.
const HeroScene = dynamic(() => import("./hero-scene"));
const IDLE_CENTER = 50;
const IDLE_SWING = 16;
const POINTER_MIN = 10;
const POINTER_MAX = 90;
// The hero comparator. The real product reel (the AI render) is the base
// layer; a wireframe 3D blocking viewport is clipped on top. The vertical
// scan line follows the visitor's cursor — sweeping between "what you
// control" and "what you get" — and drifts on its own when idle.
//
// Reduced motion, coarse pointers, small screens, hidden tabs, and WebGL
// failures all degrade to the plain footage.
export default function HeroViewport({
videoSrc,
videoLabel,
}: {
videoSrc: string;
videoLabel: string;
}) {
const [ready, setReady] = useState(false);
const [colors, setColors] = useState<SceneColors | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const clipRef = useRef<HTMLDivElement>(null);
const lineRef = useRef<HTMLDivElement>(null);
const pointerRef = useRef({ x: 0, y: 0 });
const insideRef = useRef(false);
const targetPctRef = useRef(IDLE_CENTER);
// Gate: capable device, motion allowed, tab actually foregrounded.
useEffect(() => {
const capable =
window.matchMedia("(pointer: fine) and (min-width: 768px)").matches &&
window.matchMedia("(prefers-reduced-motion: no-preference)").matches;
if (!capable) return;
let raf = 0;
const tick = () => {
if (!document.hidden) {
setReady(true);
return;
}
raf = requestAnimationFrame(tick);
};
raf = requestAnimationFrame(tick);
return () => cancelAnimationFrame(raf);
}, []);
// Theme tokens → scene colors, re-derived when the theme flips.
useEffect(() => {
if (!ready) return;
const derive = () => setColors(deriveSceneColors());
derive();
const observer = new MutationObserver(derive);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ["data-theme"],
});
const mq = window.matchMedia("(prefers-color-scheme: dark)");
mq.addEventListener("change", derive);
return () => {
observer.disconnect();
mq.removeEventListener("change", derive);
};
}, [ready]);
// Scan-line driver: pointer position while hovered, slow sweep when idle.
useEffect(() => {
if (!ready || !colors) return;
const container = containerRef.current;
if (!container) return;
const onMove = (e: PointerEvent) => {
const rect = container.getBoundingClientRect();
const nx = (e.clientX - rect.left) / rect.width;
const ny = (e.clientY - rect.top) / rect.height;
insideRef.current = true;
pointerRef.current.x = nx * 2 - 1;
pointerRef.current.y = ny * 2 - 1;
targetPctRef.current =
POINTER_MIN + nx * (POINTER_MAX - POINTER_MIN);
};
const onLeave = () => {
insideRef.current = false;
pointerRef.current.x = 0;
pointerRef.current.y = 0;
};
container.addEventListener("pointermove", onMove);
container.addEventListener("pointerleave", onLeave);
let raf = 0;
let pct = IDLE_CENTER;
let last = performance.now();
const loop = (now: number) => {
const dt = Math.min((now - last) / 1000, 0.1);
last = now;
if (!insideRef.current) {
targetPctRef.current =
IDLE_CENTER + Math.sin(now / 1000 / 3.4) * IDLE_SWING;
}
pct += (targetPctRef.current - pct) * (1 - Math.exp(-5 * dt));
if (clipRef.current) {
clipRef.current.style.clipPath = `inset(0 ${100 - pct}% 0 0)`;
}
if (lineRef.current) {
lineRef.current.style.left = `${pct}%`;
}
raf = requestAnimationFrame(loop);
};
raf = requestAnimationFrame(loop);
return () => {
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerleave", onLeave);
cancelAnimationFrame(raf);
};
}, [ready, colors]);
const sceneActive = ready && colors !== null;
return (
<div ref={containerRef} className="absolute inset-0">
<LazyVideo
src={videoSrc}
label={videoLabel}
className="absolute inset-0 h-full w-full object-cover"
/>
{sceneActive && (
<CanvasBoundary>
{/* Blocking viewport, clipped to the left of the scan line. The
wrapper is opaque so the wireframe world replaces the footage
on its side rather than floating over it. */}
<div
ref={clipRef}
aria-hidden
className="absolute inset-0 bg-bg-sunken"
style={{ clipPath: "inset(0 50% 0 0)" }}
>
<HeroScene colors={colors} pointer={pointerRef} />
<p className="hud-label absolute bottom-3 left-3 bg-invert-bg px-3 py-1.5 font-bold text-invert-fg">
3D blocking — your control
</p>
</div>
{/* Scan line */}
<div
ref={lineRef}
aria-hidden
className="absolute top-0 bottom-0 w-px -translate-x-1/2 bg-accent"
style={{ left: "50%", boxShadow: "0 0 12px 1px var(--accent)" }}
/>
<p
aria-hidden
className="hud-label absolute right-3 bottom-3 bg-invert-bg px-3 py-1.5 font-bold text-invert-fg"
>
AI render — your shot
</p>
</CanvasBoundary>
)}
</div>
);
}
// WebGL is allowed to fail; the footage underneath is the fallback.
class CanvasBoundary extends Component<
{ children: ReactNode },
{ failed: boolean }
> {
state = { failed: false };
static getDerivedStateFromError() {
return { failed: true };
}
render() {
return this.state.failed ? null : this.props.children;
}
}
// Resolves theme tokens to concrete hex colors for the scene. Tokens with
// alpha (the hairline colors) are flattened against the viewport backdrop so
// line materials can stay opaque.
function deriveSceneColors(): SceneColors {
const styles = getComputedStyle(document.documentElement);
const probe = document.createElement("span");
probe.style.display = "none";
document.body.appendChild(probe);
const resolve = (token: string): [number, number, number, number] => {
probe.style.color = "";
probe.style.color = styles.getPropertyValue(token).trim();
const parsed = getComputedStyle(probe).color.match(/[\d.]+/g) ?? [];
const [r = 0, g = 0, b = 0, a = 1] = parsed.map(Number);
return [r, g, b, a];
};
const toHex = (r: number, g: number, b: number) =>
`#${[r, g, b]
.map((v) => Math.round(Math.max(0, Math.min(255, v))).toString(16).padStart(2, "0"))
.join("")}`;
const bg = resolve("--bg-sunken");
const flatten = ([r, g, b, a]: [number, number, number, number]) =>
toHex(r * a + bg[0] * (1 - a), g * a + bg[1] * (1 - a), b * a + bg[2] * (1 - a));
const result: SceneColors = {
line: flatten(resolve("--line")),
lineStrong: flatten(resolve("--line-strong")),
accent: flatten(resolve("--accent")),
ink: flatten(resolve("--ink")),
};
probe.remove();
return result;
}
@@ -0,0 +1,100 @@
import { AppleIcon, MonitorIcon } from "lucide-react";
import { GitHubIcon } from "@/components/icons";
import { SOCIAL_LINKS, WEBAPP_URL } from "@/lib/links";
import { HERO_VIDEO_URL } from "@/lib/landing-data";
import HeroViewport from "./hero-viewport";
import CtaLink from "./cta-link";
export default function Hero() {
return (
<section className="relative">
<div className="relative mx-auto max-w-[1280px] border-x border-line">
{/* HUD strip */}
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-3 md:px-10">
<p className="hud-label text-muted">
Open-source AI studio
</p>
<p className="hud-label text-accent-ink">
Now with Seedance 2.0, Nano Banana 2 &amp; more
</p>
</div>
{/* Headline */}
<div data-reveal-group className="px-6 pt-14 pb-10 md:px-10 md:pt-20">
<h1
data-reveal
className="font-display text-[clamp(3rem,8.5vw,7.5rem)] leading-[0.98] font-medium tracking-[-0.04em] text-ink-strong"
>
Controllable AI
<br />
<span className="font-serif italic font-normal tracking-[-0.01em] text-muted">
for artists.
</span>
</h1>
<div className="mt-10 flex flex-col gap-8 md:mt-14 md:flex-row md:items-end md:justify-between">
<p
data-reveal
className="max-w-md text-lg leading-relaxed text-muted"
>
Artists need and deserve unparalleled control and precision.
ArtCraft&rsquo;s got you covered — compose in real 3D, then
render with AI.
</p>
<div data-reveal className="flex flex-wrap items-center gap-3">
<CtaLink href="/download">
<AppleIcon aria-hidden className="h-4 w-4" />
<MonitorIcon aria-hidden className="h-4 w-4" />
Download free
</CtaLink>
<CtaLink href={WEBAPP_URL} variant="outline">
Use on web
</CtaLink>
</div>
</div>
<div
data-reveal
className="mt-8 flex flex-wrap items-center gap-x-6 gap-y-2"
>
<a
href={SOCIAL_LINKS.GITHUB}
target="_blank"
rel="noopener noreferrer"
className="hud-label flex items-center gap-1.5 text-faint hover:text-ink"
>
<GitHubIcon className="h-3.5 w-3.5" />
Open source on GitHub
</a>
<p className="hud-label text-faint">macOS · Windows · Web</p>
<p className="hud-label text-faint">No subscription required</p>
</div>
</div>
{/* Viewport frame — the product-demo stage. Currently the commercial
reel; the interactive 3D blocking→render demo mounts here. */}
<figure className="relative border-t border-line">
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2 md:px-10">
<figcaption className="hud-label text-faint">
Viewport — scene 01
</figcaption>
<p aria-hidden className="hud-label text-faint">
<span className="mr-1.5 inline-block h-1.5 w-1.5 bg-accent align-middle" />
Blocking ↔ render · move your cursor
</p>
</div>
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
<HeroViewport
videoSrc={HERO_VIDEO_URL}
videoLabel="ArtCraft product reel: composing 3D scenes and rendering them with AI"
/>
<span aria-hidden className="tick top-2 left-2 opacity-60" />
<span aria-hidden className="tick top-2 right-2 opacity-60" />
<span aria-hidden className="tick bottom-2 left-2 opacity-60" />
<span aria-hidden className="tick bottom-2 right-2 opacity-60" />
</div>
</figure>
</div>
</section>
);
}
@@ -0,0 +1,42 @@
import { MADE_WITH_YOUTUBE_IDS } from "@/lib/landing-data";
import LiteYouTube from "@/components/lite-youtube";
import { SectionShell, SectionEyebrow } from "./section-shell";
export default function MadeWith() {
return (
<SectionShell id="made-with">
<SectionEyebrow index="03" label="Proof" annotation="From the community" />
<div className="px-6 py-14 md:px-10 md:py-20">
<h2
data-reveal
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
>
Made <span className="font-serif italic font-normal">with</span>{" "}
ArtCraft.
</h2>
</div>
<div
data-reveal-group
className="grid gap-px border-t border-line bg-line md:grid-cols-3"
>
{MADE_WITH_YOUTUBE_IDS.map((id, i) => (
<figure key={id} data-reveal className="bg-bg">
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-2.5">
<figcaption className="hud-label text-muted">
Community film
</figcaption>
<p className="hud-label text-faint">
{String(i + 1).padStart(2, "0")}
</p>
</div>
<div className="relative aspect-video w-full overflow-hidden bg-bg-sunken">
<LiteYouTube videoId={id} title={`Made with ArtCraft #${i + 1}`} />
</div>
</figure>
))}
</div>
</SectionShell>
);
}
@@ -0,0 +1,67 @@
import { GitHubIcon } from "@/components/icons";
import { SOCIAL_LINKS } from "@/lib/links";
import { SectionShell, SectionEyebrow } from "./section-shell";
const PILLARS = [
{
index: "A",
title: "Open source",
body: "The whole studio is on GitHub. Read it, fork it, make it yours.",
href: SOCIAL_LINKS.GITHUB,
},
{
index: "B",
title: "Yours forever",
body: "ArtCraft is yours to own and keep, forever. No rent payments.",
},
{
index: "C",
title: "No middleman",
body: "No aggregator between you and the models. Your work stays yours.",
},
];
export default function Ownership() {
return (
<SectionShell id="ownership">
<SectionEyebrow index="02" label="Ownership" annotation="Free & open source" />
<div className="px-6 py-14 md:px-10 md:py-20">
<h2
data-reveal
className="max-w-3xl font-display text-4xl font-medium leading-[1.02] tracking-[-0.035em] text-ink-strong sm:text-5xl md:text-6xl"
>
Stop <span className="font-serif italic font-normal">renting</span>{" "}
from websites.
</h2>
<p data-reveal className="mt-5 max-w-xl text-lg leading-relaxed text-muted">
ArtCraft is yours to own and keep, forever. No subscriptions needed,
no aggregator middleman, no rent payments.
</p>
</div>
<div data-reveal-group className="grid gap-px border-t border-line bg-line md:grid-cols-3">
{PILLARS.map((pillar) => (
<article key={pillar.index} data-reveal className="bg-bg p-6 md:p-8">
<p className="hud-label text-faint">{pillar.index}</p>
<h3 className="mt-6 font-display text-2xl font-medium tracking-[-0.02em] text-ink-strong">
{pillar.title}
</h3>
<p className="mt-2 leading-relaxed text-muted">{pillar.body}</p>
{pillar.href && (
<a
href={pillar.href}
target="_blank"
rel="noopener noreferrer"
className="invert-block hud-label mt-6 inline-flex items-center gap-1.5 border border-line-strong px-3 py-1.5 text-ink hover:border-transparent"
>
<GitHubIcon className="h-3.5 w-3.5" />
View source
</a>
)}
</article>
))}
</div>
</SectionShell>
);
}
@@ -0,0 +1,57 @@
import type { ReactNode } from "react";
// Structural frame shared by every landing section: a full-bleed top rule,
// content constrained between two continuous side rails, and crosshair ticks
// straddling the rail intersections.
export function SectionShell({
id,
children,
className = "",
ticks = true,
}: {
id?: string;
children: ReactNode;
className?: string;
ticks?: boolean;
}) {
return (
<section id={id} className={`border-t border-line ${className}`}>
<div className="relative mx-auto max-w-[1280px] border-x border-line">
{ticks && (
<>
<span aria-hidden className="tick -top-[6px] -left-[6px]" />
<span aria-hidden className="tick -top-[6px] -right-[5px]" />
</>
)}
{children}
</div>
</section>
);
}
// Mono eyebrow row used at the top of a section: index + label on the left,
// optional annotation on the right.
export function SectionEyebrow({
index,
label,
annotation,
}: {
index: string;
label: string;
annotation?: string;
}) {
return (
<div className="flex items-center justify-between gap-4 border-b border-line px-6 py-3 md:px-10">
<p className="hud-label text-muted">
<span className="text-faint">{index}</span>
<span aria-hidden className="mx-2 text-faint">
/
</span>
{label}
</p>
{annotation && (
<p className="hud-label hidden text-faint sm:block">{annotation}</p>
)}
</div>
);
}
@@ -0,0 +1,56 @@
"use client";
import { useEffect, useRef } from "react";
// Muted looping footage that only downloads and plays while near the viewport.
// Reduced-motion visitors get a paused first frame (metadata only) instead of
// ambient motion.
export default function LazyVideo({
src,
className,
label,
}: {
src: string;
className?: string;
label?: string;
}) {
const ref = useRef<HTMLVideoElement>(null);
useEffect(() => {
const video = ref.current;
if (!video) return;
const reduced = window.matchMedia("(prefers-reduced-motion: reduce)")
.matches;
if (reduced) {
video.preload = "metadata";
return;
}
const io = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
video.play().catch(() => {});
} else {
video.pause();
}
},
{ rootMargin: "160px 0px" },
);
io.observe(video);
return () => io.disconnect();
}, []);
return (
<video
ref={ref}
src={src}
className={className}
muted
loop
playsInline
preload="none"
aria-label={label}
/>
);
}
@@ -0,0 +1,50 @@
"use client";
import { useState } from "react";
import { PlayIcon } from "lucide-react";
// Click-to-load YouTube embed: a thumbnail until the visitor asks for the
// player, so the landing never pays the iframe cost up front.
export default function LiteYouTube({
videoId,
title,
}: {
videoId: string;
title: string;
}) {
const [activated, setActivated] = useState(false);
if (activated) {
return (
<iframe
className="absolute inset-0 h-full w-full"
src={`https://www.youtube-nocookie.com/embed/${videoId}?autoplay=1`}
title={title}
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
allowFullScreen
/>
);
}
return (
<button
type="button"
onClick={() => setActivated(true)}
className="group absolute inset-0 h-full w-full"
aria-label={`Play video: ${title}`}
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={`https://i.ytimg.com/vi/${videoId}/hqdefault.jpg`}
alt=""
loading="lazy"
className="absolute inset-0 h-full w-full object-cover"
/>
<span className="absolute inset-0 bg-black/20 transition-colors group-hover:bg-black/10" />
<span className="hud-label absolute bottom-3 left-3 flex items-center gap-1.5 bg-invert-bg px-3 py-1.5 font-bold text-invert-fg">
<PlayIcon aria-hidden className="h-3.5 w-3.5" />
Play
</span>
</button>
);
}
@@ -0,0 +1,42 @@
"use client";
import { useEffect } from "react";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
// Site-wide motion runtime: Lenis smooth scroll driven by GSAP's ticker and
// kept in sync with ScrollTrigger. Reduced-motion visitors get native scroll
// and no scroll-linked animation registration at all — sections must render
// complete without motion.
export default function MotionProvider({
children,
}: {
children: React.ReactNode;
}) {
useEffect(() => {
const prefersReducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (prefersReducedMotion) return;
const lenis = new Lenis({
lerp: 0.12,
anchors: true,
});
lenis.on("scroll", ScrollTrigger.update);
const tick = (time: number) => lenis.raf(time * 1000);
gsap.ticker.add(tick);
gsap.ticker.lagSmoothing(0);
return () => {
gsap.ticker.remove(tick);
lenis.destroy();
};
}, []);
return <>{children}</>;
}
@@ -0,0 +1,66 @@
"use client";
import { useEffect } from "react";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const HIDDEN = { autoAlpha: 0, y: 28, filter: "blur(8px)" } as const;
// clearProps on settle: a lingering `filter: blur(0px)` keeps the element on a
// composited layer, which disables subpixel text antialiasing on Windows.
const SHOWN = {
autoAlpha: 1,
y: 0,
filter: "blur(0px)",
duration: 0.9,
ease: "power3.out",
overwrite: true,
clearProps: "all",
} as const;
// Progressive-enhancement scroll reveals for `[data-reveal]` elements.
//
// Elements render fully visible in the server HTML (crawlers and no-JS
// visitors see everything). Only once JS runs — and only for visitors without
// a reduced-motion preference — do we hide them and reveal on scroll.
// `data-reveal-group` on a container staggers its `[data-reveal]` children.
export default function RevealManager() {
useEffect(() => {
const mm = gsap.matchMedia();
mm.add("(prefers-reduced-motion: no-preference)", () => {
gsap.utils
.toArray<HTMLElement>(
"[data-reveal]:not([data-reveal-group] [data-reveal])",
)
.forEach((el) => {
gsap.set(el, HIDDEN);
ScrollTrigger.create({
trigger: el,
start: "top 85%",
once: true,
onEnter: () => gsap.to(el, SHOWN),
});
});
gsap.utils.toArray<HTMLElement>("[data-reveal-group]").forEach((group) => {
const children = Array.from(
group.querySelectorAll<HTMLElement>("[data-reveal]"),
);
if (!children.length) return;
gsap.set(children, HIDDEN);
ScrollTrigger.create({
trigger: group,
start: "top 85%",
once: true,
onEnter: () => gsap.to(children, { ...SHOWN, stagger: 0.08 }),
});
});
});
return () => mm.revert();
}, []);
return null;
}
@@ -0,0 +1,101 @@
import Link from "next/link";
import { SOCIAL_LINKS, SUPPORT_EMAIL, WEBAPP_URL } from "@/lib/links";
const LINK_COLUMNS: {
heading: string;
links: { name: string; href: string; external?: boolean }[];
}[] = [
{
heading: "Product",
links: [
{ name: "Download", href: "/download" },
{ name: "Pricing", href: "/pricing" },
{ name: "Launch App", href: WEBAPP_URL, external: true },
],
},
{
heading: "Resources",
links: [
{ name: "Tutorials", href: "/tutorials" },
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
{ name: "Press Kit", href: "/press-kit" },
{ name: "Support", href: "/support" },
],
},
{
heading: "Community",
links: [
{ name: "Discord", href: SOCIAL_LINKS.DISCORD, external: true },
{ name: "GitHub", href: SOCIAL_LINKS.GITHUB, external: true },
{ name: "YouTube", href: SOCIAL_LINKS.YOUTUBE, external: true },
{ name: "Instagram", href: SOCIAL_LINKS.INSTAGRAM, external: true },
{ name: "TikTok", href: SOCIAL_LINKS.TIKTOK, external: true },
],
},
];
export default function SiteFooter() {
return (
<footer className="border-t border-line">
<div className="mx-auto max-w-[1280px] border-x border-line">
<div className="grid gap-px bg-line md:grid-cols-[2fr_1fr_1fr_1fr]">
<div className="bg-bg p-6 md:p-10">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="ArtCraft"
src="/artcraft-icon.svg"
className="themed-logo h-8 w-auto"
/>
<p className="mt-4 max-w-xs leading-relaxed text-muted">
The open-source studio for controllable AI image and video.
</p>
<a
href={`mailto:${SUPPORT_EMAIL}`}
className="hud-label mt-6 inline-block text-faint hover:text-ink"
>
{SUPPORT_EMAIL}
</a>
</div>
{LINK_COLUMNS.map((column) => (
<nav
key={column.heading}
aria-label={column.heading}
className="bg-bg p-6 md:p-8"
>
<p className="hud-label text-faint">{column.heading}</p>
<ul className="mt-4 flex flex-col gap-2.5">
{column.links.map((link) => (
<li key={link.name}>
{link.external ? (
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="text-muted hover:text-ink"
>
{link.name}
</a>
) : (
<Link href={link.href} className="text-muted hover:text-ink">
{link.name}
</Link>
)}
</li>
))}
</ul>
</nav>
))}
</div>
<div className="flex flex-col gap-2 border-t border-line px-6 py-4 md:flex-row md:items-center md:justify-between md:px-10">
<p className="hud-label text-faint">
© {new Date().getFullYear()} ArtCraft
</p>
<p className="hud-label text-faint">Made by artists, for artists</p>
</div>
</div>
</footer>
);
}
@@ -0,0 +1,335 @@
"use client";
import { useState } from "react";
import Link from "next/link";
import { usePathname } from "next/navigation";
import * as NavigationMenu from "@radix-ui/react-navigation-menu";
import { ChevronDownIcon, MenuIcon, XIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import { SOCIAL_LINKS, WEBAPP_URL, webappUrl } from "@/lib/links";
import ThemeToggle from "./theme-toggle";
type NavLeaf = { name: string; href: string };
type NavGroup = { name: string; href?: string; children: NavLeaf[] };
type NavEntry = NavLeaf | NavGroup;
const NAV_ITEMS: NavEntry[] = [
{ name: "Home", href: "/" },
{ name: "Image", href: webappUrl("/create-image") },
{ name: "Video", href: webappUrl("/create-video") },
{
name: "Resources",
children: [
{ name: "Tutorials", href: "/tutorials" },
{ name: "News", href: "/news" },
{ name: "FAQ", href: "/faq" },
{ name: "Press Kit", href: "/press-kit" },
],
},
{ name: "Download", href: "/download" },
{ name: "Support", href: "/support" },
];
// Marathon-style: the inversion highlight sits on the label only, not the cell.
const NAV_LINK_CLASSES =
"group flex h-full items-center px-2.5 hud-label whitespace-nowrap";
const NAV_LABEL_CLASSES =
"flex items-center gap-1.5 px-1.5 py-0.5 group-hover:bg-invert-bg group-hover:text-invert-fg";
const NAV_LABEL_ACTIVE_CLASSES = "bg-invert-bg text-invert-fg";
const DROPDOWN_ITEM_CLASSES =
"group flex px-3 py-2.5 hud-label whitespace-nowrap";
const SOLID_CTA_CLASSES =
"flex h-full items-center border-l border-line bg-invert-bg px-5 hud-label font-bold text-invert-fg hover:opacity-80";
const isExternalHref = (href: string) => /^https?:\/\//.test(href);
function isGroup(entry: NavEntry): entry is NavGroup {
return "children" in entry && Array.isArray(entry.children);
}
function isPathActive(pathname: string, href: string): boolean {
if (isExternalHref(href)) return false;
if (href === "/") return pathname === "/";
return pathname === href || pathname.startsWith(href + "/");
}
function isEntryActive(pathname: string, entry: NavEntry): boolean {
if (isGroup(entry)) {
if (entry.href && isPathActive(pathname, entry.href)) return true;
return entry.children.some((c) => isPathActive(pathname, c.href));
}
return isPathActive(pathname, entry.href);
}
export default function SiteNav() {
const pathname = usePathname();
const [mobileOpen, setMobileOpen] = useState(false);
return (
<nav className="sticky top-0 left-0 z-50 w-full border-b border-line bg-bg/95 backdrop-blur-sm">
<div className="flex h-12 items-stretch justify-between">
{/* Left: logo + nav cells */}
<div className="flex min-w-0 items-stretch">
<Link
href="/"
className="flex items-center border-r border-line px-4 hover:opacity-70 sm:px-5"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
alt="ArtCraft"
src="/artcraft-icon.svg"
className="themed-logo h-6 w-auto"
/>
</Link>
<NavigationMenu.Root
delayDuration={120}
className="hidden min-w-0 items-stretch lg:flex"
>
<NavigationMenu.List className="flex h-full items-stretch">
{NAV_ITEMS.map((entry) => {
const active = isEntryActive(pathname, entry);
const linkClasses = twMerge(
NAV_LINK_CLASSES,
active ? "text-ink" : "text-muted hover:text-ink",
);
const labelClasses = twMerge(
NAV_LABEL_CLASSES,
active && NAV_LABEL_ACTIVE_CLASSES,
);
if (!isGroup(entry)) {
return (
<NavigationMenu.Item
key={entry.name}
className="flex items-stretch"
>
<NavigationMenu.Link asChild>
{isExternalHref(entry.href) ? (
<a href={entry.href} className={linkClasses}>
<span className={labelClasses}>{entry.name}</span>
</a>
) : (
<Link
href={entry.href}
aria-current={active ? "page" : undefined}
className={linkClasses}
>
<span className={labelClasses}>{entry.name}</span>
</Link>
)}
</NavigationMenu.Link>
</NavigationMenu.Item>
);
}
return (
<NavigationMenu.Item
key={entry.name}
className="relative flex items-stretch"
>
<NavigationMenu.Trigger
className={twMerge(linkClasses, "focus:outline-none")}
>
<span className={labelClasses}>
{entry.name}
<ChevronDownIcon
aria-hidden
className="h-3 w-3 group-data-[state=open]:rotate-180"
/>
</span>
</NavigationMenu.Trigger>
<NavigationMenu.Content className="absolute top-full left-0 border border-t-0 border-line bg-bg">
<ul className="flex min-w-[200px] flex-col">
{entry.children.map((child) => {
const childActive = isPathActive(
pathname,
child.href,
);
return (
<li key={child.name}>
<NavigationMenu.Link asChild>
<Link
href={child.href}
aria-current={
childActive ? "page" : undefined
}
className={twMerge(
DROPDOWN_ITEM_CLASSES,
childActive
? "text-ink"
: "text-muted hover:text-ink",
)}
>
<span
className={twMerge(
NAV_LABEL_CLASSES,
childActive && NAV_LABEL_ACTIVE_CLASSES,
)}
>
{child.name}
</span>
</Link>
</NavigationMenu.Link>
</li>
);
})}
</ul>
</NavigationMenu.Content>
</NavigationMenu.Item>
);
})}
</NavigationMenu.List>
</NavigationMenu.Root>
</div>
{/* Right: pricing, theme, launch app */}
<div className="flex shrink-0 items-stretch">
<div className="hidden items-stretch md:flex">
<Link
href="/pricing"
className={twMerge(NAV_LINK_CLASSES, "text-muted hover:text-ink")}
>
<span className={NAV_LABEL_CLASSES}>Pricing</span>
</Link>
<ThemeToggle className="w-12 border-l border-line" />
<a href={WEBAPP_URL} className={SOLID_CTA_CLASSES}>
Launch App
</a>
</div>
{/* Mobile: theme + hamburger */}
<div className="flex items-stretch md:hidden">
<ThemeToggle className="w-12 border-l border-line" />
</div>
<div className="flex items-stretch lg:hidden">
<button
type="button"
onClick={() => setMobileOpen((v) => !v)}
aria-expanded={mobileOpen}
aria-controls="mobile-nav"
className="flex w-12 items-center justify-center border-l border-line text-muted hover:bg-invert-bg hover:text-invert-fg"
>
<span className="sr-only">
{mobileOpen ? "Close main menu" : "Open main menu"}
</span>
{mobileOpen ? (
<XIcon aria-hidden className="h-5 w-5" />
) : (
<MenuIcon aria-hidden className="h-5 w-5" />
)}
</button>
</div>
</div>
</div>
{/* Mobile slide-down panel */}
{mobileOpen && (
<div id="mobile-nav" className="border-t border-line lg:hidden">
<div className="flex flex-col">
{NAV_ITEMS.map((entry) => {
if (!isGroup(entry)) {
const isCurrent = isPathActive(pathname, entry.href);
const leafClassName = twMerge(
DROPDOWN_ITEM_CLASSES,
"py-3",
isCurrent ? "text-ink" : "text-muted active:text-ink",
);
const leafLabel = (
<span
className={twMerge(
"flex items-center px-1.5 py-0.5",
isCurrent && NAV_LABEL_ACTIVE_CLASSES,
)}
>
{entry.name}
</span>
);
return isExternalHref(entry.href) ? (
<a
key={entry.name}
href={entry.href}
className={leafClassName}
>
{leafLabel}
</a>
) : (
<Link
key={entry.name}
href={entry.href}
onClick={() => setMobileOpen(false)}
className={leafClassName}
>
{leafLabel}
</Link>
);
}
return (
<div key={entry.name} className="flex flex-col">
<div className="hud-label px-4 pt-3 pb-1 text-[10px] font-semibold tracking-[0.25em] text-faint">
{entry.name}
</div>
<div className="flex flex-col">
{entry.children.map((child) => {
const childActive = isPathActive(pathname, child.href);
return (
<Link
key={child.name}
href={child.href}
onClick={() => setMobileOpen(false)}
className={twMerge(
DROPDOWN_ITEM_CLASSES,
"py-3 pl-8",
childActive
? "text-ink"
: "text-muted active:text-ink",
)}
>
<span
className={twMerge(
"flex items-center px-1.5 py-0.5",
childActive && NAV_LABEL_ACTIVE_CLASSES,
)}
>
{child.name}
</span>
</Link>
);
})}
</div>
</div>
);
})}
</div>
<div className="border-t border-line" />
<a
href={WEBAPP_URL}
className="flex h-11 items-center justify-center bg-invert-bg px-4 hud-label font-bold text-invert-fg active:opacity-80"
>
Launch App
</a>
<div className="flex items-stretch border-t border-line">
<Link
href="/pricing"
onClick={() => setMobileOpen(false)}
className="hud-label flex h-10 flex-1 items-center justify-center gap-1.5 border-r border-line text-[11px] text-muted active:bg-invert-bg active:text-invert-fg"
>
Pricing
</Link>
<a
href={SOCIAL_LINKS.DISCORD}
target="_blank"
rel="noopener noreferrer"
className="hud-label flex h-10 flex-1 items-center justify-center gap-1.5 text-[11px] text-muted active:bg-invert-bg active:text-invert-fg"
>
Discord
</a>
</div>
</div>
)}
</nav>
);
}
@@ -0,0 +1,57 @@
"use client";
import { useEffect, useState } from "react";
import { MoonIcon, SunIcon } from "lucide-react";
const STORAGE_KEY = "artcraft-theme";
type Theme = "light" | "dark";
function resolveCurrentTheme(): Theme {
const explicit = document.documentElement.getAttribute("data-theme");
if (explicit === "light" || explicit === "dark") return explicit;
return window.matchMedia("(prefers-color-scheme: dark)").matches
? "dark"
: "light";
}
// Cell-styled theme switch. Follows the system preference until the visitor
// makes an explicit choice, which is persisted and applied pre-paint by the
// inline script in the root layout.
export default function ThemeToggle({ className }: { className?: string }) {
// Render a stable placeholder until mounted — the real theme is only
// knowable on the client.
const [theme, setTheme] = useState<Theme | null>(null);
useEffect(() => {
setTheme(resolveCurrentTheme());
}, []);
const toggle = () => {
const next: Theme = resolveCurrentTheme() === "dark" ? "light" : "dark";
document.documentElement.setAttribute("data-theme", next);
try {
localStorage.setItem(STORAGE_KEY, next);
} catch {
// Private-mode storage failures just lose persistence, not the toggle.
}
setTheme(next);
};
return (
<button
type="button"
onClick={toggle}
aria-label={
theme === "dark" ? "Switch to light theme" : "Switch to dark theme"
}
className={`invert-block flex items-center justify-center text-muted hover:text-invert-fg ${className ?? ""}`}
>
{theme === "dark" ? (
<SunIcon aria-hidden className="h-4 w-4" />
) : (
<MoonIcon aria-hidden className="h-4 w-4" />
)}
</button>
);
}
@@ -0,0 +1,91 @@
import { mediaUrl } from "./links";
// Product feature roster, ported from the shipping landing page. Copy is the
// approved marketing copy — edit deliberately, not casually.
export type Feature = {
index: string;
label: string;
title: string;
description: string;
video: string;
};
export const FEATURES: Feature[] = [
{
index: "01",
label: "Worlds",
title: "Image to Location",
description:
"Place virtual actors into physical environments. Establish single-location consistency and film multiple shots in a room without things disappearing.",
video: mediaUrl("/videos/features/WorldLabs_Demo_2.webm"),
},
{
index: "02",
label: "3D Compositing",
title: "Build scenes with depth",
description:
"Use images, backdrops, foreground elements, and props in scenes with real depth. A couple of images blends naturally into a finished composition.",
video: mediaUrl("/videos/features/Panel.webm"),
},
{
index: "03",
label: "2D Compositing",
title: "Precise layered control",
description:
"Combine images, background removal, layers, and simple drawing tools to compose a scene exactly the way you imagined it.",
video: mediaUrl("/videos/features/Editor.webm"),
},
{
index: "04",
label: "3D Mesh",
title: "Image to 3D Mesh",
description:
"Turning images into 3D helps position elements exactingly. Block complex scenes with intentional geometry instead of fighting prompts.",
video: mediaUrl("/videos/features/Make_3D.webm"),
},
{
index: "05",
label: "Mixed Assets",
title: "Mix every kind of asset",
description:
"Combine image cutouts, worlds, and 3D meshes in one canvas to lay out scenes with precision and intention.",
video: mediaUrl("/videos/features/Mixed.webm"),
},
{
index: "06",
label: "Posing",
title: "Character Posing",
description:
'Dynamically pose your characters to nail the precise character, scene, and camera blocking before calling "action".',
video: mediaUrl("/videos/features/Character-Pose.webm"),
},
{
index: "07",
label: "Cutouts",
title: "Background Removal",
description:
"Instantly remove backgrounds from images to create assets for your scenes. Clean, precise, and ready for compositing.",
video: mediaUrl("/videos/features/Background.webm"),
},
];
export const HERO_VIDEO_URL =
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/website-commercial-2026.05.mp4";
export const MADE_WITH_YOUTUBE_IDS = [
"HDdsKJl92H4",
"oqoCWdOwr2U",
"H4NFXGMuwpY",
];
export const TICKER_ITEMS = [
"Seedance 2.0",
"Nano Banana 2",
"Image to Location",
"3D Compositing",
"Character Posing",
"Image to 3D Mesh",
"Background Removal",
"2D Compositing",
"Mixed Assets",
];
@@ -0,0 +1,31 @@
// Cross-app link configuration, ported from the Vite site's config/links.ts.
// The marketing site links out to the webapp for all product features.
export const WEBAPP_URL =
process.env.NEXT_PUBLIC_WEBAPP_URL ?? "https://app.getartcraft.com/";
// Large media (feature footage, hero video) is served from the currently
// deployed site rather than duplicated into this app's public/ directory.
// Point this at "" once the assets migrate here.
export const MEDIA_BASE =
process.env.NEXT_PUBLIC_MEDIA_BASE ?? "https://getartcraft.com";
export function webappUrl(path: string): string {
return `${WEBAPP_URL}${path.replace(/^\//, "")}`;
}
export function mediaUrl(path: string): string {
return `${MEDIA_BASE}${path.startsWith("/") ? path : `/${path}`}`;
}
export const SOCIAL_LINKS = {
DISCORD: "https://discord.gg/artcraft",
YOUTUBE: "https://www.youtube.com/@OfficialArtCraftStudios",
TIKTOK: "https://www.tiktok.com/@artcraft.studios",
GITHUB: "https://github.com/storytold/artcraft",
INSTAGRAM: "https://www.instagram.com/get_artcraft",
LINKEDIN: "https://www.linkedin.com/company/artcraft-ai",
REDDIT: "https://www.reddit.com/r/ArtCraftAI/",
} as const;
export const SUPPORT_EMAIL = "hello@storyteller.ai";
@@ -0,0 +1,41 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"jsx": "react-jsx",
"incremental": true,
"plugins": [
{
"name": "next"
}
],
"paths": {
"@/*": [
"./src/*"
]
}
},
"include": [
"next-env.d.ts",
"**/*.ts",
"**/*.tsx",
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules"
]
}
+3 -1
View File
@@ -116,6 +116,8 @@
"apps/**/*",
"libs/**/*",
"!apps/artcraft-dashboard",
"!apps/artcraft-dashboard/**"
"!apps/artcraft-dashboard/**",
"!apps/artcraft-website-next",
"!apps/artcraft-website-next/**"
]
}