Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 10 additions & 36 deletions components/changelog/changelog-preview.tsx
Original file line number Diff line number Diff line change
@@ -1,58 +1,32 @@
"use client";

import { Suspense, useMemo } from "react";
import { type ComponentConfig, getDefaults } from "@/lib/customizer-config";
import { PreviewStage } from "@/lib/ui-preview-internals";
import registry from "@/registry/__index__";
import { previewManifest } from "@/registry/__manifest__";

export function ChangelogPreview({ name }: { name: string }) {
const entry = registry[name];
const preview = previewManifest[name];

if (!entry) {
if (!entry || !preview) {
return (
<div className="not-prose mb-6 rounded-lg border border-fd-border p-4 text-sm text-fd-muted-foreground">
Unknown component: <code>{name}</code>
</div>
);
}

return (
<Suspense
fallback={
<div className="not-prose mb-6 aspect-[1.9/1] w-full animate-pulse rounded-2xl bg-muted" />
}
>
<Preview name={name} config={entry.config} load={entry.load} />
</Suspense>
);
}

function Preview({
name,
config,
load,
}: {
name: string;
config: ComponentConfig;
// biome-ignore lint/suspicious/noExplicitAny: dynamically-loaded Remotion composition, props shape varies per component
load: () => Promise<{ default: React.ComponentType<any> }>;
}) {
const inputProps = useMemo(
() => getDefaults(config.controls),
[config.controls],
);

return (
<div className="not-prose mb-6 w-full">
<PreviewStage
name={name}
load={load}
inputProps={inputProps}
durationInFrames={config.durationInFrames}
fps={config.fps}
compositionWidth={config.compositionWidth}
compositionHeight={config.compositionHeight}
previewBackdrop={config.previewBackdrop}
load={entry.load}
inputProps={preview.defaults}
durationInFrames={preview.durationInFrames}
fps={preview.fps}
compositionWidth={preview.compositionWidth}
compositionHeight={preview.compositionHeight}
previewBackdrop={preview.previewBackdrop}
/>
</div>
);
Expand Down
20 changes: 9 additions & 11 deletions components/docs/component-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,9 @@ import { Player, type PlayerRef } from "@remotion/player";
import Link from "next/link";
import { useRef } from "react";
import { useAutoplay } from "@/app/(home)/components/use-autoplay";
import { getDefaults } from "@/lib/customizer-config";
import { cn } from "@/lib/utils";
import registry from "@/registry/__index__";
import { previewManifest } from "@/registry/__manifest__";
import type { CardItem } from "./component-card-grid";

function slugFromHref(href?: string) {
Expand All @@ -21,31 +21,29 @@ function PreviewPlaceholder() {
function CardPreview({ item }: { item: CardItem }) {
const slug = slugFromHref(item.href);
const entry = slug ? registry[slug] : undefined;
const preview = slug ? previewManifest[slug] : undefined;
const playerRef = useRef<PlayerRef>(null);

useAutoplay(playerRef);

if (!entry) {
if (!entry || !preview) {
return (
<div className="size-full">
<PreviewPlaceholder />
</div>
);
}

const { load, config } = entry;
const inputProps = getDefaults(config.controls);

return (
<div className="size-full">
<Player
ref={playerRef}
lazyComponent={load}
inputProps={inputProps}
durationInFrames={config.durationInFrames}
fps={config.fps}
compositionWidth={config.compositionWidth}
compositionHeight={config.compositionHeight}
lazyComponent={entry.load}
inputProps={preview.defaults}
durationInFrames={preview.durationInFrames}
fps={preview.fps}
compositionWidth={preview.compositionWidth}
compositionHeight={preview.compositionHeight}
style={{ width: "100%", height: "100%", backgroundColor: "#f5f5f5" }}
controls={false}
loop
Expand Down
11 changes: 6 additions & 5 deletions components/docs/component-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,14 +2,14 @@

import { CheckIcon, LinkIcon, RotateCcwIcon } from "lucide-react";
import { useQueryStates } from "nuqs";
import { Suspense, useEffect, useMemo, useRef, useState } from "react";
import { Suspense, use, useEffect, useMemo, useRef, useState } from "react";
import { CodeBlock } from "@/components/docs/code-block";
import { Button } from "@/components/ui/button";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTrackEvent } from "@/lib/analytics";
import { type ComponentConfig, getDefaults } from "@/lib/customizer-config";
import { buildParsers, PreviewStage } from "@/lib/ui-preview-internals";
import registry from "@/registry/__index__";
import registry, { loadComponentConfig } from "@/registry/__index__";
import { ComponentCustomizer } from "./component-customizer";

export function ComponentPreview({ name }: { name: string }) {
Expand All @@ -29,21 +29,22 @@ export function ComponentPreview({ name }: { name: string }) {
<div className="not-prose mb-6 aspect-[1.9/1] w-full animate-pulse rounded-2xl bg-muted" />
}
>
<Preview name={name} config={entry.config} load={entry.load} />
<Preview name={name} load={entry.load} />
</Suspense>
);
}

function Preview({
name,
config,
load,
}: {
name: string;
config: ComponentConfig;
// biome-ignore lint/suspicious/noExplicitAny: dynamically-loaded Remotion composition, props shape varies per component
load: () => Promise<{ default: React.ComponentType<any> }>;
}) {
// Suspends until this component's config chunk resolves, then returns
// synchronously from the cached promise. No other config is fetched.
const config = use(loadComponentConfig(name));
const trackEvent = useTrackEvent();
const { parsers, urlKeys } = useMemo(
() => buildParsers(name, config.controls),
Expand Down
24 changes: 17 additions & 7 deletions components/docs/icons-gallery.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,10 @@ import { AbsoluteFill } from "remotion";
import { usePrefersReducedMotion } from "@/lib/use-prefers-reduced-motion";
import { cn } from "@/lib/utils";
import registry, { type RegistryEntry } from "@/registry/__index__";
import {
type PreviewManifestEntry,
previewManifest,
} from "@/registry/__manifest__";
import { ActivityIconStatic } from "@/registry/remocn-icons/icon-activity";
import { AlertTriangleIconStatic } from "@/registry/remocn-icons/icon-alert-triangle";
import { ArrowDownIconStatic } from "@/registry/remocn-icons/icon-arrow-down";
Expand Down Expand Up @@ -838,8 +842,9 @@ function IconTile({
const reducedMotion = usePrefersReducedMotion();
const [copied, setCopied] = useState(false);
const entry = registry[icon.name];
const preview = previewManifest[icon.name];
const command = `npx shadcn@latest add @remocn/${icon.name}`;
const playing = active && !reducedMotion && Boolean(entry);
const playing = active && !reducedMotion && Boolean(entry && preview);
const Static = icon.Static;

const handleCopy = () => {
Expand Down Expand Up @@ -867,8 +872,8 @@ function IconTile({
className="flex items-center justify-center"
style={{ width: MEDIA_SIZE, height: MEDIA_SIZE }}
>
{playing && entry ? (
<IconPlayer entry={entry} />
{playing && entry && preview ? (
<IconPlayer load={entry.load} preview={preview} />
) : (
<Static size={GLYPH_SIZE} strokeWidth={2} />
)}
Expand All @@ -885,9 +890,14 @@ function IconTile({
);
}

function IconPlayer({ entry }: { entry: RegistryEntry }) {
function IconPlayer({
load,
preview,
}: {
load: RegistryEntry["load"];
preview: PreviewManifestEntry;
}) {
const playerRef = useRef<PlayerRef>(null);
const { config, load } = entry;

const centeredComponent = useMemo(
() => () =>
Expand Down Expand Up @@ -931,8 +941,8 @@ function IconPlayer({ entry }: { entry: RegistryEntry }) {
<Player
ref={playerRef}
lazyComponent={centeredComponent}
durationInFrames={config.durationInFrames}
fps={config.fps}
durationInFrames={preview.durationInFrames}
fps={preview.fps}
compositionWidth={COMPOSITION_SIZE}
compositionHeight={COMPOSITION_SIZE}
style={{ width: MEDIA_SIZE, height: MEDIA_SIZE }}
Expand Down
7 changes: 3 additions & 4 deletions components/docs/ui-component-preview.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { useTrackEvent } from "@/lib/analytics";
import { type ControlConfig, getDefaults } from "@/lib/customizer-config";
import { buildParsers, PreviewStage } from "@/lib/ui-preview-internals";
import registry from "@/registry/__index__";
import registry, { loadComponentConfig } from "@/registry/__index__";
import { ComponentCustomizer } from "./component-customizer";
import {
loadUiScene,
Expand Down Expand Up @@ -44,24 +44,23 @@ export function UiComponentPreview({ name }: { name: string }) {
<div className="not-prose mb-6 aspect-[1.9/1] w-full animate-pulse rounded-2xl bg-muted" />
}
>
<UiPreview name={name} timing={timing} controls={entry.config.controls} />
<UiPreview name={name} timing={timing} />
</Suspense>
);
}

function UiPreview({
name,
timing,
controls,
}: {
name: string;
timing: UiPreviewTiming;
controls: ControlConfig;
}) {
// Suspends on first render until this component's scene chunk resolves, then
// returns synchronously from the cached promise. Gives us the scene component,
// its honored-control list, and its code template — no other scene is fetched.
const scene = use(loadUiScene(name));
const controls: ControlConfig = use(loadComponentConfig(name)).controls;
const honored = scene.controls;
const trackEvent = useTrackEvent();

Expand Down
57 changes: 55 additions & 2 deletions lib/customizer-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,8 +56,8 @@ export const FONT_WEIGHT_OPTIONS = ["400", "500", "600", "700"];

/**
* Controls present on every animation. Merged into each component's controls
* inside the registry index so every animation in the customizer exposes the
* same baseline knobs.
* by `resolveControls` so every animation in the customizer exposes the same
* baseline knobs.
*/
export const SHARED_CONTROLS: ControlConfig = {
speed: {
Expand All @@ -70,6 +70,59 @@ export const SHARED_CONTROLS: ControlConfig = {
},
};

/** Components that opt out of the shared `speed` control entirely. */
const NO_SHARED_SPEED = new Set(["backdrop"]);

/**
* Components whose animation rides a shared progress driver that must reach its
* final frame — a count-up landing on the last frame, a lockup revealed at a
* fixed point. A speed < 1 stalls the driver before the payoff, so these cap
* `speed` at a minimum of 1 instead of the shared 0.25.
*/
const SPEED_MIN_ONE = new Set([
"chat-gpt",
"claude-chat",
"claude-code",
"github-sponsors",
"github-stars",
"number-wheel",
"opencode",
"rolling-number",
"v0",
"x-follow-card",
"x-followers-overview",
]);

const SPEED_MIN_ONE_CONTROL: ControlType = {
type: "number",
default: 1,
min: 1,
max: 4,
step: 0.25,
label: "Speed",
};

export function resolveControls(
slug: string,
controls: ControlConfig,
): ControlConfig {
const out: ControlConfig = { ...controls, ...SHARED_CONTROLS };
if (NO_SHARED_SPEED.has(slug)) {
delete out.speed;
return out;
}
// Reassigning the existing key keeps `speed` in its merged position.
if (SPEED_MIN_ONE.has(slug)) out.speed = SPEED_MIN_ONE_CONTROL;
return out;
}

export function resolveConfig(
slug: string,
config: ComponentConfig,
): ComponentConfig {
return { ...config, controls: resolveControls(slug, config.controls) };
}

export function getDefaults(controls: ControlConfig): Record<string, unknown> {
const out: Record<string, unknown> = {};
for (const [key, ctrl] of Object.entries(controls)) {
Expand Down
2 changes: 2 additions & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@
"format": "biome format --write",
"registry:build": "shadcn build --output registry-artifacts && biome format --write registry-artifacts",
"registry:check": "shadcn build --output registry-artifacts && biome format --write registry-artifacts && git diff --quiet -- registry-artifacts || (echo 'registry-artifacts is out of sync with registry/ sources. Run: bun run registry:build and commit the result.' && exit 1)",
"manifest:build": "bun scripts/build-preview-manifest.mts && biome format --write registry/__manifest__.ts",
"manifest:check": "bun run manifest:build && git diff --quiet -- registry/__manifest__.ts || (echo 'registry/__manifest__.ts is out of sync with registry config sources. Run: bun run manifest:build and commit the result.' && exit 1)",
"remotion:browser": "bun scripts/ensure-browser.mts",
"bundle:remotion": "bun scripts/bundle-remotion.mts",
"render:demos": "bun scripts/render-demos.mts",
Expand Down
Loading
Loading