diff --git a/CLAUDE.md b/CLAUDE.md
index d2410e56..450bba6b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -80,3 +80,13 @@ bun run build # production-сборка сайта
bun run typecheck # tsc --noEmit
bun run lint # biome check
bun run registry:build # пересборка registry-artifacts из registry/
+
+
+
+# This is NOT the Next.js you know
+
+This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices.
+
+This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean.
+
+
diff --git a/app/(home)/blog/[slug]/page.tsx b/app/(home)/blog/[slug]/page.tsx
index 44086464..efe6b18f 100644
--- a/app/(home)/blog/[slug]/page.tsx
+++ b/app/(home)/blog/[slug]/page.tsx
@@ -1,12 +1,20 @@
-import { ArrowLeft } from "lucide-react";
import type { Metadata } from "next";
import Link from "next/link";
import { notFound } from "next/navigation";
import { blog } from "@/.source/server";
import { GuideVideo } from "@/components/docs/guide-video";
-import { blogDateFormatter, blogSlug } from "@/lib/blog";
-import { sortByDateDesc } from "@/lib/changelog";
+import { blogSlug } from "@/lib/blog";
+import { cn } from "@/lib/utils";
import { getMDXComponents } from "@/mdx-components";
+import { landingButtonVariants } from "../../components/landing/ui/landing-button";
+import { CopyTextButton } from "../../components/site-page/copy-text-button";
+import { pageHeaderButton } from "../../components/site-page/page-header";
+import { MorePosts } from "../components/more-posts";
+import { PostIndexNav } from "../components/post-index-nav";
+import { PostMeta } from "../components/post-meta";
+import { ScreeningPlayer } from "../components/screening-player";
+import { POST_KIND_LABEL } from "../lib/post-types";
+import { getBlogPosts } from "../lib/posts";
type PageProps = { params: Promise<{ slug: string }> };
@@ -54,87 +62,97 @@ export async function generateMetadata({
const postComponents = { ...getMDXComponents(), GuideVideo };
+const MORE_POSTS = 3;
+
export default async function BlogPostPage({ params }: PageProps) {
const { slug } = await params;
const post = getPost(slug);
if (!post) notFound();
- const iso = post.date.toISOString().slice(0, 10);
+ const posts = await getBlogPosts();
+ const index = posts.findIndex((item) => item.slug === slug);
+ const summary = posts[index];
+ const more = [...posts.slice(index + 1), ...posts.slice(0, index)].slice(
+ 0,
+ MORE_POSTS,
+ );
+ const headings = post.toc.filter((item) => item.depth <= 3);
const MDX = post.body;
- const sorted = sortByDateDesc(blog);
- const index = sorted.findIndex((p) => blogSlug(p) === slug);
- const next = index === -1 ? undefined : sorted[index + 1];
return (
-
-
+
+ );
+}
diff --git a/app/(home)/blog/components/featured-post.tsx b/app/(home)/blog/components/featured-post.tsx
new file mode 100644
index 00000000..a7461c8a
--- /dev/null
+++ b/app/(home)/blog/components/featured-post.tsx
@@ -0,0 +1,77 @@
+import { cn } from "@/lib/utils";
+import {
+ LandingButton,
+ landingButtonVariants,
+} from "../../components/landing/ui/landing-button";
+import { CopyTextButton } from "../../components/site-page/copy-text-button";
+import { type BlogPostSummary, POST_KIND_LABEL } from "../lib/post-types";
+import { BeatsList } from "./beats-list";
+import { PostMeta } from "./post-meta";
+import { ScreeningPlayer } from "./screening-player";
+
+const BUTTON =
+ "h-12 w-full rounded-control-sm px-4 text-[15px] leading-5 font-medium md:h-10 md:w-auto";
+
+/**
+ * The screening room: the newest launch video in its player, next to the
+ * first beats of its storyboard and the two ways in (read it, copy the
+ * prompt that rebuilds it).
+ */
+export function FeaturedPost({ post }: { post: BlogPostSummary }) {
+ return (
+
+
+ {post.video && (
+
+ )}
+
+
+
+
+
+ {post.title}
+
+
+ {post.description}
+
+
+
+
+
+
+
+ Read the making-of
+
+ {post.prompt && (
+
+ Copy the prompt
+
+ )}
+
+
+
+
+
+ );
+}
diff --git a/app/(home)/blog/components/guide-card.tsx b/app/(home)/blog/components/guide-card.tsx
new file mode 100644
index 00000000..73a4e59f
--- /dev/null
+++ b/app/(home)/blog/components/guide-card.tsx
@@ -0,0 +1,35 @@
+import { LandingButton } from "../../components/landing/ui/landing-button";
+
+const BUTTON =
+ "h-12 rounded-control-sm px-4 text-[15px] leading-5 font-medium md:h-10";
+
+/** Closing card of the index: where the recipe behind every cut lives. */
+export function GuideCard() {
+ return (
+
+ );
+}
diff --git a/app/(home)/blog/components/more-posts.tsx b/app/(home)/blog/components/more-posts.tsx
new file mode 100644
index 00000000..6b42af70
--- /dev/null
+++ b/app/(home)/blog/components/more-posts.tsx
@@ -0,0 +1,35 @@
+import Link from "next/link";
+import type { BlogPostSummary } from "../lib/post-types";
+import { PostCard } from "./post-card";
+
+/** The next three posts, newest first, under the article. */
+export function MorePosts({ posts }: { posts: BlogPostSummary[] }) {
+ if (posts.length === 0) return null;
+
+ return (
+
+
+
+ More making-of stories
+
+
+ All posts
+
+
+
+ {posts.map((post) => (
+
+ ))}
+
+
+ );
+}
diff --git a/app/(home)/blog/components/post-card.tsx b/app/(home)/blog/components/post-card.tsx
new file mode 100644
index 00000000..4baeb2c1
--- /dev/null
+++ b/app/(home)/blog/components/post-card.tsx
@@ -0,0 +1,67 @@
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { type BlogPostSummary, POST_KIND_LABEL } from "../lib/post-types";
+import { PostMeta } from "./post-meta";
+import { PostPoster } from "./post-poster";
+
+/**
+ * Index card: poster, meta, title, description; the whole card links to the
+ * post. `lg` sits two-up, `sm` in the three-column grid.
+ */
+export function PostCard({
+ post,
+ size,
+}: {
+ post: BlogPostSummary;
+ size: "lg" | "sm";
+}) {
+ const lg = size === "lg";
+ const beats = post.beats.length;
+
+ return (
+
+
+
+ {lg || beats === 0 ? (
+
+ ) : (
+
+ )}
+
+ {post.title}
+
+
+ {post.description}
+
+
+
+ );
+}
diff --git a/app/(home)/blog/components/post-index-nav.tsx b/app/(home)/blog/components/post-index-nav.tsx
new file mode 100644
index 00000000..9add8484
--- /dev/null
+++ b/app/(home)/blog/components/post-index-nav.tsx
@@ -0,0 +1,68 @@
+"use client";
+
+import { type ReactNode, useMemo } from "react";
+import { cn } from "@/lib/utils";
+import { useActiveSection } from "../../components/site-page/use-active-section";
+
+export type PostHeading = { title: ReactNode; url: string; depth: number };
+
+/**
+ * Sticky "In this post" index beside the article, numbered like the legal
+ * page; the heading being read is raised.
+ */
+export function PostIndexNav({ headings }: { headings: PostHeading[] }) {
+ const ids = useMemo(
+ () => headings.map((heading) => heading.url.replace(/^#/, "")),
+ [headings],
+ );
+ const active = useActiveSection(ids);
+
+ return (
+
+ );
+}
diff --git a/app/(home)/blog/components/post-index.tsx b/app/(home)/blog/components/post-index.tsx
new file mode 100644
index 00000000..b1098b14
--- /dev/null
+++ b/app/(home)/blog/components/post-index.tsx
@@ -0,0 +1,95 @@
+"use client";
+
+import { useState } from "react";
+import { cn } from "@/lib/utils";
+import {
+ type BlogPostSummary,
+ POST_KIND_LABEL,
+ type PostKind,
+} from "../lib/post-types";
+import { GuideCard } from "./guide-card";
+import { PostCard } from "./post-card";
+
+type Filter = "all" | PostKind;
+
+const FILTERS: { id: Filter; label: string }[] = [
+ { id: "all", label: "All posts" },
+ { id: "launch", label: `${POST_KIND_LABEL.launch}s` },
+ { id: "product", label: POST_KIND_LABEL.product },
+];
+
+/**
+ * Everything but the featured post: a kind filter, the two newest as large
+ * cards, the rest in a three-column grid closed by the guide card.
+ */
+export function PostIndex({
+ posts,
+ counts,
+}: {
+ posts: BlogPostSummary[];
+ /** Totals per filter, featured post included. */
+ counts: Record;
+}) {
+ const [filter, setFilter] = useState("all");
+ const shown =
+ filter === "all" ? posts : posts.filter((post) => post.kind === filter);
+ const large = shown.slice(0, 2);
+ const rest = shown.slice(2);
+
+ return (
+
+
+
+
+
+ {large.length > 0 && (
+
+ {large.map((post) => (
+
+ ))}
+
+ )}
+
+
+ {rest.map((post) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/(home)/blog/components/post-meta.tsx b/app/(home)/blog/components/post-meta.tsx
new file mode 100644
index 00000000..bd744c9b
--- /dev/null
+++ b/app/(home)/blog/components/post-meta.tsx
@@ -0,0 +1,42 @@
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+/** Date (mono on desktop) followed by an optional tag or trailing note. */
+export function PostMeta({
+ iso,
+ date,
+ tag,
+ trailing,
+ className,
+}: {
+ iso: string;
+ date: string;
+ tag?: ReactNode;
+ /** Pushed to the far end, e.g. "9 beats". */
+ trailing?: ReactNode;
+ className?: string;
+}) {
+ return (
+
+ );
+}
+
+/** The four blocks the agent picked, swapping as a wave per prompt. */
+export function PickedRow() {
+ const { state, reducedMotion } = useLoop();
+
+ return (
+
+ {[0, 1, 2, 3].map((slot) => (
+
+ ))}
+
+ );
+}
+
+/**
+ * One pill. The pill itself never moves or resizes: every block that can land
+ * in this slot is laid out invisibly in the same grid cell, so the pill is as
+ * wide as the longest of them, and only the content rolls vertically inside
+ * it (clipped), like a slot-machine reel.
+ */
+function PickedSlot({
+ slot,
+ activeIndex,
+ reducedMotion,
+}: {
+ slot: number;
+ activeIndex: number;
+ reducedMotion: boolean;
+}) {
+ const block = SCENARIOS[activeIndex].picked[slot];
+ const variants = rollVariants(reducedMotion, {
+ duration: PICK_DURATION,
+ ease: EASE_OUT_QUAD,
+ delay: slot * PICK_STAGGER,
+ });
+
+ return (
+
+ );
+}
diff --git a/app/(home)/components/landing/sections/hero/hero-headline.tsx b/app/(home)/components/landing/sections/hero/hero-headline.tsx
new file mode 100644
index 00000000..f5072b98
--- /dev/null
+++ b/app/(home)/components/landing/sections/hero/hero-headline.tsx
@@ -0,0 +1,49 @@
+import type { ReactNode } from "react";
+import { HeroAeTile, HeroVideoTile } from "./tiles/hero-tiles";
+
+const HEADLINE = "You want the video, not the ordeal";
+
+export const TILE_SLOT = "size-[46px] md:size-[0.839em]";
+
+/**
+ * Display headline with two rotated tiles between the words. The tiles are
+ * sized in `em`, so the whole line scales with the font size per breakpoint.
+ * Mobile breaks into four lines ("You want / the ▢ video, / not the ▢ /
+ * ordeal"), md+ into two.
+ */
+export function HeroHeadline({
+ videoTile = ,
+ aeTile = ,
+}: {
+ videoTile?: ReactNode;
+ aeTile?: ReactNode;
+}) {
+ return (
+
+ {HEADLINE}
+
+
+ You want the
+
+
+ the
+ {videoTile}
+ video,
+
+
+
+
+ not the
+ {aeTile}
+
+ ordeal
+
+
+ );
+}
diff --git a/app/(home)/components/landing/sections/hero/prompt-composer.tsx b/app/(home)/components/landing/sections/hero/prompt-composer.tsx
new file mode 100644
index 00000000..8bb0a1c0
--- /dev/null
+++ b/app/(home)/components/landing/sections/hero/prompt-composer.tsx
@@ -0,0 +1,84 @@
+import { COMPONENT_COUNT, STUDIO_URL } from "@/config/site";
+import { MetalRing } from "../../ui/metal-ring";
+import { StudioAppIcon } from "../../ui/remocn-mark";
+import {
+ AgentLine,
+ ComposerLoop,
+ PickedRow,
+ TypedPrompt,
+} from "./composer/composer-loop";
+
+/**
+ * Illustration of agents using the remocn skill: prompts type themselves,
+ * get selected and replaced, the agent swaps and its picked blocks ripple in.
+ * Not a real input: the send button is decorative; "Run in Remocn Studio" and
+ * the picked chips are live links.
+ */
+export function PromptComposer() {
+ return (
+
+ );
+}
diff --git a/app/(home)/components/landing/sections/hero/prompt-stage.tsx b/app/(home)/components/landing/sections/hero/prompt-stage.tsx
new file mode 100644
index 00000000..cb6ba684
--- /dev/null
+++ b/app/(home)/components/landing/sections/hero/prompt-stage.tsx
@@ -0,0 +1,26 @@
+import { ComposerStatus } from "./composer-status";
+import { FloatingScene, ROW_SCENES } from "./floating-scene";
+import { PromptComposer } from "./prompt-composer";
+import { HeroSceneField } from "./scenes/hero-scene-field";
+
+/**
+ * The composer with the scenes it produced. From xl up the scenes float
+ * around it, rotated; below xl they become a horizontal row under it.
+ */
+export function PromptStage() {
+ return (
+
+ );
+}
+
+/**
+ * One numbered block of the session. A two-column grid: the badge, then the
+ * content; anything after the first content row (the player) spans both
+ * columns on mobile and stays in the content column on desktop.
+ */
+function SessionBlock({
+ index,
+ accent,
+ className,
+ children,
+}: {
+ index: number;
+ accent?: boolean;
+ className?: string;
+ children: ReactNode;
+}) {
+ return (
+
+ >
+ );
+ }
+}
diff --git a/app/(home)/components/landing/sections/how-it-works/session-player.tsx b/app/(home)/components/landing/sections/how-it-works/session-player.tsx
new file mode 100644
index 00000000..4eeda1f7
--- /dev/null
+++ b/app/(home)/components/landing/sections/how-it-works/session-player.tsx
@@ -0,0 +1,53 @@
+/** Timeline marks; the 1px ticks sit between them. */
+const MARKS = ["0s", "4s", "8s", "13s"] as const;
+const ACTIVE_MARK = "4s";
+
+/**
+ * The preview the agent opens in block 3: the violet shader with a title
+ * and a mini scrubber (play glyph, marked timeline with a selected range,
+ * timecode). Static mock, no playback.
+ */
+export function SessionPlayer() {
+ return (
+
+ );
+ })}
+
+ );
+}
diff --git a/app/(home)/components/landing/sections/studio-pipeline/studio-copy.tsx b/app/(home)/components/landing/sections/studio-pipeline/studio-copy.tsx
new file mode 100644
index 00000000..374aea46
--- /dev/null
+++ b/app/(home)/components/landing/sections/studio-pipeline/studio-copy.tsx
@@ -0,0 +1,54 @@
+import { STUDIO_URL } from "@/config/site";
+import { StudioDownloadButton } from "../../ui/download-button";
+import { LandingButton } from "../../ui/landing-button";
+import { StudioAppIcon } from "../../ui/remocn-mark";
+import { SectionTitle } from "../../ui/section-title";
+
+/** Left column of the Studio block: app badge, heading, pitch and CTAs. */
+export function StudioCopy() {
+ return (
+
+
+
+
+
+
+ Remocn Studio
+
+
+ macOS & Linux · free
+
+
+
+
+
+ The agent follows motion rules, checks every frame and fixes what
+ looks off. It runs on the Claude Code, Codex, Copilot or Grok plan you
+ already pay for.
+
+
+
+
+
+ See how it works
+
+
+
+ );
+}
diff --git a/app/(home)/components/landing/sections/testimonials-wall.tsx b/app/(home)/components/landing/sections/testimonials-wall.tsx
new file mode 100644
index 00000000..9aea93f0
--- /dev/null
+++ b/app/(home)/components/landing/sections/testimonials-wall.tsx
@@ -0,0 +1,28 @@
+import { SectionTitle } from "../ui/section-title";
+import { WallColumns } from "./testimonials-wall/wall-columns";
+import { WallFooter } from "./testimonials-wall/wall-footer";
+import { XSpotlight } from "./testimonials-wall/x-spotlight";
+
+/** "Loved by builders": a clipped wall of X posts (desktop), a spotlight (mobile). */
+export function TestimonialsWall() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/(home)/components/landing/sections/testimonials-wall/wall-columns.tsx b/app/(home)/components/landing/sections/testimonials-wall/wall-columns.tsx
new file mode 100644
index 00000000..cc00c79d
--- /dev/null
+++ b/app/(home)/components/landing/sections/testimonials-wall/wall-columns.tsx
@@ -0,0 +1,64 @@
+import { getXPost } from "@/config/x-posts";
+import { cn } from "@/lib/utils";
+import { XPostCard } from "./x-post-card";
+
+/** Handles per column, top to bottom, as laid out in the design. */
+const COLUMNS: string[][] = [
+ ["shadcn", "bhoomikacodes", "yesadok", "haydenbleasel"],
+ ["JNYBGR", "dingyi", "denicmarko", "milessy_bc"],
+ ["orcdev", "davidhaz", "guillaume_rygn", "bzagrodzki"],
+];
+
+const COLUMN_REVEAL = ["", "[--reveal-i:1]", "[--reveal-i:2]"];
+
+const COLUMN_MOTION = [
+ "[--marquee-duration:62s]",
+ "[--marquee-duration:74s] [animation-direction:reverse]",
+ "[--marquee-duration:56s]",
+];
+
+function ColumnCopy({
+ handles,
+ hidden,
+}: {
+ handles: string[];
+ hidden?: boolean;
+}) {
+ return (
+
+
+ );
+}
diff --git a/app/(home)/components/landing/sections/testimonials-wall/x-spotlight.tsx b/app/(home)/components/landing/sections/testimonials-wall/x-spotlight.tsx
new file mode 100644
index 00000000..374f9a6e
--- /dev/null
+++ b/app/(home)/components/landing/sections/testimonials-wall/x-spotlight.tsx
@@ -0,0 +1,89 @@
+import Image from "next/image";
+import { getXPost, X_SPOTLIGHT } from "@/config/x-posts";
+
+const linkFocus =
+ "outline-none focus-visible:outline-2 focus-visible:outline-offset-2 focus-visible:outline-primary";
+
+function capitalize(value: string) {
+ return value.charAt(0).toUpperCase() + value.slice(1);
+}
+
+/**
+ * Mobile take on the wall: one pulled quote, then a row of the people who
+ * posted. The active person is a pill, the rest are faded avatars; every item
+ * links to its post on X.
+ */
+export function XSpotlight() {
+ const active = getXPost(X_SPOTLIGHT.handle);
+
+ return (
+
+
+ What builders say on X
+
+
+
+
+ “{X_SPOTLIGHT.quote}”
+
+
+
+
+ {X_SPOTLIGHT.row.map((handle) => {
+ const post = getXPost(handle);
+
+ if (handle === active.handle) {
+ return (
+
+ );
+}
diff --git a/app/(home)/components/landing/sections/why-remocn/reasons.tsx b/app/(home)/components/landing/sections/why-remocn/reasons.tsx
new file mode 100644
index 00000000..7f8d2e8d
--- /dev/null
+++ b/app/(home)/components/landing/sections/why-remocn/reasons.tsx
@@ -0,0 +1,45 @@
+type WhyReason = {
+ title: string;
+ body: string;
+};
+
+const WHY_REASONS: WhyReason[] = [
+ {
+ title: "No After Effects",
+ body: "Nothing to install, nothing to learn. If you can describe it, you can make it.",
+ },
+ {
+ title: "Not a screen recording",
+ body: "A cursor wandering across your app isn’t a demo. This looks shipped on purpose.",
+ },
+ {
+ title: "Yours to keep",
+ body: "The code copies into your repo. MIT, no runtime, no account, nothing to cancel.",
+ },
+ {
+ title: "Changes in seconds",
+ body: "Say what feels wrong and watch it change. No revision round, no queue.",
+ },
+];
+
+/** Four reasons under the comparison cards, each under a hairline rule. */
+export function Reasons() {
+ return (
+
+ );
+}
diff --git a/app/(home)/components/landing/ui/section-title.tsx b/app/(home)/components/landing/ui/section-title.tsx
new file mode 100644
index 00000000..de931047
--- /dev/null
+++ b/app/(home)/components/landing/ui/section-title.tsx
@@ -0,0 +1,47 @@
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+
+/**
+ * Two-tone section heading: the first line in ink, the continuation in a
+ * muted grey, both at the h2 size (56/58, -0.045em).
+ */
+export function SectionTitle({
+ title,
+ continuation,
+ align = "left",
+ as: Tag = "h2",
+ className,
+ continuationClassName,
+}: {
+ title: ReactNode;
+ continuation?: ReactNode;
+ align?: "left" | "center";
+ as?: "h1" | "h2" | "h3";
+ className?: string;
+ continuationClassName?: string;
+}) {
+ return (
+
+ {title}
+ {continuation && (
+
+ {continuation}
+
+ )}
+
+ );
+}
diff --git a/app/(home)/components/mega-menu.tsx b/app/(home)/components/mega-menu.tsx
index 2526dda5..8aa21a32 100644
--- a/app/(home)/components/mega-menu.tsx
+++ b/app/(home)/components/mega-menu.tsx
@@ -80,7 +80,7 @@ export function MegaMenuPanel({
y: -6,
transition: reduced
? { duration: 0 }
- : { duration: 0.13, ease: "easeIn" },
+ : { duration: 0.13, ease: "easeOut" },
},
}}
className="origin-top rounded-[20px] bg-popover p-3 text-popover-foreground shadow-lg ring-1 ring-foreground/5 dark:ring-foreground/10"
@@ -197,7 +197,7 @@ function MenuRow({
onMouseEnter={onHover}
onFocus={onHover}
className={cn(
- "flex min-w-0 items-center rounded-lg px-3 text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/40",
+ "flex min-w-0 items-center rounded-lg px-3 text-sm font-medium transition-colors duration-100 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring",
hovered ? "bg-muted text-foreground" : "text-muted-foreground",
)}
>
diff --git a/app/(home)/components/section-heading.tsx b/app/(home)/components/section-heading.tsx
deleted file mode 100644
index f8487fec..00000000
--- a/app/(home)/components/section-heading.tsx
+++ /dev/null
@@ -1,61 +0,0 @@
-import type { ReactNode } from "react";
-import { cn } from "@/lib/utils";
-import { FadeUp } from "./fade-up";
-
-/**
- * Canonical section header used across the landing page so the type
- * hierarchy stays consistent everywhere:
- * eyebrow (xs, muted, mono) → title (h2) → lead (body, muted)
- * An optional `action` slot sits inline on the right at desktop widths.
- */
-export function SectionHeading({
- title,
- lead,
- action,
- align = "start",
- animated = true,
- className,
-}: {
- eyebrow?: string;
- title: ReactNode;
- lead?: ReactNode;
- action?: ReactNode;
- align?: "start" | "center";
- animated?: boolean;
- className?: string;
-}) {
- const centered = align === "center";
-
- const content = (
-
-
- {/*{eyebrow && (
-
- {eyebrow}
-
- )}*/}
-
- {title}
-
- {lead && (
-
- {lead}
-
- )}
-
- {action &&
{action}
}
-
- );
-
- return animated ? {content} : content;
-}
diff --git a/app/(home)/components/sections/faq.tsx b/app/(home)/components/sections/faq.tsx
deleted file mode 100644
index 2a596fbb..00000000
--- a/app/(home)/components/sections/faq.tsx
+++ /dev/null
@@ -1,54 +0,0 @@
-import { FAQ_ITEMS } from "@/config/landing";
-import { SUPPORT_EMAIL } from "@/config/site";
-import { cn } from "@/lib/utils";
-import { SectionHeading } from "../section-heading";
-
-const EYEBROW = "Questions";
-const TITLE = "Before you ask";
-const LEAD =
- "The things people want to know before they open a terminal for the first time.";
-
-export function Faq({ className }: { className?: string }) {
- return (
-
-
-
-
-
- {FAQ_ITEMS.map((item, i) => (
-
0 && "border-t border-border/60",
- )}
- >
-
- {item.question}
-
-
- {item.answer}
-
-
- ))}
-
-
-
- Still stuck?{" "}
-
- Email us
- {" "}
- and we’ll walk you through it.
-
-
-
- );
-}
diff --git a/app/(home)/components/sections/final-cta.tsx b/app/(home)/components/sections/final-cta.tsx
deleted file mode 100644
index 98f8d2ce..00000000
--- a/app/(home)/components/sections/final-cta.tsx
+++ /dev/null
@@ -1,63 +0,0 @@
-"use client";
-
-import { ArrowRight } from "lucide-react";
-import Link from "next/link";
-import { Button } from "@/components/ui/button";
-import { GITHUB_URL } from "@/config/site";
-import { useTrackEvent } from "@/lib/analytics";
-
-export function FinalCTA() {
- const trackEvent = useTrackEvent();
-
- return (
-
-
-
-
- Your product deserves better than a screen recording.
-
-
-
- An agent, an empty folder, ten minutes. It’s open source, all the
- way down.
-
-
- );
-}
diff --git a/app/(home)/components/site-footer.tsx b/app/(home)/components/site-footer.tsx
index 27a013e5..a9fcc355 100644
--- a/app/(home)/components/site-footer.tsx
+++ b/app/(home)/components/site-footer.tsx
@@ -1,90 +1,48 @@
-import { ArrowUpRight } from "lucide-react";
import Link from "next/link";
-import {
- externalLinkProps,
- FOOTER_GROUPS,
- GITHUB_URL,
- SUPPORT_EMAIL,
- THREADS_URL,
- X_URL,
-} from "@/config/site";
-import { cn } from "@/lib/utils";
-import { GitHubIcon } from "./github-icon";
-import { HeaderLogo } from "./header-parts";
-import { ThreadsIcon } from "./threads-icon";
-import { XIcon } from "./x-icon";
-
-const SOCIALS = [
- { href: GITHUB_URL, label: "GitHub", Icon: GitHubIcon },
- { href: X_URL, label: "X", Icon: XIcon },
- { href: THREADS_URL, label: "Threads", Icon: ThreadsIcon },
-];
-
-const LINK_CLASS =
- "w-fit text-muted-foreground transition-colors hover:text-foreground focus-visible:text-foreground focus-visible:outline-none";
+import { externalLinkProps, FOOTER_LINKS } from "@/config/site";
+import { LandingWordmark } from "./landing/header/landing-wordmark";
+/**
+ * Footer: wordmark + "MIT, built on Remotion" on the left, one flat row of
+ * links on the right (`lg`+). Below `lg` it stacks: a hairline on top, the
+ * wordmark with the tagline under it, and the links in a two-column grid.
+ */
export function SiteFooter() {
return (
-
-
);
}
diff --git a/app/(home)/components/site-header.tsx b/app/(home)/components/site-header.tsx
index f5bccaa6..898e9e33 100644
--- a/app/(home)/components/site-header.tsx
+++ b/app/(home)/components/site-header.tsx
@@ -1,14 +1,104 @@
+import Link from "next/link";
+import type { ReactNode } from "react";
+import { DOCS_MENU_HREF } from "@/config/docs-directory";
import { NAV_LINKS } from "@/config/site";
-import { NavDesktop } from "./header-nav";
-import { HeaderActions, HeaderLogo } from "./header-parts";
-import { StickyHeaderShell } from "./sticky-header-shell";
+import { HEADER_LAYOUT, headerLayoutVars } from "@/lib/header-layout";
+import { cn } from "@/lib/utils";
+import { DocsDirectory } from "./landing/header/docs-directory";
+import { GitHubStarsLink } from "./landing/header/github-stars-link";
+import {
+ LandingMenu,
+ LandingMenuPanel,
+ LandingNav,
+} from "./landing/header/landing-nav";
+import { LandingWordmark } from "./landing/header/landing-wordmark";
+import { MobileMenu } from "./landing/header/mobile-menu";
+import { HeaderPill } from "./landing/header/pill-track";
+import { RegistryHealthBadge } from "./landing/header/registry-health-badge";
+import { landingButtonVariants } from "./landing/ui/landing-button";
-export function SiteHeader() {
+const GET_STARTED_HREF = "/docs/getting-started/introduction";
+
+const CAPSULE = "flex items-center bg-capsule";
+
+/**
+ * Site header: two capsules in normal flow (no fixed/scroll behaviour, no
+ * background of its own — the page behind it shows through). Desktop (`xl`):
+ * logo + nav capsule and GitHub / health / Get started capsule, 28px + 64px.
+ * `lg`: the right capsule keeps only Get started. Below `lg`: logo capsule,
+ * Get started and a menu button that opens the full-screen sheet. Desktop
+ * sizes come from `config/header-layout.json` (tuned in `/lab/header-layout`).
+ */
+export function SiteHeader({
+ mobileAction,
+}: {
+ /** Replaces Get started below `lg` (the docs put search there). */
+ mobileAction?: ReactNode;
+} = {}) {
return (
-
-
-
-
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Get started
+
+ {mobileAction &&
{mobileAction}
}
+
+
+
+
+
+
+
+
+
);
}
diff --git a/app/(home)/components/site-page/copy-text-button.tsx b/app/(home)/components/site-page/copy-text-button.tsx
new file mode 100644
index 00000000..b8bdf907
--- /dev/null
+++ b/app/(home)/components/site-page/copy-text-button.tsx
@@ -0,0 +1,59 @@
+"use client";
+
+import { type ReactNode, useEffect, useRef, useState } from "react";
+import { useTrackEvent } from "@/lib/analytics";
+
+/**
+ * Button that copies `text` and swaps its label to "Copied" for 1.5s.
+ * Pass `installComponent` to report an `install_command_copied` event.
+ */
+export function CopyTextButton({
+ text,
+ children,
+ copiedLabel = "Copied",
+ installComponent,
+ className,
+ "aria-label": ariaLabel,
+}: {
+ text: string;
+ children: ReactNode;
+ copiedLabel?: ReactNode;
+ installComponent?: string;
+ className?: string;
+ "aria-label"?: string;
+}) {
+ const [copied, setCopied] = useState(false);
+ const timeout = useRef>(null);
+ const trackEvent = useTrackEvent();
+
+ useEffect(() => () => clearTimeout(timeout.current ?? undefined), []);
+
+ async function copy() {
+ try {
+ await navigator.clipboard.writeText(text);
+ } catch {
+ return;
+ }
+ setCopied(true);
+ if (installComponent) {
+ trackEvent("install_command_copied", {
+ component: installComponent,
+ package_manager: "npm",
+ surface: "landing",
+ });
+ }
+ clearTimeout(timeout.current ?? undefined);
+ timeout.current = setTimeout(() => setCopied(false), 1500);
+ }
+
+ return (
+
+ );
+}
diff --git a/app/(home)/components/site-page/page-header.tsx b/app/(home)/components/site-page/page-header.tsx
new file mode 100644
index 00000000..59a9676c
--- /dev/null
+++ b/app/(home)/components/site-page/page-header.tsx
@@ -0,0 +1,63 @@
+import type { ReactNode } from "react";
+import { cn } from "@/lib/utils";
+import { SectionTitle } from "../landing/ui/section-title";
+
+/**
+ * Header of the standalone site pages (blog, showcases, legal, changelog,
+ * stars): a small eyebrow over a two-tone h1 on the left, the lead and the
+ * page actions in a 420px column on the right. Below `xl` it stacks.
+ */
+export function SitePageHeader({
+ eyebrow,
+ title,
+ continuation,
+ lead,
+ children,
+ className,
+ asideClassName,
+}: {
+ eyebrow: ReactNode;
+ title: ReactNode;
+ continuation?: ReactNode;
+ lead?: ReactNode;
+ /** Right column under the lead: buttons, meta. */
+ children?: ReactNode;
+ className?: string;
+ asideClassName?: string;
+}) {
+ return (
+
+
+
{eyebrow}
+
+
+ {(lead || children) && (
+
+ {lead && (
+
{lead}
+ )}
+ {children}
+
+ )}
+
+ );
+}
+
+/** Compact filled button used in page headers (40px desktop, 44px mobile). */
+export const pageHeaderButton =
+ "inline-flex h-11 shrink-0 items-center justify-center gap-2 rounded-control-sm px-4 text-[15px] leading-5 whitespace-nowrap outline-none transition-[background-color,box-shadow,translate] duration-150 ease-out focus-visible:ring-3 focus-visible:ring-ring active:translate-y-px xl:h-10 xl:text-small";
diff --git a/app/(home)/components/site-page/play-glyph.tsx b/app/(home)/components/site-page/play-glyph.tsx
new file mode 100644
index 00000000..bb6e7ddd
--- /dev/null
+++ b/app/(home)/components/site-page/play-glyph.tsx
@@ -0,0 +1,26 @@
+import { cn } from "@/lib/utils";
+
+/** Solid play triangle in ink, for the white play keys on dark stages. */
+export function PlayGlyph({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+export function PauseGlyph({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/app/(home)/components/site-page/use-active-section.ts b/app/(home)/components/site-page/use-active-section.ts
new file mode 100644
index 00000000..cd1db526
--- /dev/null
+++ b/app/(home)/components/site-page/use-active-section.ts
@@ -0,0 +1,36 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+/**
+ * Id of the section currently being read: the last one whose top has crossed
+ * the upper third of the viewport. Falls back to the first id.
+ */
+export function useActiveSection(ids: readonly string[]) {
+ const [active, setActive] = useState(ids[0]);
+
+ useEffect(() => {
+ const nodes = ids
+ .map((id) => document.getElementById(id))
+ .filter((node): node is HTMLElement => node !== null);
+ if (nodes.length === 0) return;
+
+ const passed = new Set();
+ const observer = new IntersectionObserver(
+ (entries) => {
+ for (const entry of entries) {
+ const above = entry.boundingClientRect.top < window.innerHeight / 3;
+ if (entry.isIntersecting || above) passed.add(entry.target.id);
+ if (!above) passed.delete(entry.target.id);
+ }
+ const current = ids.findLast((id) => passed.has(id));
+ setActive(current ?? ids[0]);
+ },
+ { rootMargin: "0px 0px -66% 0px", threshold: [0, 1] },
+ );
+ for (const node of nodes) observer.observe(node);
+ return () => observer.disconnect();
+ }, [ids]);
+
+ return active;
+}
diff --git a/app/(home)/components/site-page/use-loop-controls.ts b/app/(home)/components/site-page/use-loop-controls.ts
new file mode 100644
index 00000000..49d362f3
--- /dev/null
+++ b/app/(home)/components/site-page/use-loop-controls.ts
@@ -0,0 +1,44 @@
+"use client";
+
+import { useEffect, useState } from "react";
+import { usePrefersReducedMotion } from "@/lib/use-prefers-reduced-motion";
+
+/** Starts `true` so desktop doesn't flash native controls before mount. */
+function useHoverCapable() {
+ const [hoverCapable, setHoverCapable] = useState(true);
+
+ useEffect(() => {
+ const mql = window.matchMedia("(hover: hover)");
+ setHoverCapable(mql.matches);
+ const onChange = (event: MediaQueryListEvent) =>
+ setHoverCapable(event.matches);
+ mql.addEventListener("change", onChange);
+ return () => mql.removeEventListener("change", onChange);
+ }, []);
+
+ return hoverCapable;
+}
+
+/**
+ * Native controls for a muted autoplay loop: shown on hover or keyboard
+ * focus, and always on touch screens or with reduced motion, so the loop can
+ * be paused with any input.
+ */
+export function useLoopControls() {
+ const reducedMotion = usePrefersReducedMotion();
+ const hoverCapable = useHoverCapable();
+ const [hovered, setHovered] = useState(false);
+ const [focused, setFocused] = useState(false);
+
+ return {
+ reducedMotion,
+ videoProps: {
+ controls: !hoverCapable || hovered || focused || reducedMotion,
+ tabIndex: 0,
+ onPointerEnter: () => setHovered(true),
+ onPointerLeave: () => setHovered(false),
+ onFocus: () => setFocused(true),
+ onBlur: () => setFocused(false),
+ },
+ };
+}
diff --git a/app/(home)/components/sponsor-card.tsx b/app/(home)/components/sponsor-card.tsx
deleted file mode 100644
index f893a376..00000000
--- a/app/(home)/components/sponsor-card.tsx
+++ /dev/null
@@ -1,121 +0,0 @@
-import { ArrowRight } from "lucide-react";
-import Link from "next/link";
-import type { Sponsor, SponsorTier } from "@/config/sponsors";
-import { cn } from "@/lib/utils";
-
-export type LogoTreatment = "color" | "reveal" | "muted";
-
-export const TIER_LABEL: Record = {
- legendary: "Legendary",
- featured: "Featured",
- partner: "Partner",
- builder: "Builder",
-};
-
-export const TIER_STYLE: Record<
- SponsorTier,
- { span: string; logoArea: string; maxH: string; treatment: LogoTreatment }
-> = {
- legendary: {
- span: "col-span-2",
- logoArea: "min-h-24",
- maxH: "max-h-12",
- treatment: "color",
- },
- featured: {
- span: "col-span-2",
- logoArea: "min-h-20",
- maxH: "max-h-10",
- treatment: "color",
- },
- partner: {
- span: "col-span-1",
- logoArea: "min-h-16",
- maxH: "max-h-8",
- treatment: "reveal",
- },
- builder: {
- span: "col-span-1",
- logoArea: "min-h-16",
- maxH: "max-h-8",
- treatment: "muted",
- },
-};
-
-export function SponsorCard({
- sponsor,
- label,
- logoArea,
- maxH,
- treatment,
-}: {
- sponsor: Sponsor;
- label: string;
- logoArea: string;
- maxH: string;
- treatment: LogoTreatment;
-}) {
- return (
-
-
+ );
+}
diff --git a/app/(home)/legal/components/legal-contact.tsx b/app/(home)/legal/components/legal-contact.tsx
new file mode 100644
index 00000000..50d0fa24
--- /dev/null
+++ b/app/(home)/legal/components/legal-contact.tsx
@@ -0,0 +1,31 @@
+import { SUPPORT_EMAIL } from "@/config/site";
+import { cn } from "@/lib/utils";
+import { landingButtonVariants } from "../../components/landing/ui/landing-button";
+
+/**
+ * "If anything is unclear, email us": under the index on desktop, a card at
+ * the end of the article below `xl`.
+ */
+export function LegalContact({ className }: { className?: string }) {
+ return (
+
+
+ If anything on this page is unclear, email us.
+
+ );
+}
diff --git a/app/(home)/legal/components/legal-hero.tsx b/app/(home)/legal/components/legal-hero.tsx
new file mode 100644
index 00000000..20fcc1a6
--- /dev/null
+++ b/app/(home)/legal/components/legal-hero.tsx
@@ -0,0 +1,54 @@
+import Image from "next/image";
+import { SUPPORT_EMAIL } from "@/config/site";
+import { SitePageHeader } from "../../components/site-page/page-header";
+import { LAST_UPDATED } from "../content";
+
+const META = [
+ { label: "last updated", value: LAST_UPDATED },
+ {
+ label: "questions",
+ value: (
+
+ {SUPPORT_EMAIL}
+
+ ),
+ },
+];
+
+/** "Privacy [satin chip] & Terms" over the muted line, meta on the right. */
+export function LegalHero() {
+ return (
+
+ Privacy
+
+ & Terms
+
+ }
+ continuation="How remocn handles your data."
+ className="gap-6 pb-6 md:gap-5"
+ asideClassName="xl:w-auto xl:pb-1.5"
+ >
+
+ {META.map((item) => (
+
+
{item.label}
+
{item.value}
+
+ ))}
+
+
+ );
+}
diff --git a/app/(home)/legal/components/legal-index.tsx b/app/(home)/legal/components/legal-index.tsx
new file mode 100644
index 00000000..2566b1de
--- /dev/null
+++ b/app/(home)/legal/components/legal-index.tsx
@@ -0,0 +1,76 @@
+"use client";
+
+import { cn } from "@/lib/utils";
+import { useActiveSection } from "../../components/site-page/use-active-section";
+import { LEGAL_INDEX, LEGAL_PARTS } from "../content";
+
+const IDS = LEGAL_INDEX.map((item) => item.id);
+
+/** Desktop sticky index: both parts, numbered, the section in view raised. */
+export function LegalIndex() {
+ const active = useActiveSection(IDS);
+ let offset = 0;
+
+ return (
+
+ );
+}
diff --git a/app/(home)/legal/content.tsx b/app/(home)/legal/content.tsx
new file mode 100644
index 00000000..a704069a
--- /dev/null
+++ b/app/(home)/legal/content.tsx
@@ -0,0 +1,197 @@
+import type { ReactNode } from "react";
+import { GITHUB_URL, SUPPORT_EMAIL } from "@/config/site";
+
+export const LAST_UPDATED = "July 24, 2026";
+
+export type LegalItem = { title: ReactNode; body: ReactNode };
+
+export type LegalSection = {
+ id: string;
+ title: string;
+ /** Lead paragraph(s) under the section title. */
+ body?: ReactNode;
+ /** Bulleted facts, drawn as cards. */
+ items?: LegalItem[];
+ /** Name / description rows, drawn as a table card. */
+ rows?: LegalItem[];
+};
+
+export type LegalPart = {
+ id: string;
+ title: string;
+ sections: LegalSection[];
+};
+
+function A({ href, children }: { href: string; children: ReactNode }) {
+ return (
+
+ {children}
+
+ );
+}
+
+export const Mail = () => (
+ {SUPPORT_EMAIL}
+);
+
+export const LEGAL_INTRO = {
+ lead: "How remocn handles your data, and the terms that apply when you use the site or buy something.",
+ body: (
+ <>
+ remocn (“we”) operates{" "}
+ remocn.dev — an open-source registry of
+ video components for Remotion. If anything on this page is unclear, email
+ us at .
+ >
+ ),
+};
+
+export const LEGAL_PARTS: LegalPart[] = [
+ {
+ id: "privacy",
+ title: "Privacy Policy",
+ sections: [
+ {
+ id: "what-we-collect",
+ title: "What we collect",
+ body: "The site has no accounts and no sign-up. You can browse the docs, copy components, and use everything on the site without giving us any personal data.",
+ items: [
+ {
+ title: "Usage analytics.",
+ body: (
+ <>
+ We use OpenPanel to collect
+ anonymous usage data: pages visited, referrer, browser and
+ device type, and coarse location (country level). This tells us
+ which components people actually use. We do not use advertising
+ trackers, we do not build cross-site profiles, and we never sell
+ data.
+ >
+ ),
+ },
+ {
+ title: "Local preferences.",
+ body: "Your theme choice (light or dark) is stored in your browser’s localStorage and never leaves your device.",
+ },
+ {
+ title: "Server logs.",
+ body: "Our hosting infrastructure keeps standard access logs (IP address, requested URL, timestamp) for security and operations. They are rotated automatically and not used for anything else.",
+ },
+ ],
+ },
+ {
+ id: "payments",
+ title: "Payments",
+ body: (
+ <>
+ Paid products are sold through Creem,
+ our merchant of record. Creem runs the checkout and handles payment
+ processing, invoicing, and taxes. Your card details go directly to
+ Creem and its payment processors — they never touch our servers.
+ After a purchase we receive only what is needed to deliver it: your
+ email address, the product, and the order details. How Creem handles
+ your data is described in{" "}
+ Creem’s privacy policy.
+ >
+ ),
+ },
+ {
+ id: "third-party-services",
+ title: "Third-party services",
+ rows: [
+ {
+ title: OpenPanel,
+ body: "Anonymous usage analytics.",
+ },
+ {
+ title: Creem,
+ body: "Checkout and payments, as merchant of record.",
+ },
+ {
+ title: GitHub,
+ body: "Source code, issues, and sponsorships. Anything you do there is governed by GitHub’s own terms.",
+ },
+ ],
+ },
+ {
+ id: "your-data",
+ title: "Your data",
+ body: (
+ <>
+ We keep order emails for as long as we need them to provide support
+ and meet legal obligations. To ask what we hold about you, or to
+ have it deleted, email and we will sort it out.
+ >
+ ),
+ },
+ ],
+ },
+ {
+ id: "terms",
+ title: "Terms of Service",
+ sections: [
+ {
+ id: "free-components",
+ title: "The site and free components",
+ body: (
+ <>
+ The free components in the registry are released under the{" "}
+ MIT license. You can
+ copy them, modify them, and ship them in personal or commercial
+ projects without attribution. The site and the components are
+ provided “as is”, without warranty of any kind.
+ >
+ ),
+ },
+ {
+ id: "paid-products",
+ title: "Paid products",
+ body: "Some products are paid. Unless a product states otherwise at purchase, buying it grants you a non-exclusive license to use it in any number of your own projects, commercial ones included. What it does not grant is the right to redistribute or resell the product itself — for example republishing the components in another registry, template marketplace, or paid bundle.",
+ },
+ {
+ id: "purchases-and-refunds",
+ title: "Purchases and refunds",
+ body: (
+ <>
+ Purchases are processed by Creem as merchant of record, so the sale
+ itself is also subject to the terms Creem presents at checkout. If a
+ product is broken or not as described, email within 14 days
+ of purchase and we will fix it or refund you.
+ >
+ ),
+ },
+ {
+ id: "liability",
+ title: "Liability",
+ body: "To the maximum extent permitted by law, we are not liable for indirect or consequential damages arising from your use of the site or its products, and our total liability is limited to the amount you paid us in the twelve months before the claim. Free products come with no liability at all — they are yours to own, audit, and modify.",
+ },
+ {
+ id: "changes",
+ title: "Changes",
+ body: "We may update this page as the project evolves — for example when new products or services launch. The date at the top reflects the latest revision, and continued use of the site after a change means you accept it.",
+ },
+ {
+ id: "contact",
+ title: "Contact",
+ body: (
+ <>
+ For anything related to privacy, purchases, or these terms:
+ .
+ >
+ ),
+ },
+ ],
+ },
+];
+
+/** Flat, numbered list of sections (01–10) for the index and the article. */
+export const LEGAL_INDEX = LEGAL_PARTS.flatMap((part) => part.sections).map(
+ (section, index) => ({
+ id: section.id,
+ title: section.title,
+ number: String(index + 1).padStart(2, "0"),
+ }),
+);
diff --git a/app/(home)/legal/page.tsx b/app/(home)/legal/page.tsx
index 0e7868ce..d1aba4fe 100644
--- a/app/(home)/legal/page.tsx
+++ b/app/(home)/legal/page.tsx
@@ -1,5 +1,9 @@
import type { Metadata } from "next";
-import { GITHUB_URL, SUPPORT_EMAIL } from "@/config/site";
+import { LegalArticle } from "./components/legal-article";
+import { LegalContact } from "./components/legal-contact";
+import { LegalContentsBar } from "./components/legal-contents-bar";
+import { LegalHero } from "./components/legal-hero";
+import { LegalIndex } from "./components/legal-index";
const DESCRIPTION =
"How remocn handles your data, and the terms that apply to the site, the free components, and paid products.";
@@ -32,164 +36,24 @@ export const metadata: Metadata = {
},
};
-const LAST_UPDATED = "July 24, 2026";
-
export default function LegalPage() {
return (
<>
-
-
-
- Privacy & Terms
-
-
- How remocn handles your data, and the terms that apply when you use
- the site or buy something. Last updated {LAST_UPDATED}.
-
-
-
-
-
-
-
-
- remocn (“we”) operates{" "}
- remocn.dev — an open-source
- registry of video components for Remotion. If anything on this
- page is unclear, email us at{" "}
- {SUPPORT_EMAIL}.
-
-
-
Privacy Policy
-
-
What we collect
-
- The site has no accounts and no sign-up. You can browse the docs,
- copy components, and use everything on the site without giving us
- any personal data.
-
-
-
- Usage analytics. We use{" "}
- OpenPanel to collect
- anonymous usage data: pages visited, referrer, browser and
- device type, and coarse location (country level). This tells us
- which components people actually use. We do not use advertising
- trackers, we do not build cross-site profiles, and we never sell
- data.
-
-
- Local preferences. Your theme choice (light or
- dark) is stored in your browser's localStorage and never
- leaves your device.
-
-
- Server logs. Our hosting infrastructure keeps
- standard access logs (IP address, requested URL, timestamp) for
- security and operations. They are rotated automatically and not
- used for anything else.
-
-
-
-
Payments
-
- Paid products are sold through{" "}
- Creem, our merchant of record.
- Creem runs the checkout and handles payment processing, invoicing,
- and taxes. Your card details go directly to Creem and its payment
- processors — they never touch our servers. After a purchase we
- receive only what is needed to deliver it: your email address, the
- product, and the order details. How Creem handles your data is
- described in{" "}
-
- Creem's privacy policy
-
- .
-
- Creem — checkout and payments, as
- merchant of record.
-
-
- GitHub — source code, issues, and
- sponsorships. Anything you do there is governed by GitHub's
- own terms.
-
-
-
-
Your data
-
- We keep order emails for as long as we need them to provide
- support and meet legal obligations. To ask what we hold about you,
- or to have it deleted, email{" "}
- {SUPPORT_EMAIL} and we
- will sort it out.
-
-
-
Terms of Service
-
-
The site and free components
-
- The free components in the registry are released under the{" "}
- MIT license. You
- can copy them, modify them, and ship them in personal or
- commercial projects without attribution. The site and the
- components are provided “as is”, without warranty of
- any kind.
-
-
-
Paid products
-
- Some products are paid. Unless a product states otherwise at
- purchase, buying it grants you a non-exclusive license to use it
- in any number of your own projects, commercial ones included. What
- it does not grant is the right to redistribute or resell the
- product itself — for example republishing the components in
- another registry, template marketplace, or paid bundle.
-
-
-
Purchases and refunds
-
- Purchases are processed by Creem as merchant of record, so the
- sale itself is also subject to the terms Creem presents at
- checkout. If a product is broken or not as described, email{" "}
- {SUPPORT_EMAIL} within 14
- days of purchase and we will fix it or refund you.
-
-
-
Liability
-
- To the maximum extent permitted by law, we are not liable for
- indirect or consequential damages arising from your use of the
- site or its products, and our total liability is limited to the
- amount you paid us in the twelve months before the claim. Free
- products come with no liability at all — they are yours to own,
- audit, and modify.
-
-
-
Changes
-
- We may update this page as the project evolves — for example when
- new products or services launch. The date at the top reflects the
- latest revision, and continued use of the site after a change
- means you accept it.
-
-
-
Contact
-
- For anything related to privacy, purchases, or these terms:{" "}
- {SUPPORT_EMAIL}.
-
-
+ >
);
}
diff --git a/app/(home)/showcases/components/reel/clip-list.tsx b/app/(home)/showcases/components/reel/clip-list.tsx
new file mode 100644
index 00000000..7c38ac57
--- /dev/null
+++ b/app/(home)/showcases/components/reel/clip-list.tsx
@@ -0,0 +1,168 @@
+import Link from "next/link";
+import { cn } from "@/lib/utils";
+import { formatDuration } from "@/lib/video-duration";
+import type { ReelClip } from "../../lib/reel-types";
+
+/**
+ * Every clip with its in and out on the reel, what it was built with and
+ * its source. A row's thumbnail cues the clip in the editor above; the
+ * title opens the showcase. Below `xl` the rows hang off a vertical rail.
+ */
+export function ClipList({
+ clips,
+ index,
+ onSelect,
+}: {
+ clips: ReelClip[];
+ index: number;
+ onSelect: (index: number) => void;
+}) {
+ return (
+
+