Compare commits

...
Author SHA1 Message Date
BFlat 35fdd11020 landing restyle version 1 2026-07-09 09:38:40 +07:00
19 changed files with 1677 additions and 1 deletions
@@ -37,6 +37,11 @@
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&family=Instrument+Serif:ital@0;1&display=swap"
rel="stylesheet"
/>
<!-- Landing4 (brutalist landing) type system -->
<link
href="https://fonts.googleapis.com/css2?family=Bricolage+Grotesque:opsz,wght@12..96,200..800&family=Inter:opsz,wght@14..32,100..900&family=Space+Mono:ital,wght@0,400;0,700;1,400&display=swap"
rel="stylesheet"
/>
</head>
<body>
<div id="root"></div>
@@ -17,6 +17,7 @@ import CreateVideo from "../pages/create-video";
import CreateVFX from "../pages/create-vfx";
//import Landing2 from "../pages/landing2";
import Landing3 from "../pages/landing3";
import Landing4 from "../pages/landing4";
import LandingSD2 from "../pages/landing-sd2";
import LandingSD25 from "../pages/landing-sd25";
import TutorialsPage from "../pages/tutorials";
@@ -59,14 +60,19 @@ function appOrWebapp(localElement: ReactNode, webappPath: string): ReactNode {
}
export function App() {
// Landing4 renders its own scoped navbar/footer; hide the global chrome.
const { pathname } = useLocation();
const hideGlobalChrome = pathname === "/landing4";
return (
<div className="relative">
<ScrollToTop />
<Navbar />
{!hideGlobalChrome && <Navbar />}
<Routes>
<Route path="/" element={<Landing3 />} />
<Route path="/landing3" element={<Landing3 />} />
<Route path="/landing4" element={<Landing4 />} />
<Route path="/seedance-2" element={<LandingSD2 />} />
<Route path="/seedance2-5" element={<LandingSD25 />} />
<Route path="/download" element={<Download />} />
@@ -0,0 +1,104 @@
import { Link } from "react-router-dom";
import { SOCIAL_LINKS, SUPPORT_EMAIL } from "../../../config/links";
import { MonoLabel } from "./ui";
const SITEMAP: ReadonlyArray<{ label: string; to: string }> = [
{ label: "Home", to: "/" },
{ label: "Download", to: "/download" },
{ label: "Pricing", to: "/pricing" },
{ label: "Tutorials", to: "/tutorials" },
{ label: "FAQ", to: "/faq" },
{ label: "News", to: "/news" },
{ label: "Press Kit", to: "/press-kit" },
{ label: "Support", to: "/support" },
];
const SOCIALS: ReadonlyArray<{ label: string; href: string }> = [
{ label: "GitHub", href: SOCIAL_LINKS.GITHUB },
{ label: "Discord", href: SOCIAL_LINKS.DISCORD },
{ label: "YouTube", href: SOCIAL_LINKS.YOUTUBE },
{ label: "Instagram", href: SOCIAL_LINKS.INSTAGRAM },
{ label: "TikTok", href: SOCIAL_LINKS.TIKTOK },
{ label: "Reddit", href: SOCIAL_LINKS.REDDIT },
];
// Landing4-only footer. Dark chapter: mono column grid over a hairline, then
// a giant clipped wordmark bleeding off the bottom edge.
export const BrutFooter = () => (
<footer className="border-t border-paper/15 bg-ink text-paper">
<div className="mx-auto w-full max-w-[1600px] px-4 sm:px-8">
<div className="grid grid-cols-2 gap-10 border-b border-paper/15 py-14 md:grid-cols-4">
<div>
<MonoLabel className="text-paper/40">SITEMAP</MonoLabel>
<ul className="mt-5 space-y-2">
{SITEMAP.map((item) => (
<li key={item.to}>
<Link
to={item.to}
className="hover:text-volt font-brut-mono text-[13px] uppercase tracking-[0.1em] transition-colors"
>
{item.label}
</Link>
</li>
))}
</ul>
</div>
<div>
<MonoLabel className="text-paper/40">SOCIAL</MonoLabel>
<ul className="mt-5 space-y-2">
{SOCIALS.map((item) => (
<li key={item.href}>
<a
href={item.href}
target="_blank"
rel="noreferrer"
className="hover:text-volt font-brut-mono text-[13px] uppercase tracking-[0.1em] transition-colors"
>
{item.label} ↗
</a>
</li>
))}
</ul>
</div>
<div>
<MonoLabel className="text-paper/40">CONTACT</MonoLabel>
<ul className="mt-5 space-y-2">
<li>
<a
href={`mailto:${SUPPORT_EMAIL}`}
className="hover:text-volt font-brut-mono text-[13px] uppercase tracking-[0.1em] transition-colors"
>
{SUPPORT_EMAIL}
</a>
</li>
</ul>
</div>
<div className="flex flex-col gap-2">
<MonoLabel className="text-paper/40">COLOPHON</MonoLabel>
<MonoLabel className="mt-5 block text-paper/60">
SET IN BRICOLAGE GROTESQUE
</MonoLabel>
<MonoLabel className="block text-paper/60">
+ SPACE MONO — {new Date().getFullYear()}
</MonoLabel>
</div>
</div>
<div className="flex items-center justify-between py-4">
<MonoLabel className="text-paper/40">
© {new Date().getFullYear()} ArtCraft. All rights reserved.
</MonoLabel>
<MonoLabel className="text-paper/40">MADE BY ARTISTS</MonoLabel>
</div>
</div>
{/* Giant clipped wordmark */}
<div aria-hidden className="overflow-hidden">
<div className="mx-auto w-full max-w-[1600px] px-4 sm:px-8">
<div className="font-brut-display -mb-[0.34em] select-none text-[18vw] font-bold uppercase leading-none tracking-[-0.03em] text-paper/90">
ArtCraft<span className="text-volt">®</span>
</div>
</div>
</div>
</footer>
);
@@ -0,0 +1,141 @@
import { useEffect, useState } from "react";
import { Link } from "react-router-dom";
import { SOCIAL_LINKS } from "../../../config/links";
import { MonoLabel } from "./ui";
const NAV_LINKS: ReadonlyArray<{
label: string;
index: string;
to?: string;
href?: string;
}> = [
{ label: "PRICING", index: "01", to: "/pricing" },
{ label: "TUTORIALS", index: "02", to: "/tutorials" },
{ label: "NEWS", index: "03", to: "/news" },
{ label: "GITHUB ↗", index: "04", href: SOCIAL_LINKS.GITHUB },
];
// Landing4-only fixed navbar. Chapter-aware: text and hairlines read the
// --l4-* vars so the bar recolors as the page scrubs between ink and paper.
// A backdrop tinted with the current --l4-bg keeps links legible over content.
// Mobile: a full-screen ink overlay with numbered index-style rows.
export const BrutNavbar = ({
onDownloadClick,
}: {
onDownloadClick: () => void;
}) => {
const [menuOpen, setMenuOpen] = useState(false);
// Lock page scroll behind the full-screen menu.
useEffect(() => {
if (!menuOpen) return;
const previous = document.body.style.overflow;
document.body.style.overflow = "hidden";
return () => {
document.body.style.overflow = previous;
};
}, [menuOpen]);
return (
<>
<header
className="l4-line fixed inset-x-0 top-0 z-50 border-b"
style={{ backgroundColor: "var(--l4-bg)", color: "var(--l4-ink)" }}
>
<div className="mx-auto flex h-14 w-full max-w-[1600px] items-center justify-between px-4 sm:px-8">
<Link
to="/landing4"
className="font-brut-display text-lg font-bold uppercase tracking-tight"
>
ArtCraft<span className="align-super text-[10px]">®</span>
</Link>
<nav className="hidden items-center gap-8 lg:flex">
{NAV_LINKS.map((link) =>
link.to ? (
<Link key={link.label} to={link.to} className="group">
<MonoLabel className="group-hover:text-flare transition-colors">
{link.label}{" "}
<span className="l4-muted">/{link.index}</span>
</MonoLabel>
</Link>
) : (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noreferrer"
className="group"
>
<MonoLabel className="group-hover:text-flare transition-colors">
{link.label}{" "}
<span className="l4-muted">/{link.index}</span>
</MonoLabel>
</a>
),
)}
</nav>
<div className="flex items-center gap-3">
<button
onClick={onDownloadClick}
className="bg-flare text-ink hover:bg-paper flex h-9 items-center px-5 font-brut-mono text-[12px] font-bold uppercase tracking-[0.14em] transition-colors"
>
Download
</button>
<button
onClick={() => setMenuOpen((open) => !open)}
aria-expanded={menuOpen}
aria-label="Toggle navigation index"
className="l4-line flex h-9 items-center border px-4 font-brut-mono text-[12px] uppercase tracking-[0.14em] lg:hidden"
>
{menuOpen ? "Close" : "Index"}
</button>
</div>
</div>
</header>
{/* Mobile full-screen index */}
{menuOpen && (
<div className="bg-ink text-paper fixed inset-0 z-40 flex flex-col justify-center px-6 pt-14 lg:hidden">
<nav className="border-t border-paper/15">
{NAV_LINKS.map((link) => {
const row = (
<span className="flex items-baseline justify-between py-5">
<span className="font-brut-display text-4xl font-bold uppercase">
{link.label}
</span>
<MonoLabel className="text-paper/40">/{link.index}</MonoLabel>
</span>
);
return link.to ? (
<Link
key={link.label}
to={link.to}
onClick={() => setMenuOpen(false)}
className="block border-b border-paper/15"
>
{row}
</Link>
) : (
<a
key={link.label}
href={link.href}
target="_blank"
rel="noreferrer"
onClick={() => setMenuOpen(false)}
className="block border-b border-paper/15"
>
{row}
</a>
);
})}
</nav>
<MonoLabel className="mt-10 text-paper/40">
ARTCRAFT — IMG / VID / 3D — OPEN SOURCE
</MonoLabel>
</div>
)}
</>
);
};
@@ -0,0 +1,58 @@
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faDiscord, faGithub } from "@fortawesome/free-brands-svg-icons";
import { SOCIAL_LINKS } from "../../../config/links";
import { LineMaskHeading, MonoLabel, SectionHeader } from "./ui";
// /07 — Community. Back to the dark chapter (the boundary sentinel before
// this section scrubs the root vars). Two 50/50 hairline cells; the whole
// cell is the anchor and inverts to flare on hover.
export const SectionCommunity = () => (
<>
{/* Chapter boundary: paper → dark */}
<div data-l4-boundary="dark" aria-hidden className="h-[40vh]" />
<section className="mx-auto w-full max-w-[1600px] px-4 py-24 sm:px-8 lg:py-32">
<SectionHeader number="07" label="COMMUNITY" right="OPEN SOURCE" />
<div className="py-10 lg:py-14">
<LineMaskHeading
className="text-[clamp(2.8rem,8vw,8.5rem)]"
lines={[<>BUILT IN</>, <>THE OPEN.</>]}
/>
</div>
<div className="l4-line grid grid-cols-1 border sm:grid-cols-2">
<a
href={SOCIAL_LINKS.GITHUB}
target="_blank"
rel="noreferrer"
className="l4-line hover:bg-flare hover:text-ink group flex flex-col gap-10 border-b p-8 transition-colors sm:border-b-0 sm:border-r sm:p-12"
>
<FontAwesomeIcon icon={faGithub} className="text-4xl" />
<div>
<span className="font-brut-display text-3xl font-bold uppercase sm:text-4xl">
GitHub <span aria-hidden>↗</span>
</span>
<MonoLabel className="l4-muted mt-3 block group-hover:text-ink/70">
STAR / FORK / CONTRIBUTE
</MonoLabel>
</div>
</a>
<a
href={SOCIAL_LINKS.DISCORD}
target="_blank"
rel="noreferrer"
className="hover:bg-flare hover:text-ink group flex flex-col gap-10 p-8 transition-colors sm:p-12"
>
<FontAwesomeIcon icon={faDiscord} className="text-4xl" />
<div>
<span className="font-brut-display text-3xl font-bold uppercase sm:text-4xl">
Discord <span aria-hidden>↗</span>
</span>
<MonoLabel className="l4-muted mt-3 block group-hover:text-ink/70">
SHARE / LEARN / SHIP
</MonoLabel>
</div>
</a>
</div>
</section>
</>
);
@@ -0,0 +1,127 @@
import { useEffect, useRef, useState } from "react";
import { FEATURES } from "../data";
import {
HairlineFrame,
LazyAutoplayVideo,
LineMaskHeading,
MonoLabel,
SectionHeader,
} from "./ui";
// /03 — Features. Paper chapter. Desktop: an index list on the left with a
// sticky hairline-framed preview on the right; an IntersectionObserver with a
// center-band root margin marks the row crossing mid-viewport as active,
// which drives the preview video swap. Mobile: each row embeds its own video
// and the observer never activates (rows are hidden from it via lg gating in
// markup, and the sticky preview is display:none).
export const SectionFeatures = () => {
const [activeIndex, setActiveIndex] = useState(0);
const listRef = useRef<HTMLOListElement>(null);
const active = FEATURES[activeIndex];
useEffect(() => {
const list = listRef.current;
if (!list) return;
const rows = Array.from(
list.querySelectorAll<HTMLElement>("[data-l4-feature-row]"),
);
// Center band: a row becomes active while it overlaps the middle 20% of
// the viewport. Cheap, and unlike per-row ScrollTriggers it needs no
// cleanup coordination with the GSAP context.
const observer = new IntersectionObserver(
(entries) => {
for (const entry of entries) {
if (entry.isIntersecting) {
const index = Number(
(entry.target as HTMLElement).dataset.l4FeatureRow,
);
if (!Number.isNaN(index)) setActiveIndex(index);
}
}
},
{ rootMargin: "-40% 0px -40% 0px" },
);
rows.forEach((row) => observer.observe(row));
return () => observer.disconnect();
}, []);
return (
<section
data-l4-features
className="mx-auto w-full max-w-[1600px] px-4 py-24 sm:px-8 lg:py-32"
>
<SectionHeader number="03" label="FEATURES" right="07 TOOLS" />
<div className="py-10 lg:py-14">
<LineMaskHeading
className="text-[clamp(2.8rem,8vw,8.5rem)]"
lines={[<>THE TOOLKIT</>]}
/>
</div>
<div className="l4-line grid grid-cols-1 border-t lg:grid-cols-12">
{/* Index list */}
<ol ref={listRef} className="l4-line lg:col-span-7 lg:border-r">
{FEATURES.map((feature, i) => (
<li
key={feature.title}
data-l4-feature-row={i}
data-l4-reveal
className={`l4-line border-b lg:pr-8 ${
i === activeIndex ? "l4-row-active" : ""
}`}
>
<div className="grid grid-cols-[auto_1fr_auto] items-baseline gap-4 py-6 sm:gap-8 lg:py-8">
<MonoLabel className="l4-muted w-8">
{String(i + 1).padStart(2, "0")}
</MonoLabel>
<div>
<MonoLabel className="text-ember">{feature.label}</MonoLabel>
<h3 className="mt-2 font-brut-display text-[clamp(1.5rem,3vw,2.8rem)] font-semibold uppercase leading-none tracking-[-0.01em]">
{feature.title}
</h3>
<p className="l4-muted mt-3 max-w-lg font-brut-body text-sm leading-relaxed">
{feature.description}
</p>
</div>
<span
aria-hidden
className="l4-index-marker font-brut-mono text-xl leading-none"
>
+
</span>
</div>
{/* Mobile-only inline video */}
<div className="pb-6 lg:hidden">
<HairlineFrame
caption={`FIG. ${String(i + 1).padStart(2, "0")} — ${feature.label.toUpperCase()}`}
>
<LazyAutoplayVideo
src={feature.src}
className="aspect-[12/10] w-full object-cover"
/>
</HairlineFrame>
</div>
</li>
))}
</ol>
{/* Sticky preview (desktop) */}
<div className="hidden lg:col-span-5 lg:block">
<div className="sticky top-24 p-8">
<HairlineFrame
caption={`FIG. ${String(activeIndex + 1).padStart(2, "0")} — ${active.label.toUpperCase()}`}
>
<div className="aspect-[12/10]">
<LazyAutoplayVideo
key={active.src}
src={active.src}
className="h-full w-full object-cover"
/>
</div>
</HairlineFrame>
</div>
</div>
</div>
</section>
);
};
@@ -0,0 +1,61 @@
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faApple,
faWindows,
faLinux,
} from "@fortawesome/free-brands-svg-icons";
import { LineMaskHeading, MonoLabel } from "./ui";
// /08 — Final CTA. Dark chapter, near-full-viewport, giant type with the last
// line clipped at the section edge.
export const SectionFinalCta = ({
onDownloadClick,
}: {
onDownloadClick: () => void;
}) => (
<section className="relative flex min-h-[90svh] flex-col justify-between overflow-hidden">
<div className="mx-auto w-full max-w-[1600px] px-4 sm:px-8">
<div className="l4-line flex items-center justify-between border-t py-3">
<MonoLabel className="l4-muted">/08 — DOWNLOAD</MonoLabel>
<MonoLabel className="l4-muted">
V2.X — PUBLIC BUILD — {new Date().getFullYear()}
</MonoLabel>
</div>
<div className="py-16 lg:py-24">
<LineMaskHeading
className="text-[clamp(4rem,14vw,15rem)]"
lines={[
<>OWN YOUR</>,
<span className="text-flare">TOOLS.</span>,
]}
/>
<div
data-l4-reveal
className="mt-12 flex flex-col gap-3 sm:flex-row sm:items-center"
>
<button
onClick={onDownloadClick}
className="bg-flare text-ink hover:bg-paper inline-flex h-14 items-center justify-center gap-3 px-8 font-brut-mono text-[13px] font-bold uppercase tracking-[0.14em] transition-colors"
>
Download for Desktop <span aria-hidden>→</span>
</button>
<Link
to="/pricing"
className="l4-line hover:text-flare inline-flex h-14 items-center justify-center border px-8 font-brut-mono text-[13px] uppercase tracking-[0.14em] transition-colors"
style={{ color: "var(--l4-ink)" }}
>
Or use on Web <span aria-hidden className="ml-2">→</span>
</Link>
<div className="l4-muted flex items-center gap-5 text-lg sm:ml-6">
<FontAwesomeIcon icon={faApple} title="macOS" />
<FontAwesomeIcon icon={faWindows} title="Windows" />
<FontAwesomeIcon icon={faLinux} title="Linux" />
</div>
</div>
</div>
</div>
</section>
);
@@ -0,0 +1,121 @@
import { Link } from "react-router-dom";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faApple,
faWindows,
faLinux,
} from "@fortawesome/free-brands-svg-icons";
import { HairlineFrame, LineMaskHeading, MonoLabel, PlusCorners } from "./ui";
// /01 — Hero. Dark chapter. Swiss grid with visible column rules, stacked
// oversized display type, mono metadata at the corners, and a hairline-framed
// canvas video in the right column.
export const SectionHero = ({
onDownloadClick,
}: {
onDownloadClick: () => void;
}) => (
<section className="relative flex min-h-[100svh] flex-col overflow-hidden pt-14">
{/* Vertical gridlines (desktop only) */}
<div
aria-hidden
className="pointer-events-none absolute inset-0 hidden lg:block"
>
{[25, 50, 75].map((left) => (
<span
key={left}
className="absolute bottom-0 top-0 w-px"
style={{ left: `${left}%`, backgroundColor: "var(--l4-line)" }}
/>
))}
</div>
<div className="relative mx-auto flex w-full max-w-[1600px] flex-1 flex-col px-4 sm:px-8">
{/* Top metadata row */}
<div
data-l4-reveal
className="l4-line flex items-center justify-between border-b py-3"
>
<MonoLabel className="l4-muted">EST. 2024 — OPEN SOURCE</MonoLabel>
<MonoLabel className="l4-muted hidden sm:inline">
IMG / VID / 3D
</MonoLabel>
<MonoLabel className="l4-muted">N 37°46′ — W 122°25′</MonoLabel>
</div>
{/* Headline */}
<div className="relative flex flex-1 flex-col justify-center py-14 lg:py-20">
<LineMaskHeading
as="h1"
className="text-[clamp(3.2rem,12vw,13rem)]"
lines={[
<>CONTROLLABLE</>,
<>
<span className="text-flare">AI</span> FOR
</>,
<span className="l4-stroke">ARTISTS</span>,
]}
/>
<p
data-l4-reveal
className="l4-muted mt-8 max-w-md font-brut-body text-base leading-relaxed"
>
ArtCraft is an open desktop app for AI image, video, and 3D. Every
model, one canvas, zero prompt-wrestling.
</p>
{/* CTAs */}
<div
data-l4-reveal
className="mt-10 flex flex-col gap-3 sm:flex-row sm:items-center"
>
<button
onClick={onDownloadClick}
className="bg-flare text-ink hover:bg-paper inline-flex h-12 items-center justify-center gap-3 px-7 font-brut-mono text-[13px] font-bold uppercase tracking-[0.14em] transition-colors"
>
Download_App <span aria-hidden>→</span>
</button>
<Link
to="/pricing"
className="l4-line hover:text-flare inline-flex h-12 items-center justify-center border px-7 font-brut-mono text-[13px] uppercase tracking-[0.14em] transition-colors"
style={{ color: "var(--l4-ink)" }}
>
Use on Web
</Link>
<MonoLabel className="l4-muted sm:ml-4">
MAC OS — WINDOWS — LINUX
</MonoLabel>
</div>
</div>
{/* Bottom row: figure video right, mono coordinates left */}
<div className="l4-line grid grid-cols-1 gap-8 border-t py-8 lg:grid-cols-12">
<div
data-l4-reveal
className="l4-muted flex flex-col justify-end gap-2 lg:col-span-5"
>
<MonoLabel>ARTCRAFT — DESKTOP + WEB</MonoLabel>
<MonoLabel>V2.X — PUBLIC BUILD</MonoLabel>
<div className="mt-4 flex items-center gap-5 text-lg opacity-70">
<FontAwesomeIcon icon={faApple} title="macOS" />
<FontAwesomeIcon icon={faWindows} title="Windows" />
<FontAwesomeIcon icon={faLinux} title="Linux" />
</div>
</div>
<div className="relative lg:col-span-7" data-l4-wipe>
<PlusCorners />
<HairlineFrame caption="FIG. 01 — CANVAS OUTPUT">
<video
className="aspect-video w-full object-cover"
autoPlay
loop
muted
playsInline
src="/videos/hero-video.mp4"
/>
</HairlineFrame>
</div>
</div>
</div>
</section>
);
@@ -0,0 +1,50 @@
import { MADE_WITH_VIDEOS } from "../data";
import { HairlineFrame, LineMaskHeading, SectionHeader } from "./ui";
// /06 — Output. Paper chapter. Asymmetric editorial grid: one large frame plus
// two stacked small frames, all lazy YouTube embeds with mono figure captions.
export const SectionMadeWith = () => {
const [featureFilm, ...smallFilms] = MADE_WITH_VIDEOS;
return (
<section className="mx-auto w-full max-w-[1600px] px-4 py-24 sm:px-8 lg:py-32">
<SectionHeader number="06" label="OUTPUT" right="COMMUNITY FILMS" />
<div className="py-10 lg:py-14">
<LineMaskHeading
className="text-[clamp(2.8rem,8vw,8.5rem)]"
lines={[<>MADE WITH</>, <>ARTCRAFT</>]}
/>
</div>
<div className="grid grid-cols-1 gap-8 lg:grid-cols-12">
<div data-l4-wipe className="lg:col-span-8">
<HairlineFrame caption={featureFilm.caption}>
<iframe
className="aspect-video w-full"
src={featureFilm.embedUrl}
title="Made with ArtCraft — feature film"
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</HairlineFrame>
</div>
<div className="flex flex-col gap-8 lg:col-span-4">
{smallFilms.map((film, i) => (
<div key={film.embedUrl} data-l4-wipe>
<HairlineFrame caption={film.caption}>
<iframe
className="aspect-video w-full"
src={film.embedUrl}
title={`Made with ArtCraft — community film ${i + 2}`}
loading="lazy"
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share"
allowFullScreen
/>
</HairlineFrame>
</div>
))}
</div>
</div>
</section>
);
};
@@ -0,0 +1,45 @@
import { Fragment } from "react";
import { MANIFESTO_ACCENT_WORDS, MANIFESTO_WORDS } from "../data";
import { MonoLabel } from "./ui";
// /02 — Statement. Dark chapter. On desktop the outer section is tall and the
// inner viewport sticks, while a scrubbed ScrollTrigger raises each word from
// 18% opacity to full as the user scrolls. Words tagged as accents also flip
// to flare. On mobile the section is static (no pin) and gets a simple fade.
// The [data-l4-boundary="paper"] spacer after the text drives the dark→paper
// chapter transition.
export const SectionManifesto = () => (
<>
<section data-l4-manifesto className="relative lg:h-[250vh]">
<div className="flex min-h-[70svh] flex-col justify-center lg:sticky lg:top-0 lg:h-screen">
<div className="mx-auto w-full max-w-[1600px] px-4 sm:px-8">
<div className="l4-line grid grid-cols-1 gap-8 border-t pt-6 lg:grid-cols-12">
<div className="lg:col-span-3" data-l4-reveal>
<MonoLabel>
/02 <span className="l4-muted ml-3">STATEMENT</span>
</MonoLabel>
</div>
<p className="font-brut-display text-[clamp(1.8rem,4.5vw,4.2rem)] font-medium leading-[1.12] tracking-[-0.02em] lg:col-span-9">
{MANIFESTO_WORDS.map((word, i) => (
<Fragment key={i}>
<span
data-l4-word
data-l4-word-accent={
MANIFESTO_ACCENT_WORDS.has(word) ? "" : undefined
}
className="inline-block opacity-[0.18]"
>
{word}
</span>{" "}
</Fragment>
))}
</p>
</div>
</div>
</div>
</section>
{/* Chapter boundary: scrubbing through this spacer tweens the root CSS
vars from the dark palette to paper. */}
<div data-l4-boundary="paper" aria-hidden className="h-[60vh]" />
</>
);
@@ -0,0 +1,61 @@
import { PROVIDERS } from "../data";
import { LineMaskHeading, MonoLabel, SectionHeader } from "./ui";
// /04 — Model wall. Paper chapter. Collapsed-border grid of provider marks
// (the service SVGs are dark-on-transparent, so they read natively on paper).
// Counters tick up once via the [data-l4-counter] hooks in the animations
// module.
export const SectionModelWall = () => (
<section className="mx-auto w-full max-w-[1600px] px-4 py-24 sm:px-8 lg:py-32">
<SectionHeader
number="04"
label="MODELS"
right={
<>
PROVIDERS — <span data-l4-counter="19">19</span> / MODELS —{" "}
<span data-l4-counter="60">60</span>+
</>
}
/>
<div className="py-10 lg:py-14">
<LineMaskHeading
className="text-[clamp(2.8rem,8vw,8.5rem)]"
lines={[<>EVERY MODEL.</>, <span className="l4-stroke">ONE CANVAS.</span>]}
/>
<p className="l4-muted mt-8 max-w-md font-brut-body text-base leading-relaxed">
Text prompting is neat, but artists crave control. Use every frontier
model from one desktop canvas — no tab-hopping, no per-site
subscriptions.
</p>
</div>
<div className="l4-line grid grid-cols-3 border-l border-t md:grid-cols-5 lg:grid-cols-7">
{PROVIDERS.map((provider) => (
<div
key={provider.name}
data-l4-cell
className="l4-line flex aspect-square flex-col items-center justify-center gap-3 border-b border-r p-4"
>
<img
src={provider.src}
alt={provider.name}
loading="lazy"
className="h-8 w-8 object-contain opacity-60 sm:h-10 sm:w-10"
/>
<MonoLabel className="l4-muted hidden text-center md:block">
{provider.name}
</MonoLabel>
</div>
))}
<div
data-l4-cell
className="border-ink/20 bg-flare text-ink flex aspect-square flex-col items-center justify-center gap-2 border-b border-r p-4 text-center"
>
<span className="font-brut-display text-3xl font-bold leading-none">
+
</span>
<MonoLabel>MORE EVERY WEEK</MonoLabel>
</div>
</div>
</section>
);
@@ -0,0 +1,75 @@
import { OWNERSHIP_ROWS, RENTAL_ROWS } from "../data";
import { LineMaskHeading, MonoLabel, SectionHeader } from "./ui";
// /05 — Ownership. Paper chapter with the page's strongest hit: a full ink
// panel for the ArtCraft column against a paper ledger of rental grievances.
export const SectionOwnership = () => (
<section className="mx-auto w-full max-w-[1600px] px-4 py-24 sm:px-8 lg:py-32">
<SectionHeader number="05" label="OWNERSHIP" right="NO RENT PAYMENTS" />
<div className="py-10 lg:py-14">
<LineMaskHeading
className="text-[clamp(2.8rem,8vw,8.5rem)]"
lines={[<>STOP RENTING</>, <span className="l4-stroke">YOUR TOOLS</span>]}
/>
<p className="l4-muted mt-8 max-w-md font-brut-body text-base leading-relaxed">
ArtCraft is yours to own and keep, forever. No subscriptions needed, no
aggregator middleman, no rent payments.
</p>
</div>
<div className="l4-line grid grid-cols-1 border-t lg:grid-cols-2">
{/* The subscription web — ledger of grievances */}
<div className="l4-line py-8 lg:border-r lg:pr-12">
<MonoLabel className="l4-muted">✕ THE SUBSCRIPTION WEB</MonoLabel>
<ul className="mt-8">
{RENTAL_ROWS.map((row) => (
<li
key={row}
data-l4-reveal
className="l4-line flex items-baseline gap-4 border-b py-4"
>
<span
aria-hidden
className="l4-muted font-brut-mono text-sm font-bold"
>
✕
</span>
<span className="l4-muted font-brut-body text-base">{row}</span>
</li>
))}
</ul>
</div>
{/* ArtCraft — ink block panel */}
<div
data-l4-panel
className="bg-ink text-paper my-8 flex flex-col justify-between p-8 sm:p-10 lg:my-0 lg:ml-12"
>
<div>
<MonoLabel className="text-flare">✓ ARTCRAFT</MonoLabel>
<ul className="mt-8">
{OWNERSHIP_ROWS.map((row) => (
<li
key={row}
className="flex items-baseline gap-4 border-b border-paper/15 py-4"
>
<span
aria-hidden
className="text-flare font-brut-mono text-sm font-bold"
>
✓
</span>
<span className="font-brut-body text-base text-paper/85">
{row}
</span>
</li>
))}
</ul>
</div>
<MonoLabel className="mt-10 text-paper/40">
LICENSE — YOURS / SERVERS — OPTIONAL
</MonoLabel>
</div>
</div>
</section>
);
@@ -0,0 +1,163 @@
import { useEffect, useRef, useState } from "react";
import type { ReactNode } from "react";
import { TICKER_ITEMS } from "../data";
// Small mono uppercase micro-label, the backbone of the brutalist metadata
// treatment (coordinates, figure captions, section eyebrows).
export const MonoLabel = ({
children,
className = "",
}: {
children: ReactNode;
className?: string;
}) => (
<span
className={`font-brut-mono text-[11px] uppercase tracking-[0.18em] ${className}`}
>
{children}
</span>
);
// Numbered section header rail: "/03 — FEATURES" above a hairline.
export const SectionHeader = ({
number,
label,
right,
}: {
number: string;
label: string;
right?: ReactNode;
}) => (
<div className="l4-line flex items-baseline justify-between border-b pb-3">
<MonoLabel>
<span className="opacity-100">/{number}</span>
<span className="l4-muted ml-3">{label}</span>
</MonoLabel>
{right ? <MonoLabel className="l4-muted">{right}</MonoLabel> : null}
</div>
);
// Four plus-sign markers pinned at the corners of a relative parent.
export const PlusCorners = () => (
<>
<span aria-hidden className="l4-plus -left-[6px] -top-[6px]" />
<span aria-hidden className="l4-plus -right-[6px] -top-[6px]" />
<span aria-hidden className="l4-plus -left-[6px] -bottom-[6px]" />
<span aria-hidden className="l4-plus -right-[6px] -bottom-[6px]" />
</>
);
// 1px-bordered media frame with an optional mono caption strip underneath.
export const HairlineFrame = ({
children,
caption,
className = "",
}: {
children: ReactNode;
caption?: string;
className?: string;
}) => (
<figure className={`relative ${className}`}>
<div className="l4-line relative overflow-hidden border">{children}</div>
{caption ? (
<figcaption className="l4-line l4-muted flex items-center justify-between border-x border-b px-3 py-2">
<MonoLabel>{caption}</MonoLabel>
<MonoLabel aria-hidden>+</MonoLabel>
</figcaption>
) : null}
</figure>
);
// Headline pre-split into lines, each wrapped in an overflow mask so GSAP can
// stagger the inner spans up into view. `lines` accepts ReactNodes so callers
// can color or outline individual words. Tag with data-l4-line-group so the
// animation module can find each group and its [data-l4-line] children.
export const LineMaskHeading = ({
lines,
className = "",
as: Tag = "h2",
}: {
lines: ReactNode[];
className?: string;
as?: "h1" | "h2";
}) => (
<Tag
data-l4-line-group
className={`font-brut-display font-bold uppercase leading-[0.86] tracking-[-0.02em] ${className}`}
>
{lines.map((line, i) => (
<span key={i} className="l4-line-mask">
<span data-l4-line>{line}</span>
</span>
))}
</Tag>
);
// Full-bleed flare marquee band. Content duplicated x2; the CSS animation
// translates -50% for a seamless loop (static under prefers-reduced-motion).
export const Ticker = () => {
const copy = (
<div className="flex shrink-0 items-center">
{TICKER_ITEMS.map((item) => (
<span
key={item}
className="font-brut-mono px-6 text-[13px] font-bold uppercase tracking-[0.2em]"
>
{item} <span className="ml-10">✳</span>
</span>
))}
</div>
);
return (
<div
aria-hidden
className="border-ink/20 bg-flare text-ink flex h-12 items-center overflow-hidden border-y"
>
<div className="l4-ticker-track">
{copy}
{copy}
</div>
</div>
);
};
// Lazy autoplay video: defers fetch + decoder spin-up until the element nears
// the viewport (same pattern as landing3's LazyAutoplayVideo).
export const LazyAutoplayVideo = ({
src,
className,
}: {
src: string;
className?: string;
}) => {
const ref = useRef<HTMLVideoElement>(null);
const [shouldLoad, setShouldLoad] = useState(false);
useEffect(() => {
if (!ref.current || shouldLoad) return;
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting) {
setShouldLoad(true);
observer.disconnect();
}
},
{ rootMargin: "150% 0px" },
);
observer.observe(ref.current);
return () => observer.disconnect();
}, [shouldLoad]);
return (
<video
ref={ref}
className={className}
autoPlay
loop
muted
playsInline
preload={shouldLoad ? "auto" : "none"}
src={shouldLoad ? src : undefined}
/>
);
};
@@ -0,0 +1,165 @@
// Content data for landing4. Feature copy mirrors landing3's FEATURES array so
// the two landings stay in sync on messaging while diverging on presentation.
export interface Feature {
label: string;
title: string;
description: string;
src: string;
}
export const FEATURES: ReadonlyArray<Feature> = [
{
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.",
src: "/videos/features/WorldLabs_Demo_2.webm",
},
{
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.",
src: "/videos/features/Panel.webm",
},
{
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.",
src: "/videos/features/Editor.webm",
},
{
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.",
src: "/videos/features/Make_3D.webm",
},
{
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.",
src: "/videos/features/Mixed.webm",
},
{
label: "Posing",
title: "Character Posing",
description:
'Dynamically pose your characters to nail the precise character, scene, and camera blocking before calling "action".',
src: "/videos/features/Pose_Second_Version.webm",
},
{
label: "Cutouts",
title: "Background Removal",
description:
"Instantly remove backgrounds from images to create assets for your scenes. Clean, precise, and ready for compositing.",
src: "/videos/features/Background.webm",
},
];
export const MANIFESTO_WORDS: ReadonlyArray<string> = [
"ArtCraft",
"brings",
"control",
"to",
"AI",
"image",
"and",
"video",
"generation,",
"giving",
"artists",
"like",
"you",
"full",
"power",
"over",
"every",
"shot.",
];
// Words rendered in flare once the manifesto scrub reaches them.
export const MANIFESTO_ACCENT_WORDS: ReadonlySet<string> = new Set([
"control",
"artists",
]);
export const TICKER_ITEMS: ReadonlyArray<string> = [
"IMAGE",
"VIDEO",
"3D",
"OPEN SOURCE",
"EVERY MODEL",
"NO PROMPTS REQUIRED",
"MADE BY ARTISTS",
];
export interface Provider {
name: string;
src: string;
}
export const PROVIDERS: ReadonlyArray<Provider> = [
{ name: "OpenAI", src: "/images/services/openai.svg" },
{ name: "Google", src: "/images/services/google.svg" },
{ name: "Midjourney", src: "/images/services/midjourney.svg" },
{ name: "ByteDance", src: "/images/services/bytedance.svg" },
{ name: "Kling", src: "/images/services/kling.svg" },
{ name: "Black Forest", src: "/images/services/blackforestlabs.svg" },
{ name: "World Labs", src: "/images/services/worldlabs.svg" },
{ name: "Alibaba", src: "/images/services/alibaba.svg" },
{ name: "Tencent", src: "/images/services/tencent.svg" },
{ name: "Grok", src: "/images/services/grok.svg" },
{ name: "Recraft", src: "/images/services/recraft.svg" },
{ name: "Vidu", src: "/images/services/vidu.svg" },
{ name: "Suno", src: "/images/services/suno.svg" },
{ name: "Krea", src: "/images/services/krea.svg" },
{ name: "Higgsfield", src: "/images/services/higgsfield.svg" },
{ name: "Replicate", src: "/images/services/replicate.svg" },
{ name: "Fal", src: "/images/services/fal.svg" },
{ name: "OpenArt", src: "/images/services/openart.svg" },
{ name: "TensorArt", src: "/images/services/tensorart.svg" },
];
export interface MadeWithVideo {
embedUrl: string;
caption: string;
}
export const MADE_WITH_VIDEOS: ReadonlyArray<MadeWithVideo> = [
{
embedUrl: "https://www.youtube.com/embed/HDdsKJl92H4?si=0Hm4AweSRHq3qRt6",
caption: "FILM_001 — COMMUNITY",
},
{
embedUrl: "https://www.youtube.com/embed/oqoCWdOwr2U?si=ILMPk8hGHo9hP8RU",
caption: "FILM_002 — COMMUNITY",
},
{
embedUrl: "https://www.youtube.com/embed/H4NFXGMuwpY?si=wPuQl5cJOu1v8MJu",
caption: "FILM_003 — COMMUNITY",
},
];
export interface LedgerRow {
text: string;
}
export const RENTAL_ROWS: ReadonlyArray<string> = [
"Paying for access, not a product",
"Your work lives on someone else's servers",
"Monthly fees, forever",
"Locked into one provider's models",
"History disappears when they do",
];
export const OWNERSHIP_ROWS: ReadonlyArray<string> = [
"Download it. It's yours, forever",
"Open source, on your desktop",
"Your files stay on your machine",
"Every model in one canvas",
"Bring your own API keys, or use ours",
];
@@ -0,0 +1 @@
export { default } from "./landing4";
@@ -0,0 +1,262 @@
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
gsap.registerPlugin(ScrollTrigger);
const ACCENT = "#00E5FF";
const PAPER_VARS = {
"--l4-bg": "#F2F1EC",
"--l4-ink": "#0A0A0B",
"--l4-line": "rgba(10,10,11,0.18)",
"--l4-muted": "rgba(10,10,11,0.55)",
};
const DARK_VARS = {
"--l4-bg": "#0A0A0B",
"--l4-ink": "#F2F1EC",
"--l4-line": "rgba(242,241,236,0.16)",
"--l4-muted": "rgba(242,241,236,0.55)",
};
// All ScrollTrigger wiring for landing4. Sections carry data-l4-* hooks;
// this module owns the motion. Runs inside a gsap.context created by the
// page component, so everything reverts on unmount.
//
// NOTE: entrance animations deliberately create their tweens inside onEnter
// callbacks (the landing3 pattern) instead of linking a pre-built tween to
// the trigger — pre-linked non-scrub tweens failed to fire on triggers that
// were already past their start position at mount.
export function initLanding4Animations(root: HTMLElement): void {
const reducedMotion = window.matchMedia(
"(prefers-reduced-motion: reduce)",
).matches;
if (reducedMotion) {
// Everything stays in its final, fully visible state (no initial hidden
// states are ever applied). Accent words still get their color, and
// chapter colors snap so paper sections stay legible.
root.querySelectorAll<HTMLElement>("[data-l4-word]").forEach((word) => {
word.style.opacity = "1";
if (word.dataset.l4WordAccent !== undefined) {
word.style.color = ACCENT;
}
});
setupChapterSnaps(root, true);
return;
}
setupLineMasks(root);
setupReveals(root);
setupWipes(root);
setupCounters(root);
setupOwnershipPanel(root);
const mm = gsap.matchMedia();
mm.add("(min-width: 1024px)", () => {
setupManifestoScrub(root);
setupChapterScrubs(root);
});
mm.add("(max-width: 1023px)", () => {
setupManifestoFade(root);
setupChapterSnaps(root, false);
});
}
// Headline line reveals: each [data-l4-line-group] staggers its masked lines
// up into view when the group enters the viewport.
function setupLineMasks(root: HTMLElement) {
const groups = root.querySelectorAll<HTMLElement>("[data-l4-line-group]");
groups.forEach((group) => {
const lines = group.querySelectorAll<HTMLElement>("[data-l4-line]");
gsap.set(lines, { yPercent: 110 });
ScrollTrigger.create({
trigger: group,
start: "top 85%",
once: true,
onEnter: () =>
gsap.to(lines, {
yPercent: 0,
duration: 0.9,
ease: "power4.out",
stagger: 0.09,
}),
});
});
}
// Generic fade-up entrances, batched like landing3 so ~30 elements share a
// handful of triggers instead of one each.
function setupReveals(root: HTMLElement) {
const elements = gsap.utils.toArray<HTMLElement>("[data-l4-reveal]", root);
if (!elements.length) return;
gsap.set(elements, { autoAlpha: 0, y: 20 });
ScrollTrigger.batch(elements, {
start: "top 88%",
once: true,
onEnter: (batch) =>
gsap.to(batch, {
autoAlpha: 1,
y: 0,
duration: 0.8,
ease: "power2.out",
stagger: 0.06,
}),
});
}
// Media frames open with a left→right clip wipe.
function setupWipes(root: HTMLElement) {
const elements = gsap.utils.toArray<HTMLElement>("[data-l4-wipe]", root);
elements.forEach((el) => {
gsap.set(el, { clipPath: "inset(0 100% 0 0)" });
ScrollTrigger.create({
trigger: el,
start: "top 85%",
once: true,
onEnter: () =>
gsap.to(el, {
clipPath: "inset(0 0% 0 0)",
duration: 1.1,
ease: "power3.inOut",
}),
});
});
}
// Model-wall counters tick from 0 to their data-l4-counter target once.
function setupCounters(root: HTMLElement) {
const counters = root.querySelectorAll<HTMLElement>("[data-l4-counter]");
counters.forEach((el) => {
const target = Number(el.dataset.l4Counter);
if (Number.isNaN(target)) return;
ScrollTrigger.create({
trigger: el,
start: "top 90%",
once: true,
onEnter: () => {
const state = { value: 0 };
gsap.to(state, {
value: target,
duration: 1.4,
ease: "power2.out",
onUpdate: () => {
el.textContent = String(Math.round(state.value));
},
});
},
});
});
}
// Ownership ink panel reveals with a top→bottom wipe.
function setupOwnershipPanel(root: HTMLElement) {
const panel = root.querySelector<HTMLElement>("[data-l4-panel]");
if (!panel) return;
gsap.set(panel, { clipPath: "inset(0 0 100% 0)" });
ScrollTrigger.create({
trigger: panel,
start: "top 82%",
once: true,
onEnter: () =>
gsap.to(panel, {
clipPath: "inset(0 0 0% 0)",
duration: 1.1,
ease: "power3.inOut",
}),
});
}
// Desktop manifesto: the tall section pins its inner viewport via CSS sticky;
// this scrub raises each word to full opacity in sequence and flips accent
// words to the accent color near their reveal.
function setupManifestoScrub(root: HTMLElement) {
const section = root.querySelector<HTMLElement>("[data-l4-manifesto]");
if (!section) return;
const words = section.querySelectorAll<HTMLElement>("[data-l4-word]");
if (!words.length) return;
const timeline = gsap.timeline({
scrollTrigger: {
trigger: section,
start: "top top",
end: "bottom bottom",
scrub: true,
},
});
words.forEach((word) => {
timeline.to(word, { opacity: 1, duration: 1, ease: "none" }, "<0.35");
if (word.dataset.l4WordAccent !== undefined) {
timeline.to(word, { color: ACCENT, duration: 0.5 }, "<");
}
});
}
// Mobile manifesto: no pin, just a staggered fade of the words.
function setupManifestoFade(root: HTMLElement) {
const section = root.querySelector<HTMLElement>("[data-l4-manifesto]");
if (!section) return;
const words = section.querySelectorAll<HTMLElement>("[data-l4-word]");
if (!words.length) return;
ScrollTrigger.create({
trigger: section,
start: "top 70%",
once: true,
onEnter: () => {
gsap.to(words, {
opacity: 1,
duration: 0.8,
ease: "power2.out",
stagger: 0.03,
});
words.forEach((word) => {
if (word.dataset.l4WordAccent !== undefined) {
gsap.to(word, { color: ACCENT, duration: 0.5, delay: 0.4 });
}
});
},
});
}
// Desktop chapter transitions: scrubbing through each boundary spacer tweens
// the root CSS vars, recoloring everything chapter-aware (navbar, hairlines,
// type) as one system.
function setupChapterScrubs(root: HTMLElement) {
root
.querySelectorAll<HTMLElement>("[data-l4-boundary]")
.forEach((boundary) => {
const vars =
boundary.dataset.l4Boundary === "paper" ? PAPER_VARS : DARK_VARS;
gsap.to(root, {
...vars,
ease: "none",
scrollTrigger: {
trigger: boundary,
start: "top 75%",
end: "bottom 25%",
scrub: true,
},
});
});
}
// Mobile / reduced-motion chapter transitions: tween (or snap) the vars when
// the boundary enters, and reverse when scrolling back up past it.
function setupChapterSnaps(root: HTMLElement, instant: boolean) {
root
.querySelectorAll<HTMLElement>("[data-l4-boundary]")
.forEach((boundary) => {
const isPaper = boundary.dataset.l4Boundary === "paper";
const forward = isPaper ? PAPER_VARS : DARK_VARS;
const backward = isPaper ? DARK_VARS : PAPER_VARS;
const duration = instant ? 0 : 0.6;
ScrollTrigger.create({
trigger: boundary,
start: "top 60%",
onEnter: () => gsap.to(root, { ...forward, duration }),
onLeaveBack: () => gsap.to(root, { ...backward, duration }),
});
});
}
@@ -0,0 +1,104 @@
/* Landing4 (brutalist landing) scoped styles. Every class is .l4- prefixed:
Vite bundles CSS globally, so unprefixed names would leak into other pages. */
.l4-root {
--l4-bg: #0a0a0b;
--l4-ink: #f2f1ec;
--l4-line: rgba(242, 241, 236, 0.16);
--l4-muted: rgba(242, 241, 236, 0.55);
background-color: var(--l4-bg);
color: var(--l4-ink);
}
/* Chapter-aware helpers: everything that must recolor when the background
scrubs between ink and paper reads these vars instead of fixed colors. */
.l4-line {
border-color: var(--l4-line);
}
.l4-muted {
color: var(--l4-muted);
}
/* Outlined display type (e.g. "ARTISTS", "ONE CANVAS.") */
.l4-stroke {
-webkit-text-stroke: 2px var(--l4-ink);
color: transparent;
}
@media (max-width: 640px) {
.l4-stroke {
-webkit-text-stroke-width: 1.2px;
}
}
/* Overflow mask for line-by-line headline reveals. The inner span gets
translated by GSAP; padding absorbs Bricolage's descender overhang. */
.l4-line-mask {
overflow: hidden;
display: block;
padding-bottom: 0.08em;
margin-bottom: -0.08em;
}
.l4-line-mask > span {
display: block;
will-change: transform;
}
/* Marquee ticker: two identical copies of the content; -50% = seamless loop.
Reuses the marquee-track keyframes defined in src/styles.css. */
.l4-ticker-track {
display: flex;
width: max-content;
animation: marquee-track 28s linear infinite;
}
/* Plus-sign grid intersection markers */
.l4-plus {
position: absolute;
width: 13px;
height: 13px;
pointer-events: none;
}
.l4-plus::before,
.l4-plus::after {
content: "";
position: absolute;
background: var(--l4-ink);
opacity: 0.5;
}
.l4-plus::before {
left: 6px;
top: 0;
width: 1px;
height: 13px;
}
.l4-plus::after {
left: 0;
top: 6px;
width: 13px;
height: 1px;
}
/* Feature index rows: the + marker rotates to × when the row is active. */
.l4-index-marker {
transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1);
}
.l4-row-active .l4-index-marker {
transform: rotate(45deg);
}
@media (prefers-reduced-motion: reduce) {
.l4-ticker-track {
animation: none;
}
.l4-index-marker {
transition: none;
}
}
@@ -0,0 +1,106 @@
import { useEffect, useLayoutEffect, useRef, useState } from "react";
import { isMobile } from "react-device-detect";
import Lenis from "lenis";
import gsap from "gsap";
import { ScrollTrigger } from "gsap/ScrollTrigger";
import Seo from "../../components/seo";
import { DownloadModal } from "../../components/download-modal";
import { initLanding4Animations } from "./landing4-animations";
import { BrutNavbar } from "./components/brut-navbar";
import { BrutFooter } from "./components/brut-footer";
import { Ticker } from "./components/ui";
import { SectionHero } from "./components/section-hero";
import { SectionManifesto } from "./components/section-manifesto";
import { SectionFeatures } from "./components/section-features";
import { SectionModelWall } from "./components/section-model-wall";
import { SectionOwnership } from "./components/section-ownership";
import { SectionMadeWith } from "./components/section-made-with";
import { SectionCommunity } from "./components/section-community";
import { SectionFinalCta } from "./components/section-final-cta";
import "./landing4.css";
gsap.registerPlugin(ScrollTrigger);
// Landing4 — brutalist grid landing. Chapters: dark (hero, statement) →
// paper (toolkit, models, ownership, output) → dark (community, CTA, footer).
// Sections are static markup with data-l4-* hooks; all motion lives in
// landing4-animations.ts.
const Landing4 = () => {
const [showDownloadModal, setShowDownloadModal] = useState(false);
const rootRef = useRef<HTMLDivElement>(null);
// Lenis smooth scrolling (same wiring as landing3). Skipped on mobile and
// under prefers-reduced-motion.
useEffect(() => {
if (isMobile) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
const lenis = new Lenis({
duration: 1.2,
easing: (t) => Math.min(1, 1.001 - Math.pow(2, -10 * t)),
smoothWheel: true,
lerp: 0.1,
});
let rafId: number;
const raf = (time: number) => {
lenis.raf(time);
rafId = requestAnimationFrame(raf);
};
rafId = requestAnimationFrame(raf);
lenis.on("scroll", ScrollTrigger.update);
const handleResize = () => {
lenis.resize();
ScrollTrigger.refresh();
};
window.addEventListener("resize", handleResize);
return () => {
window.removeEventListener("resize", handleResize);
cancelAnimationFrame(rafId);
lenis.destroy();
};
}, []);
useLayoutEffect(() => {
if (!rootRef.current) return;
const ctx = gsap.context(
() => initLanding4Animations(rootRef.current!),
rootRef,
);
return () => ctx.revert();
}, []);
const openDownloadModal = () => setShowDownloadModal(true);
return (
<div ref={rootRef} className="l4-root font-brut-body">
<Seo
title="ArtCraft — Controllable AI for Artists"
description="Own your tools. ArtCraft is an open desktop app for AI image, video, and 3D — every model, one canvas."
/>
<BrutNavbar onDownloadClick={openDownloadModal} />
<main>
<SectionHero onDownloadClick={openDownloadModal} />
<Ticker />
<SectionManifesto />
<SectionFeatures />
<SectionModelWall />
<SectionOwnership />
<SectionMadeWith />
<SectionCommunity />
<SectionFinalCta onDownloadClick={openDownloadModal} />
</main>
<BrutFooter />
<DownloadModal
isOpen={showDownloadModal}
onClose={() => setShowDownloadModal(false)}
/>
</div>
);
};
export default Landing4;
@@ -29,6 +29,15 @@ module.exports = {
"Arial",
"sans-serif",
],
// Landing4 (brutalist landing) type system
"brut-display": [
"Bricolage Grotesque",
"Outfit",
"system-ui",
"sans-serif",
],
"brut-body": ["Inter", "system-ui", "sans-serif"],
"brut-mono": ["Space Mono", "ui-monospace", "monospace"],
},
},
colors: {
@@ -90,6 +99,18 @@ module.exports = {
950: "#3E3E41",
},
"base-fg": "rgb(255 255 255 / <alpha-value>)",
// Landing4 (brutalist landing) palette
ink: {
DEFAULT: "#0A0A0B",
800: "#141416",
700: "#1C1C1F",
},
paper: {
DEFAULT: "#F2F1EC",
200: "#E8E6DE",
},
flare: "#00E5FF",
ember: "#F0563A",
ui: {
// Aligned with artcraft app's theme-gray (frontend/apps/artcraft/app/src/styles/base.css)
background: "#242424",