Compare commits

...
Author SHA1 Message Date
BFlat 6454b40a76 Landing page polish: manifesto, feature deck, model marquee, community CTA
- Manifesto: drop "3D" and animate a blue underline on "control"
- Feature cards: scroll-driven stacking deck (scale back + fade once covered)
- Use Every Model: fill chips from canonical model list with correct brand icons
- Use Every Model: alternating-direction marquees, highlight on hover
- Stop renting: italicize "renting"; make the good/bad comparison read clearly
- Community CTA: "Join our community" banner restyled to fit site + not steal focus from final CTA
2026-06-18 11:25:09 +07:00
9 changed files with 296 additions and 227 deletions
@@ -46,6 +46,9 @@
{
"path": "../../libs/components/upload-modal/tsconfig.lib.json"
},
{
"path": "../../libs/components/pagedraw/tsconfig.lib.json"
},
{
"path": "../../libs/components/slider-v2/tsconfig.lib.json"
},
@@ -11,6 +11,9 @@
{
"path": "../../libs/components/upload-modal"
},
{
"path": "../../libs/components/pagedraw"
},
{
"path": "../../libs/components/slider-v2"
},
@@ -1,141 +1,105 @@
import { ReactNode } from "react";
import { twMerge } from "tailwind-merge";
import {
getCreatorIconPath,
IMAGE_MODELS,
VIDEO_MODELS,
} from "@storyteller/model-list";
interface BadgeInfo {
id: string;
label?: string | ReactNode;
svg?: ReactNode | null;
label: string;
iconSrc: string;
}
interface ModelBadgeGridProps {
className?: string;
highlight?: string;
rowOffsets?: number[];
}
const baseBadgeClasses =
"rounded-2xl px-6 py-3 text-2xl font-normal flex-shrink-0 bg-[#121212] text-white/90 text-center h-[58px] flex items-center justify-center gap-1.5";
const highlightClasses =
"bg-primary/30 text-white font-semibold shadow-lg border-2 border-primary/60";
const ROW_COUNT = 3;
// Per-row marquee durations (seconds). Deliberately mismatched so the three
// rows never move in lockstep.
const ROW_DURATIONS = [56, 68, 62];
const rows: BadgeInfo[][] = [
[
{ id: "empty1", svg: null },
{ id: "empty2", svg: null },
{ id: "empty3", svg: null },
{
id: "seedance-2",
label: "Seedance 2.0",
svg: (
<img
src="/model-logos/bytedance.svg"
alt="Seedance logo"
className="w-full h-full invert"
/>
),
},
{ id: "empty4", svg: null },
// Transparent border in the base keeps the box size constant when the hover
// highlight border appears, so the marquee doesn't jitter. The chip lights up
// only while the pointer is over it.
const badgeClasses =
"rounded-2xl px-6 py-3 text-2xl font-normal flex-shrink-0 bg-[#121212] text-white/90 text-center h-[58px] flex items-center justify-center gap-1.5 border-2 border-transparent transition-all duration-300 hover:bg-primary/30 hover:text-white hover:font-semibold hover:shadow-lg hover:border-primary/60";
{ id: "empty6", svg: null },
{ id: "empty7", svg: null },
{ id: "empty8", svg: null },
],
[
{ id: "empty9", svg: null },
{
id: "midjourney",
label: "Midjourney",
svg: (
<img
src="/model-logos/midjourney.svg"
alt="Midjourney logo"
className="w-full h-full invert"
/>
),
},
{
id: "nano-banana-2",
label: "Nano Banana 2",
svg: (
<img
src="/model-logos/google.svg"
alt="Google gemini icon"
className="w-full h-full invert"
/>
),
},
{ id: "empty10", svg: null },
// Build one badge per unique model (deduped by display name so e.g. an
// image+video pair like "Grok" only shows once), pulling the correct brand
// icon from the canonical model mapping.
const BADGES: BadgeInfo[] = (() => {
const seen = new Set<string>();
const badges: BadgeInfo[] = [];
for (const model of [...IMAGE_MODELS, ...VIDEO_MODELS]) {
if (seen.has(model.fullName)) continue;
seen.add(model.fullName);
badges.push({
id: model.id,
label: model.fullName,
iconSrc: getCreatorIconPath(model.creator),
});
}
return badges;
})();
{ id: "empty11", svg: null },
{ id: "empty12", svg: null },
{ id: "empty13", svg: null },
{ id: "empty14", svg: null },
],
[
{ id: "empty15", svg: null },
{ id: "empty16", svg: null },
{ id: "empty17", svg: null },
{
id: "kling",
label: "Kling 3.0 Pro",
svg: (
<img
src="/model-logos/kling.svg"
alt="Kling AI logo"
className="w-full h-full invert"
/>
),
},
{ id: "empty18", svg: null },
{ id: "empty19", svg: null },
{ id: "empty20", svg: null },
{ id: "empty21", svg: null },
],
];
// Distribute badges round-robin across the rows so each row carries a mix of
// brands rather than all of one creator clumping together.
const ROWS: BadgeInfo[][] = Array.from({ length: ROW_COUNT }, () => []);
BADGES.forEach((badge, i) => {
ROWS[i % ROW_COUNT].push(badge);
});
export default function ModelBadgeGrid({
className = "",
highlight,
rowOffsets = [],
}: ModelBadgeGridProps) {
return (
<div
className={`select-none relative z-10 h-full overflow-hidden ${className}`}
>
{/* Gradient fade overlays */}
{/* Gradient fade overlays mask the marquee edges */}
<div className="absolute left-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-r from-[#080808] to-transparent z-10 pointer-events-none" />
<div className="absolute right-0 top-0 w-32 xl:w-96 h-full bg-gradient-to-l from-[#080808] to-transparent z-10 pointer-events-none" />
<div className="flex flex-col gap-4 h-full -mx-[280px] xl:-mx-8">
{rows.map((row, rowIdx) => {
const offset = rowOffsets[rowIdx] ?? 0;
return (
<div className="flex flex-col gap-4 h-full justify-center py-4">
{ROWS.map((row, rowIdx) => (
<div key={rowIdx} className="overflow-hidden">
<div
key={rowIdx}
className="flex gap-4 whitespace-nowrap"
style={{ marginLeft: offset }}
className="flex w-max motion-reduce:[animation:none]"
style={{
animationName: "marquee-track",
animationDuration: `${ROW_DURATIONS[rowIdx]}s`,
animationTimingFunction: "linear",
animationIterationCount: "infinite",
// Even rows scroll right (reversed), odd rows scroll left.
animationDirection: rowIdx % 2 === 0 ? "reverse" : "normal",
}}
>
{row.map(({ id, label, svg }, idx) => {
const isHighlight = highlight && highlight.toLowerCase() === id;
return (
<div
key={idx}
className={twMerge(
baseBadgeClasses,
label ? "" : "min-w-[180px]",
isHighlight ? highlightClasses : "",
)}
>
{svg ? (
<span className="mr-2 w-6 h-6 inline-flex ">{svg}</span>
) : null}
{label}
</div>
);
})}
{/* Two identical copies make the loop seamless: translating the
track by -50% lands exactly on the start of the second copy. */}
{[0, 1].map((copy) => (
<div
key={copy}
className="flex gap-4 pr-4 flex-shrink-0"
aria-hidden={copy === 1}
>
{row.map((badge) => (
<div key={`${copy}-${badge.id}`} className={badgeClasses}>
<span className="mr-2 w-6 h-6 inline-flex">
<img
src={badge.iconSrc}
alt=""
className="w-full h-full invert"
/>
</span>
{badge.label}
</div>
))}
</div>
))}
</div>
);
})}
</div>
))}
</div>
</div>
);
@@ -643,11 +643,7 @@ const LandingSD2 = () => {
all in one place. Log in with your existing subscriptions.
</p>
</div>
<ModelBadgeGrid
highlight="nano-banana-2"
rowOffsets={[-70, -90, -160]}
className="mt-3"
/>
<ModelBadgeGrid className="mt-3" />
</div>
</div>
@@ -640,11 +640,7 @@ const Landing = () => {
all in one place. Log in with your existing subscriptions.
</p>
</div>
<ModelBadgeGrid
highlight="nano-banana-2"
rowOffsets={[-70, -90, -160]}
className="mt-3"
/>
<ModelBadgeGrid className="mt-3" />
</div>
</div>
@@ -115,7 +115,6 @@ const MADE_WITH_VIDEOS = [
const MANIFESTO_WORDS: ReadonlyArray<string> = [
"ArtCraft",
"brings",
"3D",
"control",
"to",
"AI",
@@ -353,6 +352,50 @@ const Landing3 = () => {
}),
});
// Feature deck: all cards pin flush at the same top. As the NEXT card
// rises past roughly halfway over this one, scale this card down (top
// edge stays put) so it reads as receding behind the incoming card.
const stackCards = gsap.utils.toArray<HTMLElement>("[data-stack-card]");
stackCards.forEach((card, i) => {
const next = stackCards[i + 1];
if (!next) return; // top card is never covered
gsap.fromTo(
card,
{ scale: 1 },
{
scale: 0.9,
ease: "none",
transformOrigin: "center top",
scrollTrigger: {
// Drive off the incoming card: start once it's about halfway up
// the viewport (~half over this card), finish when it pins flush.
trigger: next,
start: "top center",
end: "top top+=96",
scrub: true,
},
},
);
// Only AFTER the next card has fully overlapped this one (it pins flush
// at the same top) do we fade this card out. It's hidden behind the
// next card by then, so the fade is invisible — it just drops this
// card's shadow so the edge shadows don't stack across the deck.
gsap.fromTo(
card,
{ opacity: 1 },
{
opacity: 0,
ease: "none",
scrollTrigger: {
trigger: next,
start: "top top+=96",
end: "top top-=40",
scrub: true,
},
},
);
});
// Hero pattern parallax (slower than scroll, drifts upward)
const heroPattern = document.querySelector<HTMLElement>(
"[data-hero-pattern]",
@@ -428,6 +471,23 @@ const Landing3 = () => {
);
});
// Phase 1b — once the sentence is fully revealed, draw a blue
// underline in under "control" to land the emphasis.
const manifestoUnderline = manifestoSection.querySelector<HTMLElement>(
"[data-manifesto-underline]",
);
if (manifestoUnderline) {
gsap.set(manifestoUnderline, {
scaleX: 0,
transformOrigin: "left center",
});
tl.to(
manifestoUnderline,
{ scaleX: 1, duration: 4, ease: "power2.out" },
">",
);
}
// Phase 2 — hold the fully-revealed manifesto frozen so the user gets
// a beat to read it before the transition triggers.
tl.to({}, { duration: 4 });
@@ -678,7 +738,18 @@ const Landing3 = () => {
className="text-2xl sm:text-3xl tracking-[-0.035em] font-medium text-white px-4 sm:px-0"
style={{ lineHeight: 1.4 }}
>
{MANIFESTO_WORDS.join(" ")}
{MANIFESTO_WORDS.map((w, i) => (
<span key={i}>
{w === "control" ? (
<span className="underline decoration-primary decoration-[3px] underline-offset-4">
{w}
</span>
) : (
w
)}
{i < MANIFESTO_WORDS.length - 1 ? " " : ""}
</span>
))}
</h2>
</div>
</section>
@@ -729,7 +800,18 @@ const Landing3 = () => {
data-manifesto-word
className="inline-block opacity-15 will-change-[opacity,transform]"
>
{w}
{w === "control" ? (
<span className="relative inline-block">
{w}
<span
data-manifesto-underline
aria-hidden
className="absolute left-0 -bottom-0.5 h-[4px] w-full origin-left scale-x-0 rounded-full bg-primary"
/>
</span>
) : (
w
)}
{i < MANIFESTO_WORDS.length - 1 ? " " : ""}
</span>
))}
@@ -758,7 +840,7 @@ const Landing3 = () => {
</div>
</section>
{/* FEATURES: alternating cards */}
<section className="relative px-4 sm:px-8 py-16 sm:py-24">
<section className="relative px-4 sm:px-8 py-16 sm:pt-24">
<TruchetBlob
className="top-[6%] -left-40 w-[640px] h-[640px]"
variant="content"
@@ -780,45 +862,48 @@ const Landing3 = () => {
speed={-22}
rotate={8}
/>
<div className="max-w-6xl mx-auto flex flex-col gap-8 sm:gap-16">
<div className="max-w-6xl mx-auto">
{FEATURES.map((feature, i) => (
<article
<div
key={feature.title}
data-reveal
className="grid grid-cols-1 lg:grid-cols-12 gap-0 rounded-2xl sm:rounded-[28px] overflow-hidden bg-[#080808] transition-colors"
data-stack-card
className="relative lg:sticky"
style={!isMobile ? { top: "96px" } : undefined}
>
<div
className={`lg:col-span-5 p-7 sm:p-10 lg:p-12 flex flex-col justify-center ${
i % 2 === 1 ? "lg:order-2" : ""
}`}
>
<div className="flex items-center gap-2 mb-5">
<span className="inline-flex h-7 px-2.5 items-center gap-1.5 rounded-full bg-primary/15 text-primary text-[12px] font-semibold border border-primary/20">
<FontAwesomeIcon
icon={feature.icon}
className="text-[10px]"
/>
{feature.label}
</span>
<article className="grid grid-cols-1 lg:grid-cols-12 gap-0 rounded-2xl sm:rounded-[28px] overflow-hidden bg-[#080808] shadow-[0_-16px_50px_-8px_rgba(0,0,0,0.85)] transition-colors mb-8 lg:mb-[8vh]">
<div
className={`lg:col-span-5 p-7 sm:p-10 lg:p-12 flex flex-col justify-center ${
i % 2 === 1 ? "lg:order-2" : ""
}`}
>
<div className="flex items-center gap-2 mb-5">
<span className="inline-flex h-7 px-2.5 items-center gap-1.5 rounded-full bg-primary/15 text-primary text-[12px] font-semibold border border-primary/20">
<FontAwesomeIcon
icon={feature.icon}
className="text-[10px]"
/>
{feature.label}
</span>
</div>
<h3 className="text-2xl sm:text-3xl md:text-[32px] tracking-[-0.02em] font-medium leading-[1.15] mb-4 text-white">
{feature.title}
</h3>
<p className="text-[15px] sm:text-base text-white/55 leading-relaxed">
{feature.description}
</p>
</div>
<h3 className="text-2xl sm:text-3xl md:text-[32px] tracking-[-0.02em] font-medium leading-[1.15] mb-4 text-white">
{feature.title}
</h3>
<p className="text-[15px] sm:text-base text-white/55 leading-relaxed">
{feature.description}
</p>
</div>
<div
className={`lg:col-span-7 relative bg-[#080808] aspect-[12/10] lg:self-center ${
i % 2 === 1 ? "lg:order-1" : ""
}`}
>
<LazyAutoplayVideo
src={feature.src}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
</article>
<div
className={`lg:col-span-7 relative bg-[#080808] aspect-[12/10] lg:self-center ${
i % 2 === 1 ? "lg:order-1" : ""
}`}
>
<LazyAutoplayVideo
src={feature.src}
className="absolute inset-0 w-full h-full object-cover"
/>
</div>
</article>
</div>
))}
</div>
</section>
@@ -837,45 +922,57 @@ const Landing3 = () => {
Ownership
</span>
<h2 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
Stop renting from{" "}
<span className="font-serif-italic">websites</span>.
Stop <span className="font-serif-italic">renting</span> from
websites.
</h2>
<p className="max-w-xl mx-auto text-base sm:text-lg text-white/55 leading-relaxed">
ArtCraft is yours to own and keep,{" "}
<span className="font-serif-italic text-white/75">forever</span>.
No subscriptions, no aggregator middleman, no rent payments.
ArtCraft is yours to own and keep, forever. No subscriptions
needed, no aggregator middleman, no rent payments.
</p>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 sm:gap-6">
{/* Websites column */}
<div className="rounded-2xl sm:rounded-[28px] bg-[#080808] p-7 sm:p-8">
<div className="flex items-center gap-2 mb-9">
<span className="text-[12px] font-bold uppercase tracking-wider text-white/40">
Other tools
</span>
</div>
<h3 className="text-xl sm:text-2xl font-medium mb-5 tracking-[-0.01em] text-white/85">
The Rental Trap
</h3>
<p className="text-[15px] text-white/55 leading-relaxed mb-6">
With browser-based tools, you're paying for access, not a
product. Your work, models, and history live on someone else's
servers, and disappear with them.
</p>
<div className="flex flex-wrap gap-2">
{["No ownership", "Monthly fees"].map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-red-300/80 bg-red-500/[0.08] border border-red-500/20 rounded-lg px-2.5 py-1.5"
>
{/* Websites column — deliberately styled to feel like a downgrade */}
<div className="relative overflow-hidden rounded-2xl sm:rounded-[28px] bg-gradient-to-br from-red-500/[0.08] via-[#0a0607] to-[#080808] border border-red-500/20 p-7 sm:p-8">
<div
className="absolute -top-16 -left-16 w-72 h-72 rounded-full pointer-events-none"
style={{
background:
"radial-gradient(circle, rgba(239,68,68,0.18) 0%, transparent 60%)",
}}
/>
<div className="relative">
<div className="flex items-center gap-2 mb-8">
<span className="inline-flex items-center gap-1.5 text-[12px] font-bold uppercase tracking-wider text-red-300/70">
<FontAwesomeIcon
icon={faXmark}
className="text-red-400 text-[10px]"
className="text-red-400 text-[11px]"
/>
{tag}
Other tools
</span>
))}
</div>
<h3 className="text-xl sm:text-2xl font-medium mb-5 tracking-[-0.01em] text-white/70">
The Rental Trap
</h3>
<p className="text-[15px] text-white/45 leading-relaxed mb-6">
With browser-based tools, you're paying for access, not a
product. Your work, models, and history live on someone else's
servers, and disappear with them.
</p>
<div className="flex flex-wrap gap-2">
{["No ownership", "Monthly fees", "Locked in"].map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-red-300/80 bg-red-500/[0.08] border border-red-500/20 rounded-lg px-2.5 py-1.5"
>
<FontAwesomeIcon
icon={faXmark}
className="text-red-400 text-[10px]"
/>
{tag}
</span>
))}
</div>
</div>
</div>
@@ -906,18 +1003,20 @@ const Landing3 = () => {
keys, or use ours.
</p>
<div className="flex flex-wrap gap-2 self-end">
{["Yours forever", "BYO keys"].map((tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-primary-200 bg-primary/[0.12] border border-primary/25 rounded-lg px-2.5 py-1.5"
>
<FontAwesomeIcon
icon={faCheck}
className="text-primary text-[10px]"
/>
{tag}
</span>
))}
{["Yours forever", "BYO keys", "No subscriptions needed"].map(
(tag) => (
<span
key={tag}
className="inline-flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wider text-primary-200 bg-primary/[0.12] border border-primary/25 rounded-lg px-2.5 py-1.5"
>
<FontAwesomeIcon
icon={faCheck}
className="text-primary text-[10px]"
/>
{tag}
</span>
),
)}
</div>
</div>
</div>
@@ -1073,11 +1172,7 @@ const Landing3 = () => {
all in one place. Log in with your existing subscriptions.
</p>
</div>
<ModelBadgeGrid
highlight="nano-banana-2"
rowOffsets={[-70, -90, -160]}
className="mt-3"
/>
<ModelBadgeGrid className="mt-3" />
</div>
</div>
@@ -1191,25 +1286,30 @@ const Landing3 = () => {
</section>
{/* COMMUNITY CTA */}
<section className="relative px-4 sm:px-8 pt-8 sm:pt-12 overflow-hidden">
<div className="relative z-10 max-w-3xl mx-auto" data-reveal>
<div className="rounded-2xl sm:rounded-3xl bg-white/[0.02] border border-white/[0.08] px-6 py-10 sm:px-10 sm:py-12 text-center">
<h2 className="text-2xl sm:text-3xl tracking-[-0.025em] font-medium leading-tight mb-3 text-white">
Open source & community-driven
</h2>
<p className="max-w-xl mx-auto text-base text-white/60 leading-relaxed mb-8">
Join the conversation on Discord and star us on GitHub.
</p>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
<div className="relative z-10 max-w-5xl mx-auto" data-reveal>
<div className="relative overflow-hidden flex flex-col gap-7 md:flex-row md:items-center md:justify-between rounded-2xl sm:rounded-[28px] bg-[#080808] border border-white/[0.1] px-7 py-8 sm:px-10 sm:py-10">
<div className="flex flex-col sm:flex-row items-center gap-5 text-center sm:text-left">
{/* <span className="flex h-14 w-14 shrink-0 items-center justify-center rounded-2xl bg-primary/15 border border-primary/25 text-primary">
<FontAwesomeIcon icon={faDiscord} className="text-2xl" />
</span> */}
<div>
<h2 className="text-2xl sm:text-3xl tracking-[-0.02em] font-medium leading-tight text-white">
Join our <span className="font-serif-italic">community</span>
</h2>
<p className="text-sm sm:text-base text-white/55 leading-relaxed mt-2 max-w-md">
ArtCraft is open source and community-driven. Come build with
us.
</p>
</div>
</div>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3 shrink-0">
<a
href={SOCIAL_LINKS.DISCORD}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 h-11 px-5 rounded-full bg-white/[0.06] hover:bg-white/[0.1] text-white text-[14px] font-semibold border border-white/[0.1] transition-all hover:-translate-y-px"
className="group inline-flex items-center gap-2 h-11 px-5 rounded-full bg-primary hover:bg-primary-600 text-white text-[14px] font-semibold transition-all shadow-[0_4px_24px_-4px_rgba(45,129,255,0.4)] hover:shadow-[0_8px_32px_-4px_rgba(45,129,255,0.5)] hover:-translate-y-px"
>
<FontAwesomeIcon
icon={faDiscord}
className="text-[13px] text-[#5865F2]"
/>
<FontAwesomeIcon icon={faDiscord} className="text-[13px]" />
Join Discord
</a>
<a
@@ -163,6 +163,19 @@
}
}
/* For marquee tracks that render two identical copies of their content:
translating by -50% lands exactly on the start of the second copy, so the
loop is seamless. */
@keyframes marquee-track {
from {
transform: translateX(0);
}
to {
transform: translateX(-50%);
}
}
@keyframes shimmer {
0% {
background-position: 200% 0;
@@ -5,9 +5,6 @@
{
"path": "../../api"
},
{
"path": "../../tauri-api"
},
{
"path": "../button"
},
@@ -46,9 +46,6 @@
{
"path": "../../api/tsconfig.lib.json"
},
{
"path": "../../tauri-api/tsconfig.lib.json"
},
{
"path": "../button/tsconfig.lib.json"
},