diff --git a/frontend/apps/artcraft-webapp/tsconfig.app.json b/frontend/apps/artcraft-webapp/tsconfig.app.json
index 15daebf644..b0ce196f48 100644
--- a/frontend/apps/artcraft-webapp/tsconfig.app.json
+++ b/frontend/apps/artcraft-webapp/tsconfig.app.json
@@ -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"
},
diff --git a/frontend/apps/artcraft-webapp/tsconfig.json b/frontend/apps/artcraft-webapp/tsconfig.json
index 23a7c50fd2..e53f73d97d 100644
--- a/frontend/apps/artcraft-webapp/tsconfig.json
+++ b/frontend/apps/artcraft-webapp/tsconfig.json
@@ -11,6 +11,9 @@
{
"path": "../../libs/components/upload-modal"
},
+ {
+ "path": "../../libs/components/pagedraw"
+ },
{
"path": "../../libs/components/slider-v2"
},
diff --git a/frontend/apps/artcraft-website/src/components/model-badge-grid.tsx b/frontend/apps/artcraft-website/src/components/model-badge-grid.tsx
index 9b1ea132f0..b6598aadef 100644
--- a/frontend/apps/artcraft-website/src/components/model-badge-grid.tsx
+++ b/frontend/apps/artcraft-website/src/components/model-badge-grid.tsx
@@ -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: (
-
- ),
- },
- { 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: (
-
- ),
- },
- {
- id: "nano-banana-2",
- label: "Nano Banana 2",
- svg: (
-
- ),
- },
- { 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();
+ 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: (
-
- ),
- },
- { 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 (
- {/* Gradient fade overlays */}
+ {/* Gradient fade overlays mask the marquee edges */}
-
- {rows.map((row, rowIdx) => {
- const offset = rowOffsets[rowIdx] ?? 0;
- return (
+
+ {ROWS.map((row, rowIdx) => (
+
- {row.map(({ id, label, svg }, idx) => {
- const isHighlight = highlight && highlight.toLowerCase() === id;
- return (
-
- {svg ? (
- {svg}
- ) : null}
- {label}
-
- );
- })}
+ {/* 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) => (
+
+ {row.map((badge) => (
+
+
+
+
+ {badge.label}
+
+ ))}
+
+ ))}
- );
- })}
+
+ ))}
);
diff --git a/frontend/apps/artcraft-website/src/pages/landing-sd2/landing-sd2.tsx b/frontend/apps/artcraft-website/src/pages/landing-sd2/landing-sd2.tsx
index 7c89afd486..da75a0640a 100644
--- a/frontend/apps/artcraft-website/src/pages/landing-sd2/landing-sd2.tsx
+++ b/frontend/apps/artcraft-website/src/pages/landing-sd2/landing-sd2.tsx
@@ -643,11 +643,7 @@ const LandingSD2 = () => {
all in one place. Log in with your existing subscriptions.
-
+
diff --git a/frontend/apps/artcraft-website/src/pages/landing2/landing2.tsx b/frontend/apps/artcraft-website/src/pages/landing2/landing2.tsx
index e639c51ecc..e5ab8b8ebf 100644
--- a/frontend/apps/artcraft-website/src/pages/landing2/landing2.tsx
+++ b/frontend/apps/artcraft-website/src/pages/landing2/landing2.tsx
@@ -640,11 +640,7 @@ const Landing = () => {
all in one place. Log in with your existing subscriptions.
-
+
diff --git a/frontend/apps/artcraft-website/src/pages/landing3/landing3.tsx b/frontend/apps/artcraft-website/src/pages/landing3/landing3.tsx
index c72d88f114..6b61ac424b 100644
--- a/frontend/apps/artcraft-website/src/pages/landing3/landing3.tsx
+++ b/frontend/apps/artcraft-website/src/pages/landing3/landing3.tsx
@@ -115,7 +115,6 @@ const MADE_WITH_VIDEOS = [
const MANIFESTO_WORDS: ReadonlyArray = [
"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("[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(
"[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(
+ "[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) => (
+
+ {w === "control" ? (
+
+ {w}
+
+ ) : (
+ w
+ )}
+ {i < MANIFESTO_WORDS.length - 1 ? " " : ""}
+
+ ))}
@@ -729,7 +800,18 @@ const Landing3 = () => {
data-manifesto-word
className="inline-block opacity-15 will-change-[opacity,transform]"
>
- {w}
+ {w === "control" ? (
+
+ {w}
+
+
+ ) : (
+ w
+ )}
{i < MANIFESTO_WORDS.length - 1 ? " " : ""}
))}
@@ -758,7 +840,7 @@ const Landing3 = () => {
{/* FEATURES: alternating cards */}
-
+
{
speed={-22}
rotate={8}
/>
-
+
{FEATURES.map((feature, i) => (
-
-
-
-
-
- {feature.label}
-
+
+
+
+
+
+ {feature.label}
+
+
+
+ {feature.title}
+
+
+ {feature.description}
+
-
- {feature.title}
-
-
- {feature.description}
-
-
-
-
-
-
+
+
+
+
+
))}
@@ -837,45 +922,57 @@ const Landing3 = () => {
Ownership
- Stop renting from{" "}
- websites.
+ Stop renting from
+ websites.
- ArtCraft is yours to own and keep,{" "}
- forever.
- 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.
- {/* Websites column */}
-
-
-
- Other tools
-
-
-
- The Rental Trap
-
-
- 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.
-
-
- {["No ownership", "Monthly fees"].map((tag) => (
-
+ {/* Websites column — deliberately styled to feel like a downgrade */}
+
+
+
+
+
- {tag}
+ Other tools
- ))}
+
+
+ The Rental Trap
+
+
+ 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.
+
+
+ {["No ownership", "Monthly fees", "Locked in"].map((tag) => (
+
+
+ {tag}
+
+ ))}
+
@@ -906,18 +1003,20 @@ const Landing3 = () => {
keys, or use ours.
- {["Yours forever", "BYO keys"].map((tag) => (
-
-
- {tag}
-
- ))}
+ {["Yours forever", "BYO keys", "No subscriptions needed"].map(
+ (tag) => (
+
+
+ {tag}
+
+ ),
+ )}
@@ -1073,11 +1172,7 @@ const Landing3 = () => {
all in one place. Log in with your existing subscriptions.
-
+
@@ -1191,25 +1286,30 @@ const Landing3 = () => {
{/* COMMUNITY CTA */}
-
-
-
- Open source & community-driven
-
-
- Join the conversation on Discord and star us on GitHub.
-
-
+
+
+
+ {/*
+
+ */}
+
+
+ Join our community
+
+
+ ArtCraft is open source and community-driven. Come build with
+ us.
+
+
+
+