mirror of
https://github.com/storytold/artcraft.git
synced 2026-11-03 08:57:54 +00:00
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:
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;
|
||||
+2803
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 & 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’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"
|
||||
]
|
||||
}
|
||||
@@ -116,6 +116,8 @@
|
||||
"apps/**/*",
|
||||
"libs/**/*",
|
||||
"!apps/artcraft-dashboard",
|
||||
"!apps/artcraft-dashboard/**"
|
||||
"!apps/artcraft-dashboard/**",
|
||||
"!apps/artcraft-website-next",
|
||||
"!apps/artcraft-website-next/**"
|
||||
]
|
||||
}
|
||||
Reference in new issue
Block a user