Compare commits

...
Author SHA1 Message Date
Tanish Manku c7934c3e44 Merge branch 'main' into app-visual-upgrades 2026-06-23 20:13:53 -07:00
Tanish Manku 273c8e985c webapp: premium motion pass across app
Motion foundation + expressive entrance/interaction polish for the webapp,
following a "balanced" direction (restrained chrome, expressive canvas).

Foundation
- Motion tokens: --ease-*/--dur-* CSS vars (styles.css) mirrored as JS
  constants/presets in lib/motion.ts; prefers-reduced-motion respected.
- Shared Reveal/RevealGroup primitive (components/motion) — fade-up + stagger.

Chrome & navigation
- Global route fade (components/motion/page-transition) keyed by section.
- Sidebar active-indicator slide via framer layoutId + icon hover.

Pages
- Home: hero/eyebrow reveal, staggered launcher cards, spring lift/press
  hover, double-bezel cards, nested arrow chip.
- Auth: AuthHeader staggers in (login+signup); form blocks cascade after.
- Pricing: wire the orphaned data-reveal hero through the real Reveal.
- Library: sliding active indicator on tab/filter switchers (layoutId),
  shimmer-sweep loading skeleton.

Pagescene entrance animations (user-drop only)
- EntranceAnimator: meshes get a transform-based pop/rise/fall drop with a
  rubbery squash overshoot; splats get a per-gaussian radial settle (Spark
  dyno) with center-out fade + height drop. Dev-only speed slider.

Gallery + asset drag-under UX
- On drag-start the gallery/asset modal becomes pointer-transparent so the
  drag passes under onto the canvas; steps back to translucent (reopen on)
  or eases fully out (reopen off), all interpolated. Shared DragGhost
  (lift/tilt/follow) feeds both DnD systems. Media fades to placeholders so
  thumbnails don't block the canvas. Asset modal always reopens fully visible.

PageDraw: entrance fade on add + workspace vignette.

Dev-only (local testing): drop setDevelopment() API host pin; add /__cdn
proxy + maybeProxyCdnUrl so CDN splat/GLB assets dodge CORS.
2026-06-17 08:44:10 -07:00
41 changed files with 1480 additions and 276 deletions
@@ -36,6 +36,7 @@ import {
useSidebar,
} from "../components/ui/sidebar";
import { AppSidebar } from "../components/sidebar/app-sidebar";
import { PageTransition } from "../components/motion/page-transition";
import { MobileBottomNav } from "../components/sidebar/mobile-bottom-nav";
import { TopBar } from "../components/topbar/topbar";
import { SignupCtaModal } from "../components/signup-cta-modal";
@@ -109,15 +110,10 @@ function ProtectedContent() {
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
>
{!hideTopBar && <TopBar />}
{/* The immersive editors (hideTopBar routes) paint their own #101014
canvas backdrop; match the inset to it so no lighter strip shows at
the edges. Other pages keep the standard #121212 surface. */}
<SidebarInset
className={`flex-1 min-h-0 overflow-y-auto ${
hideTopBar ? "bg-[#101014]" : "bg-[#121212]"
}`}
>
<Outlet />
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-[#121212]">
<PageTransition>
<Outlet />
</PageTransition>
</SidebarInset>
{isMobile && <MobileBottomNav />}
</div>
@@ -6,6 +6,7 @@ import { TruchetPattern } from "@storyteller/ui-vfx";
import { AuthShowcase } from "./auth-showcase";
import { useMediaQuery } from "../ui/use-media-query";
import { useSession } from "../../lib/session";
import { Reveal, RevealGroup } from "../motion/reveal";
/**
* Persistent shell for the auth pages. Rendered as a layout route so the
@@ -108,16 +109,22 @@ interface AuthHeaderProps {
}
export const AuthHeader = ({ title, subtitle }: AuthHeaderProps) => (
<div className="mb-8 text-center">
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
<h1 className="mb-2 text-2xl font-semibold">{title}</h1>
<p className="text-sm text-white/60">{subtitle}</p>
</div>
<RevealGroup inView={false} stagger={0.08} className="mb-8 text-center">
<Reveal y={10}>
<img
src="/images/artcraft-icon.png"
alt="ArtCraft"
className="mx-auto mb-6 h-12 w-auto select-none pointer-events-none"
draggable={false}
/>
</Reveal>
<Reveal as="h1" y={10} className="mb-2 text-2xl font-semibold">
{title}
</Reveal>
<Reveal as="p" y={10} className="text-sm text-white/60">
{subtitle}
</Reveal>
</RevealGroup>
);
export const AuthFooter = ({ children }: { children: ReactNode }) => (
@@ -0,0 +1,50 @@
import { motion } from "framer-motion";
import { useLocation } from "react-router-dom";
import { pageFadeVariants } from "../../lib/motion";
// Routes whose dynamic sub-segments should NOT replay the page transition.
// Switching scenes (`/edit-3d/:token`), library filters (`/library/:slug`) or
// media items (`/media/:id`) stays *within* a section — re-fading the whole
// shell on every param change would feel laggy and would needlessly remount
// heavy editors. Section changes (Home → Create Image) still animate.
const SECTION_PREFIXES = [
"/edit-3d",
"/video-editor",
"/library",
"/media",
] as const;
/** Collapse a pathname to a stable key for its app section. */
export function routeSectionKey(pathname: string): string {
const section = SECTION_PREFIXES.find(
(prefix) => pathname === prefix || pathname.startsWith(`${prefix}/`),
);
return section ?? pathname;
}
/**
* Wraps the routed `<Outlet />` so navigating between app sections fades the
* new page in. Enter-only (no exit) keeps navigation feeling instant — the old
* page is replaced immediately and the new one fades up over it.
*
* The fade is opacity-only by design; see `pageFadeVariants` for why a
* transform here would break `position: fixed` page chrome.
*
* The wrapper mirrors `SidebarInset`'s flex slot (`flex-1 flex-col min-h-0`) so
* pages that rely on `h-full` (the 3D/video editors) or `min-h-full` (scrolling
* pages like Home) keep resolving their heights exactly as before.
*/
export function PageTransition({ children }: { children: React.ReactNode }) {
const { pathname } = useLocation();
return (
<motion.div
key={routeSectionKey(pathname)}
variants={pageFadeVariants}
initial="initial"
animate="animate"
className="flex w-full flex-1 flex-col min-h-0"
>
{children}
</motion.div>
);
}
@@ -0,0 +1,119 @@
// Reveal — the webapp's reusable entrance primitive.
//
// Wraps content in a fade-and-lift that plays once when the element scrolls
// into view (or immediately on mount for above-the-fold content). It reuses the
// shared motion tokens so every page enters with the same restrained, premium
// cadence rather than each surface hand-rolling its own.
//
// Two pieces:
// <RevealGroup> — a stagger container; its <Reveal> children cascade in.
// <Reveal> — a single fade-up element (works standalone or as a child).
//
// Both respect `prefers-reduced-motion`: motion collapses to a plain, instant
// presence so the layout never moves for users who opt out.
import type { ElementType, ReactNode } from "react";
import { motion, useReducedMotion, type Variants } from "framer-motion";
import { DUR_SLOW, EASE_OUT } from "../../lib/motion";
interface RevealProps {
children: ReactNode;
/** Render as a different element/component (e.g. "section", "li", Link). */
as?: ElementType;
/** Extra delay (seconds) before this element animates. */
delay?: number;
/** Travel distance for the lift, in px. Defaults to a gentle 16. */
y?: number;
/** Play once when scrolled into view (default) vs. immediately on mount. */
inView?: boolean;
className?: string;
}
/**
* Stagger container. Place <Reveal> children inside and they fade up in
* sequence. Use for grids, card rows, and stacked content blocks.
*/
export function RevealGroup({
children,
as = "div",
stagger = 0.07,
delayChildren = 0.04,
inView = true,
className,
}: {
children: ReactNode;
as?: ElementType;
stagger?: number;
delayChildren?: number;
inView?: boolean;
className?: string;
}) {
const reduceMotion = useReducedMotion();
const MotionTag = motion(as);
const variants: Variants = {
initial: {},
animate: {
transition: reduceMotion
? {}
: { staggerChildren: stagger, delayChildren },
},
};
return (
<MotionTag
className={className}
variants={variants}
initial="initial"
{...(inView
? { whileInView: "animate", viewport: { once: true, margin: "-10%" } }
: { animate: "animate" })}
>
{children}
</MotionTag>
);
}
export function Reveal({
children,
as = "div",
delay = 0,
y = 16,
inView,
className,
}: RevealProps) {
const reduceMotion = useReducedMotion();
const MotionTag = motion(as);
const variants: Variants = {
initial: reduceMotion ? { opacity: 0 } : { opacity: 0, y },
animate: {
opacity: 1,
y: 0,
transition: { duration: DUR_SLOW, ease: EASE_OUT, delay },
},
};
// When `inView` is left undefined the element behaves as a stagger child:
// it inherits the parent RevealGroup's orchestration and must NOT drive its
// own initial/animate props (that would override the cascade).
const standalone = inView !== undefined;
return (
<MotionTag
className={className}
variants={variants}
{...(standalone
? inView
? {
initial: "initial",
whileInView: "animate",
viewport: { once: true, margin: "-10%" },
}
: { initial: "initial", animate: "animate" }
: {})}
>
{children}
</MotionTag>
);
}
@@ -1,5 +1,6 @@
import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faHouse,
@@ -137,9 +138,16 @@ function NavMenuItem({
pathname: string;
onClick: () => void;
}) {
const reduceMotion = useReducedMotion();
const active = !item.external && isActive(pathname, item.href);
const inner = (
<>
<FontAwesomeIcon icon={item.icon} />
{/* Icon nudges up in scale on hover — a small tactile cue that the row is
interactive, kept subtle to stay within the "restrained chrome" lane. */}
<FontAwesomeIcon
icon={item.icon}
className="transition-transform duration-200 ease-out group-hover/menu-item:scale-110"
/>
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
{item.badge && (
<span className="ml-auto bg-amber-600 px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full">
@@ -150,9 +158,24 @@ function NavMenuItem({
);
return (
<SidebarMenuItem>
{/* Brand accent bar that glides between nav rows as the active route
changes (shared-element layout animation via `layoutId`). One is
mounted at a time, so framer-motion tweens it from the old row to the
new one. */}
{active && (
<motion.span
layoutId="sidebar-active-indicator"
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-1 rounded-full bg-primary"
transition={
reduceMotion
? { duration: 0 }
: { type: "spring", stiffness: 520, damping: 40, mass: 0.7 }
}
/>
)}
<SidebarMenuButton
asChild
isActive={!item.external && isActive(pathname, item.href)}
isActive={active}
tooltip={item.label}
>
{item.external ? (
@@ -0,0 +1,80 @@
// Shared motion tokens for the webapp.
//
// These mirror the CSS custom properties defined in `src/styles.css` (`--ease-*`,
// `--dur-*`) so framer-motion animations and plain CSS transitions stay in sync.
// The "balanced" motion direction: chrome and navigation are restrained and fast;
// the creative canvas (3D drops, gallery, pagedraw) is where motion gets to be
// expressive. Reach for these instead of hand-writing curves and durations.
import type { Transition, Variants } from "framer-motion";
// ── Easing curves (cubic-bezier control points) ───────────────────────────────
// Tuples are typed as 4-number arrays so framer-motion accepts them as `ease`.
/** Decelerate-to-rest. The default for elements entering the screen. */
export const EASE_OUT: [number, number, number, number] = [0.22, 1, 0.36, 1];
/** Symmetric ease for elements that move and settle in place. */
export const EASE_IN_OUT: [number, number, number, number] = [0.65, 0, 0.35, 1];
/** Gentle overshoot for playful, tactile moments (pills, badges, drops). */
export const EASE_SPRING: [number, number, number, number] = [0.34, 1.56, 0.64, 1];
/** Apple/Linear "expo" curve for confident, premium slides. */
export const EASE_EMPHASIS: [number, number, number, number] = [0.32, 0.72, 0, 1];
// ── Durations (seconds, to match framer-motion's unit) ────────────────────────
export const DUR_FAST = 0.14;
export const DUR_BASE = 0.22;
export const DUR_SLOW = 0.34;
export const DUR_PAGE = 0.26;
// ── Ready-made transition presets ─────────────────────────────────────────────
/** Quick, restrained transition for chrome (nav, hovers, toggles). */
export const TRANSITION_CHROME: Transition = {
duration: DUR_BASE,
ease: EASE_OUT,
};
/** The global page/route transition — fast and unobtrusive. */
export const TRANSITION_PAGE: Transition = {
duration: DUR_PAGE,
ease: EASE_OUT,
};
/** Tactile spring for expressive bits that should feel physical. */
export const TRANSITION_SPRING: Transition = {
type: "spring",
stiffness: 420,
damping: 30,
mass: 0.9,
};
// ── Reusable variants ─────────────────────────────────────────────────────────
/**
* Opacity-only page enter. Deliberately avoids `transform`/`filter`: a lingering
* transform on a route wrapper would establish a containing block and re-scope
* `position: fixed` page chrome (promptboxes, editor toolbars) to the wrapper
* instead of the viewport. Fading is the safe, premium choice here — the
* expressive vertical motion lives inside individual pages, not the shell.
*/
export const pageFadeVariants: Variants = {
initial: { opacity: 0 },
animate: { opacity: 1, transition: TRANSITION_PAGE },
exit: { opacity: 0, transition: { duration: DUR_FAST, ease: EASE_OUT } },
};
/** Fade-and-lift for content that is safe to translate (no fixed children). */
export const fadeUpVariants: Variants = {
initial: { opacity: 0, y: 12 },
animate: { opacity: 1, y: 0, transition: TRANSITION_CHROME },
exit: { opacity: 0, y: 6, transition: { duration: DUR_FAST, ease: EASE_OUT } },
};
/** Parent variant that staggers children using `fadeUpVariants` (or similar). */
export const staggerContainerVariants: Variants = {
initial: {},
animate: {
transition: { staggerChildren: 0.05, delayChildren: 0.02 },
},
};
@@ -1,4 +1,5 @@
import { Link } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faImage,
@@ -10,6 +11,10 @@ import {
} from "@fortawesome/pro-solid-svg-icons";
import type { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { TRANSITION_SPRING } from "../../lib/motion";
const MotionLink = motion(Link);
type AppCard = {
label: string;
@@ -72,60 +77,71 @@ const APPS: AppCard[] = [
];
export function Home() {
const reduceMotion = useReducedMotion();
return (
<div className="min-h-full px-6 sm:px-10 py-10 sm:py-16 max-w-6xl mx-auto w-full">
<Seo
title="ArtCraft - Create AI Images and Video"
description="Generate AI images and video with ArtCraft."
/>
<h1 className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight">
<Reveal as="h1" inView={false} y={20} className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight">
What will you <span className="text-primary">craft</span> today?
</h1>
</Reveal>
<section className="py-12">
<h2 className="text-sm font-semibold text-white/85 mb-4">Create</h2>
<div className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3">
<Reveal as="h2" inView={false} delay={0.08} className="text-sm font-semibold text-white/85 mb-4">
Create
</Reveal>
<RevealGroup
inView={false}
delayChildren={0.12}
stagger={0.06}
className="grid gap-3 auto-rows-fr sm:grid-cols-2 lg:grid-cols-3"
>
{APPS.map((app) => (
<Link
key={app.href}
to={app.href}
className="bg-ui-controls/50 group relative h-full overflow-hidden rounded-2xl p-5 hover:border-white/20 hover:bg-ui-controls"
>
<div
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity pointer-events-none`}
aria-hidden
/>
<div className="relative flex items-start gap-4">
<Reveal key={app.href} y={20}>
<MotionLink
to={app.href}
className="bg-ui-controls/50 group relative flex h-full overflow-hidden rounded-2xl p-5 ring-1 ring-white/[0.06] shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04)] hover:bg-ui-controls hover:ring-white/15"
whileHover={reduceMotion ? undefined : { y: -4 }}
whileTap={reduceMotion ? undefined : { scale: 0.985 }}
transition={TRANSITION_SPRING}
>
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor}`}
>
<FontAwesomeIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-base font-semibold text-white truncate">
{app.label}
</h3>
{app.badge && (
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
{app.badge}
</span>
)}
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none`}
aria-hidden
/>
<div className="relative flex w-full items-start gap-4">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor} shadow-[inset_0_1px_1px_rgba(255,255,255,0.12)] transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-105`}
>
<FontAwesomeIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-base font-semibold text-white truncate">
{app.label}
</h3>
{app.badge && (
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
{app.badge}
</span>
)}
</div>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/[0.04] text-white/40 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:bg-white/10 group-hover:text-white/80 group-hover:translate-x-0.5">
<FontAwesomeIcon icon={faArrowRight} className="text-xs" />
</span>
</div>
<FontAwesomeIcon
icon={faArrowRight}
className="text-sm text-white/40 group-hover:text-white/70 group-hover:translate-x-0.5 transition-all"
/>
<p className="mt-1 text-sm text-white/55 leading-snug">
{app.description}
</p>
</div>
<p className="mt-1 text-sm text-white/55 leading-snug">
{app.description}
</p>
</div>
</div>
</Link>
</MotionLink>
</Reveal>
))}
</div>
</RevealGroup>
</section>
</div>
);
@@ -1,6 +1,8 @@
import { memo, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { Link, useParams, useNavigate, useLocation } from "react-router-dom";
import { motion } from "framer-motion";
import { EASE_EMPHASIS } from "../../lib/motion";
import { Button } from "@storyteller/ui-button";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
import {
@@ -859,25 +861,45 @@ export default function Library() {
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
<Link
to="/library"
className={`flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tab === "unsorted"
? "bg-ui-controls text-white"
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
<FontAwesomeIcon icon={faBorderAll} className="text-xs" />
<span>All Assets</span>
{tab === "unsorted" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FontAwesomeIcon
icon={faBorderAll}
className="relative z-10 text-xs"
/>
<span className="relative z-10">All Assets</span>
</Link>
<Link
to="/library/folders"
className={`flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
tab === "folders"
? "bg-ui-controls text-white"
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
<FontAwesomeIcon icon={faFolder} className="text-xs" />
<span>Folders</span>
{tab === "folders" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FontAwesomeIcon
icon={faFolder}
className="relative z-10 text-xs"
/>
<span className="relative z-10">Folders</span>
</Link>
</div>
{tab === "unsorted" && (
@@ -901,17 +923,26 @@ export default function Library() {
<button
key={filter.id}
onClick={() => navigate(filter.route)}
className={`flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-lg text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
activeFilter === filter.id
? "bg-ui-controls text-white"
? "text-white"
: "text-white/60 hover:text-white"
}`}
>
{activeFilter === filter.id && (
<motion.span
layoutId="library-filter-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FontAwesomeIcon
icon={filter.icon}
className="text-xs"
className="relative z-10 text-xs"
/>
<span className="hidden sm:inline">{filter.label}</span>
<span className="relative z-10 hidden sm:inline">
{filter.label}
</span>
</button>
))}
</div>
@@ -1063,7 +1094,15 @@ export default function Library() {
)
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
<div>
<div className="h-4 w-24 rounded bg-white/[0.06] mb-3" />
<div
className="h-4 w-24 rounded mb-3"
style={{
background:
"linear-gradient(100deg, rgba(255,255,255,0.05) 30%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 70%)",
backgroundSize: "200% 100%",
animation: "lib-shimmer 1.6s ease-in-out infinite",
}}
/>
<div className={GRID_CLASS}>
{Array.from({ length: 15 }).map((_, i) => (
<div
@@ -1071,15 +1110,18 @@ export default function Library() {
className="aspect-square rounded-lg overflow-hidden"
>
<div
className="h-full w-full bg-white/[0.06]"
className="h-full w-full"
style={{
animation: `pulse 1.8s ease-in-out ${i * 0.07}s infinite`,
background:
"linear-gradient(100deg, rgba(255,255,255,0.04) 30%, rgba(255,255,255,0.09) 50%, rgba(255,255,255,0.04) 70%)",
backgroundSize: "200% 100%",
animation: `lib-shimmer 1.6s ease-in-out ${i * 0.08}s infinite`,
}}
/>
</div>
))}
</div>
<style>{`@keyframes pulse {0%,100%{opacity:.4}50%{opacity:.8}}`}</style>
<style>{`@keyframes lib-shimmer {0%{background-position:200% 0}100%{background-position:-200% 0}}`}</style>
</div>
) : rootEmpty ? (
<div className="flex flex-col items-center justify-center py-20">
@@ -11,6 +11,7 @@ import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { refreshSession } from "../../lib/session";
import { hasActiveSubscription } from "../../lib/billing";
@@ -71,6 +72,8 @@ const Login = () => {
/>
<AuthHeader title="Welcome Back" subtitle="Log in to your account" />
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
<Reveal>
<form
className="space-y-4"
onSubmit={(e) => {
@@ -141,31 +144,37 @@ const Login = () => {
</Button>
</div>
</form>
</Reveal>
<div className="relative my-6 flex items-center justify-center">
<Reveal className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
or
</span>
</div>
</Reveal>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
<Reveal>
<GoogleLoginButton
mode="login"
onSuccess={handleGoogleSuccess}
onError={handleGoogleError}
/>
</Reveal>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
<Reveal>
<AuthFooter>
Don't have an account?{" "}
<Link
to="/signup"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Sign up
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
</>
);
};
@@ -37,6 +37,21 @@ import { useSceneCacheStore } from "./scene-cache-store";
const apiHost = () =>
StorytellerApiHostStore.getInstance().getApiSchemeAndHost();
// In local dev, route absolute cross-origin asset URLs (CDN splats/GLBs/images)
// through the Vite `/__cdn` proxy so they aren't blocked by CORS — the CDN only
// sends CORS headers for the production origin. In production this is a no-op:
// the asset is fetched directly from its real URL.
function maybeProxyCdnUrl(url: string): string {
if (!import.meta.env.DEV) return url;
try {
const parsed = new URL(url, window.location.origin);
if (parsed.origin === window.location.origin) return url;
return `/__cdn/${parsed.host}${parsed.pathname}${parsed.search}`;
} catch {
return url;
}
}
// ─── Scene file IO ─────────────────────────────────────────────────────────
const saveSceneViaApi = async (
@@ -206,7 +221,7 @@ export const useWebAppPageSceneAdapter = (
},
fetchAsset: (url: string, init?: { signal?: AbortSignal }) =>
fetch(url, { mode: "cors", signal: init?.signal }),
fetch(maybeProxyCdnUrl(url), { mode: "cors", signal: init?.signal }),
getCdnOrigin: () => GetCdnOrigin(),
getApiSchemeAndHost: apiHost,
@@ -10,6 +10,7 @@ import {
PricingPromoBanner,
} from "@storyteller/ui-pricing-table";
import { CreditsModal } from "../../components/credits-modal";
import { Reveal } from "../../components/motion/reveal";
const SeedanceBanner = () => (
<div className="flex flex-col gap-5">
@@ -152,10 +153,7 @@ const Pricing = () => {
) : (
<div className="max-w-6xl mx-auto">
<PricingPromoBanner className="mb-10 sm:mb-12" />
<div className="text-center mb-14" data-reveal>
{/* <span className="inline-block text-sm font-semibold uppercase tracking-[0.18em] text-primary mb-3">
Plans
</span> */}
<Reveal as="div" inView={false} y={20} className="text-center mb-14">
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
Invest in <span className="font-serif-italic">yourself</span>.
</h1>
@@ -164,7 +162,7 @@ const Pricing = () => {
Your subscription helps keep ArtCraft free and open for
everyone.
</p>
</div>
</Reveal>
{hasPlan && (
<BuyMoreCreditsCta onClick={() => setCreditsModalOpen(true)} />
)}
@@ -1,6 +1,7 @@
import { Link, useNavigate } from "react-router-dom";
import { AuthHeader, AuthFooter, SignupForm } from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
const Signup = () => {
const navigate = useNavigate();
@@ -13,17 +14,26 @@ const Signup = () => {
/>
<AuthHeader title="Create an Account" subtitle="Join thousands of creators" />
<SignupForm onSuccess={() => navigate("/welcome")} signupSource="artcraft" />
<RevealGroup inView={false} delayChildren={0.24} stagger={0.07}>
<Reveal>
<SignupForm
onSuccess={() => navigate("/welcome")}
signupSource="artcraft"
/>
</Reveal>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
<Reveal>
<AuthFooter>
Already have an account?{" "}
<Link
to="/login"
className="font-semibold text-primary transition-colors hover:text-primary-400"
>
Log in
</Link>
</AuthFooter>
</Reveal>
</RevealGroup>
</>
);
};
@@ -35,6 +35,39 @@
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 217 91% 60%;
/* ── Motion tokens ──────────────────────────────────────────────────
Shared easing curves + durations so motion across the app feels like
one system, not a pile of one-offs. Mirrored as JS constants in
src/lib/motion.ts for framer-motion. "Balanced" direction: chrome and
navigation stay restrained; the creative canvas gets to be expressive.
Curves:
--ease-out decelerate-to-rest; the default for things entering.
--ease-in-out symmetric; for things that move and settle in place.
--ease-spring gentle overshoot; for playful, tactile moments.
--ease-emphasis Apple/Linear "expo" curve; for confident, premium slides. */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-emphasis: cubic-bezier(0.32, 0.72, 0, 1);
/* Durations — keep chrome snappy, reserve longer ones for canvas drama. */
--dur-fast: 140ms;
--dur-base: 220ms;
--dur-slow: 340ms;
--dur-page: 260ms;
}
/* Respect users who ask for less motion: collapse durations to near-zero
so transitions become instant cuts rather than being removed wholesale. */
@media (prefers-reduced-motion: reduce) {
:root {
--dur-fast: 1ms;
--dur-base: 1ms;
--dur-slow: 1ms;
--dur-page: 1ms;
}
}
}
@@ -38,6 +38,22 @@ export default defineConfig(() => ({
Origin: 'https://api.storyteller.ai',
},
},
// Dev-only CDN proxy. Media assets (splats, GLBs, images) are fetched
// from the CDN with fetch(), but the CDN only sends CORS headers for the
// production origin — not localhost. The web adapter's fetchAsset rewrites
// absolute CDN URLs to `/__cdn/<host>/<path>` in DEV; this forwards them
// server-side (no browser CORS). The target host is taken from the path
// so any cdn-N.fakeyou.com / Cloudflare host works.
'/__cdn': {
target: 'https://cdn-2.fakeyou.com',
changeOrigin: true,
secure: true,
router: (req) => {
const match = req.url?.match(/^\/__cdn\/([^/]+)/);
return match ? `https://${match[1]}` : 'https://cdn-2.fakeyou.com';
},
rewrite: (path) => path.replace(/^\/__cdn\/[^/]+/, ''),
},
},
},
preview:{
@@ -1,5 +1,6 @@
export * from "./lib/gallery-modal";
export * from "./lib/galleryModalSignals";
export * from "./lib/DragGhost";
export * from "./lib/GalleryDragComponent";
export * from "./lib/GalleryDraggableItem";
export * from "./lib/GalleryItemMenuItems";
@@ -0,0 +1,145 @@
import { useEffect, useRef, useState } from "react";
import ReactDOM from "react-dom";
// Shared "floating thumbnail" that follows the cursor while dragging an item out
// of a library. Tuned to feel like physically carrying something rather than
// teleporting a flat image: it lifts in with a scale/opacity pop on pickup,
// trails the cursor with a touch of weight, and tilts into the direction of
// motion based on drag velocity. All motion is folded into a single transform
// driven by one rAF loop.
//
// Decoupled from any specific drag system via a `getState` callback the rAF polls
// each frame, so both the gallery (galleryDnd) and the 3D asset library (its own
// store-driven DnD) can reuse the exact same ghost.
const APPEAR_MS = 170; // pickup lift duration
const FOLLOW = 0.4; // cursor-follow easing (1 = rigid, lower = more trail/weight)
const TILT_PER_PX = 0.5; // velocity → tilt (deg per px/frame)
const MAX_TILT = 14; // clamp so it never looks broken
export interface DragGhostState {
/** Page-space cursor position (document coords). */
x: number;
y: number;
thumbnail: string;
/** Optional caption rendered under the thumbnail (asset library shows names). */
label?: string;
}
interface DragGhostProps {
/** Polled every frame; return null when nothing is being dragged. */
getState: () => DragGhostState | null;
width?: number;
height?: number;
/** Pass "anonymous" for CORS-tainted asset thumbnails; omit for the gallery. */
crossOrigin?: "anonymous" | "use-credentials";
}
function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
function clamp(value: number, min: number, max: number): number {
return Math.max(min, Math.min(max, value));
}
export function DragGhost({
getState,
width = 120,
height = 120,
crossOrigin,
}: DragGhostProps) {
const [, force] = useState(0);
// Keep the latest getState in a ref so the rAF loop (started once) always reads
// fresh drag state without restarting on every render.
const getStateRef = useRef(getState);
getStateRef.current = getState;
// Animation state kept in refs so the rAF loop mutates without re-rendering.
const appearStartRef = useRef<number | null>(null);
const renderXRef = useRef(0);
const renderYRef = useRef(0);
const prevXRef = useRef(0);
const tiltRef = useRef(0);
useEffect(() => {
let frame: number;
function update() {
const state = getStateRef.current();
if (state) {
// On the first frame of a drag, snap the rendered position to the cursor
// and start the lift-in timer so it grows from the pickup point.
if (appearStartRef.current === null) {
appearStartRef.current = performance.now();
renderXRef.current = state.x;
renderYRef.current = state.y;
prevXRef.current = state.x;
}
renderXRef.current += (state.x - renderXRef.current) * FOLLOW;
renderYRef.current += (state.y - renderYRef.current) * FOLLOW;
const velocityX = state.x - prevXRef.current;
prevXRef.current = state.x;
const targetTilt = clamp(velocityX * TILT_PER_PX, -MAX_TILT, MAX_TILT);
// Ease the tilt toward target so it leans and recovers smoothly.
tiltRef.current += (targetTilt - tiltRef.current) * 0.2;
} else {
appearStartRef.current = null;
tiltRef.current = 0;
}
force((n) => (n + 1) & 0xffff);
frame = requestAnimationFrame(update);
}
frame = requestAnimationFrame(update);
return () => cancelAnimationFrame(frame);
}, []);
const state = getState();
if (
!state ||
appearStartRef.current === null ||
typeof document === "undefined"
) {
return null;
}
const appear = easeOutCubic(
clamp((performance.now() - appearStartRef.current) / APPEAR_MS, 0, 1),
);
const scale = 0.62 + 0.38 * appear;
return ReactDOM.createPortal(
<div
style={{
position: "fixed",
left: renderXRef.current + 1,
top: renderYRef.current - height / 2,
width,
height,
opacity: appear,
transform: `rotate(${tiltRef.current}deg) scale(${scale})`,
transformOrigin: "center center",
willChange: "transform, opacity",
zIndex: 10000,
pointerEvents: "none",
}}
className="flex cursor-grabbing flex-col overflow-hidden rounded-xl shadow-[0_18px_40px_-12px_rgba(0,0,0,0.7)] ring-1 ring-white/20"
>
<img
src={state.thumbnail}
alt={state.label ?? ""}
crossOrigin={crossOrigin}
className="pointer-events-none min-h-0 w-full flex-1 select-none object-cover"
/>
{state.label && (
<div className="w-full select-none truncate bg-ui-controls px-2 py-1 text-center text-[12px]">
{state.label}
</div>
)}
</div>,
document.body,
);
}
@@ -1,40 +1,22 @@
import React, { useEffect, useState } from "react";
import ReactDOM from "react-dom";
import { DragGhost } from "./DragGhost";
import galleryDnd from "./galleryDnd";
export const GalleryDragComponent: React.FC = () => {
const [dragState, setDragState] = useState(galleryDnd.getDragState());
useEffect(() => {
let frame: number;
function update() {
setDragState({ ...galleryDnd.getDragState() });
frame = requestAnimationFrame(update);
}
frame = requestAnimationFrame(update);
return () => cancelAnimationFrame(frame);
}, []);
if (!dragState.isDragging || !dragState.item) return null;
const { currX, currY, item } = dragState;
const size = 120;
const style: React.CSSProperties = {
left: currX + 1,
top: currY - size / 2,
};
return ReactDOM.createPortal(
<div
style={style}
className="fixed z-[1000] w-[120px] h-[120px] pointer-events-none aspect-square flex flex-col bg-black/70 rounded-xl overflow-hidden shadow-lg cursor-grabbing"
>
<img
src={item.thumbnail || item.fullImage || ""}
alt={item.label}
className="pointer-events-none select-none w-full h-full object-cover rounded-xl"
/>
</div>,
document.body
);
};
/**
* The floating thumbnail that follows the cursor while dragging a gallery item.
* Thin wrapper that feeds the shared {@link DragGhost} from galleryDnd's state.
*/
export const GalleryDragComponent: React.FC = () => (
<DragGhost
width={120}
height={120}
getState={() => {
const state = galleryDnd.getDragState();
if (!state.isDragging || !state.item) return null;
return {
x: state.currX,
y: state.currY,
thumbnail: state.item.thumbnail || state.item.fullImage || "",
};
}}
/>
);
@@ -68,8 +68,10 @@ export const GalleryFolderChip: React.FC<GalleryFolderChipProps> = ({
}}
style={colorCode ? { borderColor: colorCode } : undefined}
className={twMerge(
"group/chip relative w-full aspect-square overflow-hidden rounded-md border-2 border-ui-controls/40 bg-ui-controls/20 transition-colors cursor-pointer hover:border-primary/60",
"[&.folder-drag-over]:border-primary/60 [&.folder-drag-over]:bg-primary/20",
"group/chip relative w-full aspect-square overflow-hidden rounded-md border-2 border-ui-controls/40 bg-ui-controls/20 transition-[transform,border-color,background-color,box-shadow] duration-200 ease-out cursor-pointer hover:border-primary/60",
// A dragged item hovering the folder makes it lift and glow — clear,
// tactile "drop here" feedback rather than a flat color swap.
"[&.folder-drag-over]:scale-[1.04] [&.folder-drag-over]:border-primary [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:ring-2 [&.folder-drag-over]:ring-primary/40",
)}
aria-label={name}
>
@@ -43,7 +43,7 @@ import {
PLACEHOLDER_IMAGES,
} from "@storyteller/common";
import {
galleryModalVisibleDuringDrag,
galleryModalDraggingUnder,
galleryReopenAfterDragSignal,
galleryModalVisibleViewMode,
galleryModalLightboxMediaId,
@@ -2125,10 +2125,23 @@ export const GalleryModal = React.memo(
<>
<Modal
resizable={mode === "view"}
// While dragging an item out, the panel becomes pointer-transparent so
// the drag passes under it onto the canvas. With "Reopen after adding"
// ON it steps back to translucent (stays to keep adding); with it OFF
// it eases all the way out (it's closing after the drop anyway). Both
// go through the same spring, so neither is abrupt.
contentInteractive={!galleryModalDraggingUnder.value}
contentDimmed={
galleryModalDraggingUnder.value &&
galleryReopenAfterDragSignal.value
}
contentHidden={
galleryModalDraggingUnder.value &&
!galleryReopenAfterDragSignal.value
}
isOpen={
mode === "view"
? galleryModalVisibleViewMode.value &&
galleryModalVisibleDuringDrag.value
? galleryModalVisibleViewMode.value
: typeof isOpen === "boolean"
? isOpen
: true
@@ -1,6 +1,7 @@
import { GalleryItem } from "./gallery-modal";
import {
galleryModalVisibleDuringDrag,
galleryModalDraggingUnder,
galleryModalVisibleViewMode,
galleryReopenAfterDragSignal,
} from "./galleryModalSignals";
@@ -12,7 +13,6 @@ interface DragState {
startY: number;
currX: number;
currY: number;
modalHidden: boolean;
}
const dragState: DragState = {
@@ -23,7 +23,6 @@ const dragState: DragState = {
startY: 0,
currX: 0,
currY: 0,
modalHidden: false,
};
const dragThreshold = 5;
@@ -73,6 +72,40 @@ function removeDragPreview() {
}
}
// ── Drop-success ripple ──────────────────────────────────────────────────────
// A quick brand-colored ring that expands and fades at the drop point, so a
// successful drop reads as a deliberate landing instead of the item silently
// vanishing. Self-removing; skipped under reduced-motion.
function spawnDropRipple(x: number, y: number) {
if (window.matchMedia?.("(prefers-reduced-motion: reduce)").matches) return;
const ring = document.createElement("div");
ring.style.cssText = `
position: fixed;
left: ${x}px;
top: ${y}px;
z-index: 99998;
width: 30px;
height: 30px;
margin-left: -15px;
margin-top: -15px;
border-radius: 9999px;
border: 2px solid var(--primary, #2d81ff);
pointer-events: none;
`;
document.body.appendChild(ring);
const anim = ring.animate(
[
{ transform: "scale(0.4)", opacity: 0.9 },
{ transform: "scale(2.6)", opacity: 0 },
],
{ duration: 460, easing: "cubic-bezier(0.22, 1, 0.36, 1)" },
);
anim.onfinish = () => ring.remove();
anim.oncancel = () => ring.remove();
}
// ── Drag lifecycle ───────────────────────────────────────────────────────────
function onPointerDown(
@@ -88,12 +121,35 @@ function onPointerDown(
dragState.currX = event.pageX;
dragState.currY = event.pageY;
dragState.isDragging = false;
dragState.modalHidden = false;
document.body.style.cursor = "grabbing";
window.addEventListener("pointermove", onPointerMove);
window.addEventListener("pointerup", onPointerUp);
}
/**
* The folder chip under a screen point, found geometrically. We can't use
* `elementFromPoint` here: once a drag begins the modal goes pointer-transparent
* (so the drag can reach the canvas), which would make hit-testing skip the
* folder chips entirely.
*/
function folderIdAt(clientX: number, clientY: number): string | null {
const chips = Array.from(
document.querySelectorAll<HTMLElement>("[data-folder-id]"),
);
for (const chip of chips) {
const rect = chip.getBoundingClientRect();
if (
clientX >= rect.left &&
clientX <= rect.right &&
clientY >= rect.top &&
clientY <= rect.bottom
) {
return chip.getAttribute("data-folder-id");
}
}
return null;
}
function onPointerMove(event: PointerEvent) {
if (!dragState.item) return;
const deltaX = event.pageX - dragState.startX;
@@ -104,39 +160,27 @@ function onPointerMove(event: PointerEvent) {
) {
dragState.isDragging = true;
createDragPreview(dragState.items.length);
// Let pointer events pass through the gallery the moment the drag begins, so
// the item can be dropped anywhere — including under the modal. The panel
// eases to translucent (reopen on) or all the way out (reopen off) via the
// Modal's contentDimmed/contentHidden, so the transition is never abrupt.
galleryModalDraggingUnder.value = true;
}
dragState.currX = event.pageX;
dragState.currY = event.pageY;
if (dragState.isDragging) {
updateDragPreviewPosition(event.clientX, event.clientY);
}
if (dragState.isDragging && !dragState.modalHidden) {
// Check if cursor left the modal area - if so, hide modal for scene drop
const modalEl = document.querySelector("[data-gallery-modal]");
if (modalEl) {
const rect = modalEl.getBoundingClientRect();
const isOutside =
event.clientX < rect.left ||
event.clientX > rect.right ||
event.clientY < rect.top ||
event.clientY > rect.bottom;
if (isOutside) {
dragState.modalHidden = true;
galleryModalVisibleDuringDrag.value = false;
}
}
// Update folder hover indicators while inside the modal
if (!dragState.modalHidden) {
const folderEl = document
.elementFromPoint(event.clientX, event.clientY)
?.closest("[data-folder-id]");
document.querySelectorAll("[data-folder-id]").forEach((el) => {
el.classList.toggle("folder-drag-over", el === folderEl);
});
}
// Highlight the folder under the cursor (if any) — folders still accept
// drops even while the gallery is dimmed.
const overFolder = folderIdAt(event.clientX, event.clientY);
document.querySelectorAll("[data-folder-id]").forEach((el) => {
el.classList.toggle(
"folder-drag-over",
el.getAttribute("data-folder-id") === overFolder,
);
});
}
}
@@ -187,25 +231,28 @@ export function removeFolderDropListener(handler: (e: any) => void) {
}
function onPointerUp(event: PointerEvent) {
const wasModalHidden = dragState.modalHidden;
// When closing after an add (reopen off), keep `draggingUnder` true so the
// panel stays faded-out through the close — clearing it would race the close
// animation and flash the hidden panel back up.
let closedHidden = false;
if (dragState.item && dragState.isDragging) {
if (!dragState.modalHidden) {
// Still inside modal - check for folder drop
const folderEl = document
.elementFromPoint(event.clientX, event.clientY)
?.closest("[data-folder-id]");
if (folderEl) {
const folderId = folderEl.getAttribute("data-folder-id")!;
emitFolderDrop(dragState.items, folderId);
}
} else {
// Modal was hidden - scene drop (existing behavior)
if (
dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional"
) {
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
const folderId = folderIdAt(event.clientX, event.clientY);
if (folderId) {
// Dropped onto a folder — organize, and keep the gallery open.
emitFolderDrop(dragState.items, folderId);
spawnDropRipple(event.clientX, event.clientY);
} else if (
dragState.item.mediaClass === "image" ||
dragState.item.mediaClass === "dimensional"
) {
// Dropped onto the canvas (anywhere not on a folder) — add to the scene.
emitImageDrop(dragState.item, { x: event.pageX, y: event.pageY });
spawnDropRipple(event.clientX, event.clientY);
// Close the gallery after adding unless the user asked it to stay open.
if (!galleryReopenAfterDragSignal.value) {
galleryModalVisibleViewMode.value = false;
closedHidden = true;
}
}
}
@@ -220,10 +267,11 @@ function onPointerUp(event: PointerEvent) {
dragState.item = null;
dragState.items = [];
dragState.isDragging = false;
dragState.modalHidden = false;
if (wasModalHidden) {
galleryModalVisibleDuringDrag.value = galleryReopenAfterDragSignal.value;
// Refocus the gallery (un-dim, restore pointer events) — unless it's closing,
// in which case it stays faded until reopened (reset on open).
if (!closedHidden) {
galleryModalDraggingUnder.value = false;
}
document.body.style.cursor = "";
@@ -4,6 +4,11 @@ import { signal } from "@preact/signals-react";
export const galleryModalVisibleDuringDrag = signal(true);
export const galleryReopenAfterDragSignal = signal(false);
export const galleryModalVisibleViewMode = signal(false);
// True while an item is being dragged out of the gallery. The modal stays open
// but goes translucent and pointer-transparent so the drag can pass "under" it
// onto the canvas; it refocuses (or closes, per the reopen-after-adding setting)
// on drop.
export const galleryModalDraggingUnder = signal(false);
// Lightbox Modal Signals
export const galleryModalLightboxMediaId = signal<string | null>(null);
@@ -6,7 +6,7 @@ import { IconDefinition } from "@fortawesome/pro-solid-svg-icons";
import { CloseButton } from "@storyteller/ui-close-button";
import { useRef, useState, useEffect, useContext, createContext } from "react";
import { cloneElement, isValidElement } from "react";
import { useTransition, animated } from "@react-spring/web";
import { useTransition, useSpring, to, animated } from "@react-spring/web";
import {
faUpRightAndDownLeftFromCenter,
faDownLeftAndUpRightToCenter,
@@ -183,6 +183,9 @@ export const Modal = ({
closeOnOutsideClick = true,
closeOnEsc = true,
allowBackgroundInteraction = false,
contentInteractive = true,
contentDimmed = false,
contentHidden = false,
expandable = false,
accessibleTitle,
accessibleDescription,
@@ -207,6 +210,28 @@ export const Modal = ({
closeOnOutsideClick?: boolean;
closeOnEsc?: boolean;
allowBackgroundInteraction?: boolean;
/**
* When false, the modal's content panel ignores pointer events (events fall
* through to whatever is behind it). Lets a caller make the panel a passive,
* see-through overlay — e.g. the gallery going translucent so a drag can pass
* under it onto the canvas. Defaults to true.
*/
contentInteractive?: boolean;
/**
* When true, fades the content panel down (transition-eased). Applied to an
* inner wrapper so it composes with — rather than fights — the spring opacity
* the modal animates on open/close. Pair with `contentInteractive={false}` for
* a passive, see-through overlay. Defaults to false.
*/
contentDimmed?: boolean;
/**
* When true, eases the whole panel all the way out (opacity → 0, slightly more
* scale-down) through the same spring as `contentDimmed`, so a "hide it" reads
* as a smooth step-away rather than an abrupt close. The panel stays mounted;
* the caller flips `isOpen` once it's faded if it wants to fully unmount.
* Takes precedence over `contentDimmed`. Defaults to false.
*/
contentHidden?: boolean;
expandable?: boolean;
accessibleTitle?: string;
accessibleDescription?: string;
@@ -250,6 +275,17 @@ export const Modal = ({
},
});
// Smoothly drives the `contentDimmed`/`contentHidden` "step back": the panel
// eases to a target opacity factor (1 = shown, 0.4 = translucent, 0 = hidden)
// with a paired scale-down, so going translucent or hiding reads as a
// deliberate recede rather than a flicker or an abrupt cut. easeOutCubic over
// 280ms: responsive on the way down, calm as it settles.
const dimFactor = contentHidden ? 0 : contentDimmed ? 0.4 : 1;
const dimSpring = useSpring({
factor: dimFactor,
config: { duration: 280, easing: (t: number) => 1 - Math.pow(1 - t, 3) },
});
const backdropTransitions = useTransition(isOpen, {
from: { opacity: 0 },
enter: { opacity: 1 },
@@ -872,6 +908,16 @@ export const Modal = ({
}, [isOpen, zIndex, onClose, closeOnEsc]);
const getModalStyle = (): React.CSSProperties => {
// Pointer behavior of the content panel: explicitly off when the caller
// wants it passive (drag-through); otherwise re-enabled when the rest of the
// screen is click-through (allowBackgroundInteraction makes the wrappers
// pointer-none, so the panel needs pointer-auto to stay interactive).
const pointerStyle: React.CSSProperties = !contentInteractive
? { pointerEvents: "none" }
: allowBackgroundInteraction
? { pointerEvents: "auto" }
: {};
// fill the entire viewport even if theres a stored size or position
if (expanded) {
return {
@@ -882,7 +928,7 @@ export const Modal = ({
zIndex,
width: "100vw",
height: "100vh",
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
...pointerStyle,
};
}
@@ -892,7 +938,7 @@ export const Modal = ({
...(resizable && size
? { width: size.width, height: size.height }
: {}),
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
...pointerStyle,
};
}
return {
@@ -902,7 +948,7 @@ export const Modal = ({
margin: 0,
zIndex,
...(resizable && size ? { width: size.width, height: size.height } : {}),
...(allowBackgroundInteraction ? { pointerEvents: "auto" } : {}),
...pointerStyle,
};
};
@@ -985,14 +1031,35 @@ export const Modal = ({
ref={modalRef}
style={{
...getModalStyle(),
opacity: styles.opacity,
transform: styles.transform,
// The dim/hide factor eases the whole panel (background
// included): translucent (0.4) or hidden (0), with a
// paired scale-down. Composed with the open/close spring
// so both animations coexist (factor multiplies opacity;
// the scale-down is appended to the spring transform).
opacity: to(
[styles.opacity, dimSpring.factor],
(o, f) => (o as number) * (f as number),
),
transform: to(
[styles.transform, dimSpring.factor],
(tr, f) => `${tr} scale(${0.93 + 0.07 * (f as number)})`,
),
transformOrigin: "center center",
willChange: "transform, opacity", // Optimize for animations
outline: "none",
}}
>
<div className="w-full h-full">
<div
className={twMerge(
"w-full h-full",
// When dimmed/hiding for a drag-under, fade out media so
// the colored thumbnails don't block the view of
// whatever's behind the translucent panel — the layout
// (faint card outlines) stays as a placeholder.
(contentDimmed || contentHidden) &&
"[&_img]:opacity-0 [&_video]:opacity-0 [&_img]:transition-opacity [&_video]:transition-opacity [&_img]:duration-200 [&_video]:duration-200",
)}
>
{accessibleDescription && (
<VisuallyHidden asChild>
<Dialog.Description>
@@ -125,6 +125,9 @@ export const PaintSurface = ({
const mouseMoveThrottle = React.useRef(-1);
const imageRef = React.useRef<Konva.Image>(null);
const leftPanelRef = React.useRef<Konva.Layer>(null);
// Tracks which draw-node ids we've already shown, so only freshly added
// images/shapes get the entrance fade — not the whole canvas on scene restore.
const seenNodeIdsRef = React.useRef<Set<string> | null>(null);
const rightContainerRef = React.useRef<HTMLDivElement>(null);
const cursorLayerRef = React.useRef<Konva.Layer>(null);
const cursorShapeRef = React.useRef<Konva.Circle>(null);
@@ -1446,6 +1449,46 @@ export const PaintSurface = ({
[activeTool, drawNodes, inpaintLineNodes, selectedNodeIds, transformerRefs],
);
// Entrance fade for newly added images/shapes. Opacity is the only safe
// channel here: x/y/scale are controlled props that react-konva would reset
// on the next render, but opacity is left untouched, so an imperative tween
// survives re-renders. Strokes ("line") are skipped — a drawn line shouldn't
// fade in. The first pass just records existing ids so a restored scene
// doesn't replay the entrance for every object at once.
useLayoutEffect(() => {
const stage = stageRef.current;
if (!stage) return;
const currentIds = new Set(drawNodes.map((n) => n.id));
if (seenNodeIdsRef.current === null) {
seenNodeIdsRef.current = currentIds;
return;
}
const reduceMotion = window.matchMedia?.(
"(prefers-reduced-motion: reduce)",
).matches;
if (!reduceMotion) {
const seen = seenNodeIdsRef.current;
for (const node of drawNodes) {
if (node.type === "line" || seen.has(node.id)) continue;
const konvaNode = stage.findOne<Konva.Node>(`#${node.id}`);
if (!konvaNode) continue;
konvaNode.opacity(0);
new Konva.Tween({
node: konvaNode,
opacity: 1,
duration: 0.26,
easing: Konva.Easings.EaseOut,
}).play();
}
}
seenNodeIdsRef.current = currentIds;
}, [drawNodes, stageRef]);
// Load the checkboard image for transparency background
useEffect(() => {
loadImageFromUrl(checkerboard).then(setCheckerImage);
@@ -3,13 +3,20 @@
.pegboard-bg {
inset: 0;
z-index: 0;
/* Themeable canvas backdrop; hosts may set --st-canvas, otherwise the
original dark grey is used. */
background-color: var(--st-canvas, #101014);
background-size: 50px 50px;
background-position:
0 0,
15px 15px;
/* Soft center lift + edge vignette so the workspace reads as a lit stage
with depth instead of a flat slab. Static and GPU-cheap (no animation). */
background-color: #1b1b1b;
background-image:
radial-gradient(
130% 100% at 50% 28%,
rgba(255, 255, 255, 0.045) 0%,
rgba(255, 255, 255, 0) 50%
),
radial-gradient(
120% 120% at 50% 125%,
rgba(0, 0, 0, 0.5) 0%,
rgba(0, 0, 0, 0) 55%
);
}
/* react-colorful overrides for the brush color picker in SideToolbar */
@@ -47,7 +47,6 @@ class DndAsset {
this.isDragging = false;
store.setCanDrop(false);
this.notDropText = "";
store.setAssetModalVisibleDuringDrag(false);
window.addEventListener("pointerup", this.onPointerUp);
window.addEventListener("pointermove", this.onPointerMove);
}
@@ -60,8 +59,17 @@ class DndAsset {
store.setCanDrop(false);
this.overElement = null;
this.notDropText = "";
store.setAssetModalVisibleDuringDrag(store.reopenAfterDrag);
}
if (store.reopenAfterDrag) {
// Refocus the library (un-dim, restore pointer events).
store.setAssetDraggingUnder(false);
} else {
// Close it after adding. Keep `assetDraggingUnder` true so the panel stays
// faded-out through the close instead of flashing back up; it's reset when
// the library is reopened.
store.setAssetModalVisible(false);
}
this.isDragging = false;
this.editor = null;
}
@@ -88,7 +96,7 @@ class DndAsset {
const store = usePageSceneStore.getState();
if (!this.isDragging) {
store.setAssetModalVisibleDuringDrag(true);
// A click, not a drag — leave the library as-is, just clear drag state.
store.setDragItem(null);
store.setDragPosition({ currX: 0, currY: 0 });
this.editor = null;
@@ -137,10 +145,15 @@ class DndAsset {
const deltaX = event.pageX - this.initX;
const deltaY = event.pageY - this.initY;
if (
Math.abs(deltaX) > this.dragThreshold ||
Math.abs(deltaY) > this.dragThreshold
!this.isDragging &&
(Math.abs(deltaX) > this.dragThreshold ||
Math.abs(deltaY) > this.dragThreshold)
) {
this.isDragging = true;
// Let pointer events pass through the library the moment the drag begins.
// The panel eases to translucent (reopen on) or all the way out (reopen
// off) via AssetModal's contentDimmed/contentHidden — never abrupt.
store.setAssetDraggingUnder(true);
}
store.setDragPosition({
currX: this.initX + deltaX,
@@ -145,6 +145,10 @@ interface PageSceneState {
// layout / panels
assetModalVisible: boolean;
assetModalVisibleDuringDrag: boolean;
// True while an asset is being dragged out of the library modal. The modal
// stays open but goes pointer-transparent (and translucent when reopen is on)
// so the drag passes under it onto the canvas.
assetDraggingUnder: boolean;
reopenAfterDrag: boolean;
// overlays
@@ -239,6 +243,7 @@ interface PageSceneState {
// layout
setAssetModalVisible: (visible: boolean) => void;
setAssetModalVisibleDuringDrag: (visible: boolean) => void;
setAssetDraggingUnder: (dragging: boolean) => void;
setReopenAfterDrag: (reopen: boolean) => void;
// overlays
@@ -318,6 +323,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
assetModalVisible: false,
assetModalVisibleDuringDrag: true,
assetDraggingUnder: false,
reopenAfterDrag: false,
editorLoader: { isShowing: false, message: "Loading Editor Engine 🦊" },
@@ -430,6 +436,7 @@ export const usePageSceneStore = create<PageSceneState>((set, get) => ({
setAssetModalVisible: (visible) => set({ assetModalVisible: visible }),
setAssetModalVisibleDuringDrag: (visible) =>
set({ assetModalVisibleDuringDrag: visible }),
setAssetDraggingUnder: (dragging) => set({ assetDraggingUnder: dragging }),
setReopenAfterDrag: (reopen) => set({ reopenAfterDrag: reopen }),
// overlays actions
@@ -51,6 +51,7 @@ import { EditorCanvas } from "./comps/EngineCanvases";
import { FocalLengthDisplay } from "./comps/FocalLengthDisplay/FocalLengthDisplay";
import { OnboardingHelper } from "./comps/OnboardingHelper";
import { PerfStatsOverlay } from "./comps/PerfStatsOverlay";
import { EntranceDebugPanel } from "./comps/EntranceDebugPanel";
import { Outliner } from "./comps/Outliner";
import { PoseModeSelector } from "./comps/PoseModeSelector";
import { PreviewBox } from "./comps/PreviewBox";
@@ -433,6 +434,7 @@ export const Stage3DBody = ({
</SceneContainer>
<PerfStatsOverlay />
{import.meta.env.DEV && <EntranceDebugPanel />}
<FocalLengthDisplay />
<PoseModeSelector />
@@ -18,6 +18,9 @@ export async function addCharacter(
obj.userData.isCharacter = true;
// Animate it in (see addObject).
editor.entranceAnimator.play(obj);
editor.history.record(new CreateAction(editor, obj));
editor.bus.emit(
@@ -16,6 +16,11 @@ export async function addObject(
);
if (!obj) return undefined;
// Animate it in (mask-reveal for meshes, per-gaussian radial settle for
// splats). Runs synchronously before the next render frame, so the object is
// never shown fully-formed first.
editor.entranceAnimator.play(obj);
editor.history.record(new CreateAction(editor, obj));
editor.bus.emit(
@@ -26,6 +26,9 @@ export async function addShape(
obj.userData.color = "#ffffff";
obj.name = item.name ?? "shape";
// Animate it in (see addObject).
editor.entranceAnimator.play(obj);
editor.history.record(new CreateAction(editor, obj));
editor.bus.emit(
@@ -9,8 +9,12 @@ export const AssetMenu = () => {
const [, setIsModalOpen] = useState(false);
const handleOpenModal = () => {
usePageSceneStore.getState().setAssetModalVisibleDuringDrag(true);
usePageSceneStore.getState().setAssetModalVisible(true);
const store = usePageSceneStore.getState();
store.setAssetModalVisibleDuringDrag(true);
store.setAssetModalVisible(true);
// Clear any leftover drag-under state so the panel opens fully shown (a
// reopen-off drag leaves it faded-hidden until the next open).
store.setAssetDraggingUnder(false);
setIsModalOpen(true);
};
@@ -106,14 +106,14 @@ export const AssetModal = () => {
const {
assetModalVisible,
assetModalVisibleDuringDrag,
assetDraggingUnder,
reopenAfterDrag,
setAssetModalVisible,
setReopenAfterDrag,
} = usePageSceneStore(
useShallow((s) => ({
assetModalVisible: s.assetModalVisible,
assetModalVisibleDuringDrag: s.assetModalVisibleDuringDrag,
assetDraggingUnder: s.assetDraggingUnder,
reopenAfterDrag: s.reopenAfterDrag,
setAssetModalVisible: s.setAssetModalVisible,
setReopenAfterDrag: s.setReopenAfterDrag,
@@ -142,6 +142,15 @@ export const AssetModal = () => {
useEffect(() => {
if (assetModalVisible) {
// Whenever the modal becomes visible, clear any leftover drag-under state.
// A reopen-off drag closes the panel while keeping `assetDraggingUnder`
// true (so its fade-out doesn't flash), so without this a later reopen
// would render straight back into the hidden state. Resetting here is the
// single safety net covering every open path (toolbar +, asset menu,
// upload-reopen). It can't clobber an active drag: `assetModalVisible`
// doesn't change mid-drag, so this effect doesn't re-run then.
usePageSceneStore.getState().setAssetDraggingUnder(false);
const lastUploadedTab = sessionStorage.getItem("lastUploadedTab");
if (lastUploadedTab) {
setActiveAssetTab(lastUploadedTab);
@@ -457,7 +466,7 @@ export const AssetModal = () => {
return (
<>
<Modal
isOpen={assetModalVisible && assetModalVisibleDuringDrag}
isOpen={assetModalVisible}
onClose={handleClose}
className="relative h-[640px] max-w-4xl"
childPadding={false}
@@ -466,6 +475,13 @@ export const AssetModal = () => {
draggable={true}
closeOnOutsideClick={false}
allowBackgroundInteraction={true}
// While dragging an asset out, the panel becomes pointer-transparent so
// the drag passes under it onto the canvas. With "Reopen after adding"
// on it steps back to translucent; with it off it eases all the way out
// (closing after the drop). Same drag-under behavior as the gallery.
contentInteractive={!assetDraggingUnder}
contentDimmed={assetDraggingUnder && reopenAfterDrag}
contentHidden={assetDraggingUnder && !reopenAfterDrag}
>
<Modal.DragHandle>
<div className="absolute left-0 top-0 z-[50] h-[46px] w-full cursor-move" />
@@ -20,6 +20,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
galleryModalVisibleViewMode,
galleryModalVisibleDuringDrag,
galleryModalDraggingUnder,
} from "@storyteller/ui-gallery-modal";
import { twMerge } from "tailwind-merge";
@@ -43,6 +44,7 @@ export const Controls3D = ({
assetModalVisible,
setAssetModalVisible,
setAssetModalVisibleDuringDrag,
setAssetDraggingUnder,
selectedMode,
transformSpace,
currentUserToken,
@@ -52,6 +54,7 @@ export const Controls3D = ({
assetModalVisible: s.assetModalVisible,
setAssetModalVisible: s.setAssetModalVisible,
setAssetModalVisibleDuringDrag: s.setAssetModalVisibleDuringDrag,
setAssetDraggingUnder: s.setAssetDraggingUnder,
selectedMode: s.selectedMode,
transformSpace: s.transformSpace,
currentUserToken: s.currentUserToken,
@@ -100,6 +103,9 @@ export const Controls3D = ({
const handleOpenModal = () => {
setAssetModalVisibleDuringDrag(true);
setAssetModalVisible(true);
// Clear any leftover drag-under state so the panel opens fully shown (a
// reopen-off drag leaves it faded-hidden until the next open).
setAssetDraggingUnder(false);
};
const handleOpenCreate3dModal = () => {
@@ -109,6 +115,9 @@ export const Controls3D = ({
const handleOpenGalleryModal = () => {
galleryModalVisibleViewMode.value = true;
galleryModalVisibleDuringDrag.value = true;
// Clear any leftover drag-under state so the panel opens fully shown (a
// reopen-off drag leaves it faded-hidden until the next open).
galleryModalDraggingUnder.value = false;
};
const handleAddAssetAction = (action: string) => {
@@ -1,53 +1,29 @@
import { createPortal } from "react-dom";
import { DragGhost } from "@storyteller/ui-gallery-modal";
import { usePageSceneStore } from "../../PageSceneStore";
// Portaled to <body> so the chip stays anchored to the page (using the
// document-coord pageX/pageY values stored in dragPosition) regardless
// of any positioned/`transform`ed ancestor the host wraps Stage3D in.
// Without the portal, hosts whose Stage3D wrapper is a containing
// block (e.g. the webapp's wrapper uses `transform: translateZ(0)` to
// scope the lib's fixed overlays) push the chip away from the cursor
// by the wrapper's offset. Tauri has no such wrapper so the portal is
// a no-op for it.
export const DragComponent = () => {
const dragItem = usePageSceneStore((s) => s.dragItem);
const dragPosition = usePageSceneStore((s) => s.dragPosition);
if (!dragItem) {
return null;
}
if (typeof document === "undefined") {
return null;
}
const { currX, currY } = dragPosition;
const thumbnail = dragItem.thumbnail
? dragItem.thumbnail
: `/resources/images/default-covers/${dragItem.imageIndex || 0}.webp`;
return createPortal(
<div
className="pointer-events-none absolute rounded-lg"
style={{
width: 91,
height: 114,
top: currY - 57,
left: currX + 1,
zIndex: 10000,
}}
>
<img
{...{
crossOrigin: "anonymous",
src: thumbnail,
}}
alt={dragItem.name}
className="pointer-events-none select-none rounded-t-lg bg-gradient-to-b from-[#CCCCCC] to-[#A0A0A0]"
/>
<div className="w-full truncate rounded-b-lg bg-ui-controls px-2 py-1 text-center text-[12px]">
{dragItem.name || dragItem.media_id}
</div>
</div>,
document.body,
);
};
// The floating chip shown while dragging an asset out of the library. Feeds the
// shared DragGhost (lift/tilt/follow) from the pagescene store, so the asset
// library gets the exact same pickup motion as the gallery. Gated on
// `assetDraggingUnder` so it only appears once a real drag begins (not on a
// plain click, which sets dragItem on pointer-down).
export const DragComponent = () => (
<DragGhost
width={96}
height={120}
crossOrigin="anonymous"
getState={() => {
const { dragItem, dragPosition, assetDraggingUnder } =
usePageSceneStore.getState();
if (!dragItem || !assetDraggingUnder) return null;
const thumbnail =
dragItem.thumbnail ||
`/resources/images/default-covers/${dragItem.imageIndex || 0}.webp`;
return {
x: dragPosition.currX,
y: dragPosition.currY,
thumbnail,
label: dragItem.name || dragItem.media_id,
};
}}
/>
);
@@ -0,0 +1,64 @@
// Dev-only floating control for tuning the drop-in entrance animation timing
// without re-editing constants. Mounted only under import.meta.env.DEV in
// Stage3DBody. Sets the shared `entranceDebugSettings.durationScale`, which the
// EntranceAnimator reads when a new object is dropped — so move the slider, then
// drop an object to see it at that speed.
import { useEffect, useState } from "react";
import {
ENTRANCE_BASE_DURATIONS,
entranceDebugSettings,
} from "../engine/entranceAnimation";
// The preferred timing is now baked into ENTRANCE_BASE_DURATIONS, so the panel
// starts at 1× (real) and just lets you scale around it while tuning.
const DEFAULT_SCALE = 1;
export function EntranceDebugPanel() {
const [scale, setScale] = useState(DEFAULT_SCALE);
// Keep the shared knob in sync with this panel's lifetime (dev only).
useEffect(() => {
entranceDebugSettings.durationScale = DEFAULT_SCALE;
return () => {
entranceDebugSettings.durationScale = 1;
};
}, []);
const apply = (value: number) => {
entranceDebugSettings.durationScale = value;
setScale(value);
};
const meshSec = (ENTRANCE_BASE_DURATIONS.mesh * scale).toFixed(2);
const splatSec = (ENTRANCE_BASE_DURATIONS.splat * scale).toFixed(2);
return (
<div className="pointer-events-auto absolute right-2 top-16 z-50 w-56 rounded-lg border border-white/10 bg-black/70 p-3 text-xs text-white/80 backdrop-blur-md">
<div className="mb-2 flex items-center justify-between font-medium text-white/90">
<span>Entrance speed</span>
<span className="tabular-nums">{scale.toFixed(2)}×</span>
</div>
<input
type="range"
min={0.25}
max={12}
step={0.25}
value={scale}
onChange={(e) => apply(parseFloat(e.target.value))}
className="w-full accent-primary"
/>
<div className="mt-2 flex justify-between tabular-nums text-white/50">
<span>mesh {meshSec}s</span>
<span>splat {splatSec}s</span>
</div>
<button
type="button"
onClick={() => apply(1)}
className="mt-2 w-full rounded border border-white/10 py-1 text-white/60 transition-colors hover:bg-white/10 hover:text-white/90"
>
Reset to 1× (real timing)
</button>
</div>
);
}
@@ -9,6 +9,7 @@ import { SaveManager } from "./save_manager";
import { SceneGenereationMetaData } from "../models/sceneGenerationMetadata";
import { SceneManager } from "./scene_manager_api";
import { EntranceAnimator } from "./entranceAnimation";
import { ViewportController } from "./editor/ViewportController";
import { PostProcessingPipeline } from "./editor/PostProcessingPipeline";
import { GizmoController } from "./editor/GizmoController";
@@ -81,6 +82,12 @@ class Editor {
// engine/editor/actions/ encapsulates its own apply/revert.
history: HistoryManager;
// Plays the entrance animation for newly added objects (per-gaussian radial
// settle for splats, silhouette mask-reveal for meshes). The drop actions
// (addObject/addCharacter/addShape) register the object here and the per-frame
// render loop ticks it. Field initializer so it's always ready.
readonly entranceAnimator = new EntranceAnimator();
// Typed event bus the engine emits onto. EngineStoreBridge subscribes
// once at construction and is the only file under engine/ that
// imports the Zustand store.
@@ -868,6 +875,9 @@ class Editor {
this.cameraController.tickPerFrame(delta_time);
// Advance any in-flight entrance animations.
this.entranceAnimator.tick(delta_time);
this.activeScene.shader_objects.forEach((shader) => {
shader.material.uniforms["time"].value += 0.5 * delta_time;
});
@@ -0,0 +1,37 @@
// Shared easing curves for the entrance animations. Kept as plain scalar
// functions so both the transform-based mesh drop and the shader-driven splat
// settle pull from the same vocabulary of motion.
export function clamp01(t: number): number {
return t < 0 ? 0 : t > 1 ? 1 : t;
}
export function lerp(a: number, b: number, t: number): number {
return a + (b - a) * t;
}
/** Decelerate to rest — the default for things arriving. */
export function easeOutCubic(t: number): number {
return 1 - Math.pow(1 - t, 3);
}
/** Accelerate from rest — reads as gravity for a fall. */
export function easeInCubic(t: number): number {
return t * t * t;
}
/** Stronger gravity feel for the drop phase. */
export function easeInQuart(t: number): number {
return t * t * t * t;
}
/** Symmetric ease for settling motions. */
export function easeInOutCubic(t: number): number {
return t < 0.5 ? 4 * t * t * t : 1 - Math.pow(-2 * t + 2, 3) / 2;
}
/** Overshoot-and-settle — the "pop" when an object first appears. */
export function easeOutBack(t: number, overshoot = 1.70158): number {
const c3 = overshoot + 1;
return 1 + c3 * Math.pow(t - 1, 3) + overshoot * Math.pow(t - 1, 2);
}
@@ -0,0 +1,91 @@
import * as THREE from "three";
import type { EntranceHandle } from "./types";
import { clamp01, easeInQuart, easeOutBack, easeOutCubic, lerp } from "./easing";
// Transform-based "drop onto the ground" entrance for non-splat objects (GLB
// models, primitives, image planes). Replaces the earlier fragment-clip reveal,
// which mis-clipped non-convex shapes (e.g. a torus). The object pops into
// existence slightly raised, then falls and lands with a soft rubbery vertical
// squash — as if it were physically dropped into place. Only the object's
// transform is touched (no shaders), so it works for any object uniformly.
// Phase boundaries on linear time (0..1):
const POP_END = 0.22; // appear + rise finishes here
const IMPACT = 0.55; // object reaches the ground here; squash begins
// Squash/recover runs from IMPACT → 1.
const POP_START_SCALE = 0.45; // appears at this fraction of full size, then pops up
const SQUASH_AMOUNT = 0.12; // peak vertical compression (0.12 = 12%); kept subtle
const SQUASH_DECAY = 3.6; // how fast the rubbery wobble dies out
const SQUASH_FREQ = 6.5; // wobble frequency (one squash + a small rebound)
export function buildMeshDrop(object: THREE.Object3D): EntranceHandle | null {
const finalPos = object.position.clone();
const finalScale = object.scale.clone();
// Size drives the hop height and the ground-anchor compensation so the motion
// reads the same for a teacup and a building.
const size = new THREE.Box3().setFromObject(object).getSize(new THREE.Vector3());
const maxDim = Math.max(size.x, size.y, size.z) || 1;
const halfHeight = Math.max(size.y * 0.5, 1e-3);
const peak = THREE.MathUtils.clamp(0.22 * maxDim, 0.08, 0.45);
return {
setProgress: (t) => {
const pop = popScale(t);
const yHop = hopOffset(t, peak);
const { sy, sxz } = squash(t);
// Keep the base planted while it squashes: a center-pivot scaleY<1 lifts
// the bottom by (1-sy)*halfHeight, so push the object down to compensate.
const groundComp = -(1 - sy) * halfHeight * pop;
object.position.set(
finalPos.x,
finalPos.y + yHop + groundComp,
finalPos.z,
);
object.scale.set(
finalScale.x * pop * sxz,
finalScale.y * pop * sy,
finalScale.z * pop * sxz,
);
},
finish: () => {
object.position.copy(finalPos);
object.scale.copy(finalScale);
},
};
}
/** Appear with an overshooting pop, then hold at full size. */
function popScale(t: number): number {
const appear = easeOutBack(clamp01(t / POP_END), 2.2);
return lerp(POP_START_SCALE, 1, appear);
}
/** Rise up from the ground as it pops in, then fall back down under gravity. */
function hopOffset(t: number, peak: number): number {
if (t <= POP_END) {
// Lift off the ground while it materializes, so the rise is visible.
return peak * easeOutCubic(t / POP_END);
}
if (t < IMPACT) {
// Accelerating fall down to the ground.
return peak * (1 - easeInQuart((t - POP_END) / (IMPACT - POP_END)));
}
return 0;
}
/** Rubbery vertical squash-and-stretch that fires on landing and settles out. */
function squash(t: number): { sy: number; sxz: number } {
if (t < IMPACT) return { sy: 1, sxz: 1 };
const u = clamp01((t - IMPACT) / (1 - IMPACT));
// sin starts at 0 (no jump at the instant of contact), swells to a squash,
// then a damped rebound — a single bouncy wobble that decays to rest.
const wobble = Math.exp(-SQUASH_DECAY * u) * Math.sin(SQUASH_FREQ * u);
return {
sy: 1 - SQUASH_AMOUNT * wobble,
sxz: 1 + SQUASH_AMOUNT * 0.6 * wobble,
};
}
@@ -0,0 +1,109 @@
import * as THREE from "three";
import { SplatMesh, dyno } from "@sparkjsdev/spark";
import type { EntranceHandle } from "./types";
import { easeInOutCubic } from "./easing";
// Per-gaussian "radial settle" entrance for gaussian splats. Each gaussian rains
// down from a height and arrives as a center-out wave: gaussians near the splat's
// centroid fall in and fade up first, the farthest ones last. Done in Spark's
// `dyno` shader graph via an objectModifier — the only way to touch gaussians
// individually.
//
// The wave is driven by one float uniform (`uProgress`) we advance each frame
// from the editor render loop, so it doesn't depend on Spark's internal time
// ticker (which isn't wired up here — SparkRenderer is unused).
// Wide wavefront so each gaussian fades and falls over a leisurely window rather
// than snapping in — the fade should read as a clear alpha ramp during the rain.
const SOFT_EDGE = 0.38; // width of the reveal wavefront, as a fraction of radius
const MIN_SCALE = 0.25; // gaussians start at this fraction of their size, then pop to full
const DROP_FACTOR = 0.5; // fall height as a multiple of the splat radius
export function buildSplatSettle(splat: SplatMesh): EntranceHandle | null {
// Normalize per-gaussian distance by the splat radius so the wave covers the
// whole cloud regardless of scale.
let radius = 1;
try {
const box = splat.getBoundingBox(true);
const size = box.getSize(new THREE.Vector3());
radius = 0.5 * Math.max(size.x, size.y, size.z);
} catch {
radius = 1;
}
if (!(radius > 0) || !Number.isFinite(radius)) radius = 1;
// World "up" expressed in the splat's local frame, so gaussians fall from
// straight above regardless of the splat's orientation (splats are loaded with
// a 180° flip, so local +Y is not world up).
splat.updateWorldMatrix(true, false);
const worldQuat = splat.getWorldQuaternion(new THREE.Quaternion());
const upLocal = new THREE.Vector3(0, 1, 0).applyQuaternion(worldQuat.invert());
const uProgress = dyno.dynoFloat(0);
const previousModifier = splat.objectModifier;
const modifier = dyno.dynoBlock(
{ gsplat: dyno.Gsplat },
{ gsplat: dyno.Gsplat },
({ gsplat }) => {
if (!gsplat) throw new Error("splat settle: gsplat undefined");
const split = dyno.splitGsplat(gsplat);
const center = split.outputs.center; // vec3, object space
const opacity = split.outputs.opacity; // float
const scales = split.outputs.scales; // vec3
// Normalized distance of this gaussian from the splat centroid (0..~1).
const nd = dyno.div(dyno.length(center), dyno.dynoFloat(radius));
// Reveal wavefront: gaussians with normalized distance below the growing
// threshold are settled. `reveal` goes 1 (arrived) → 0 (not yet).
const edge0 = dyno.sub(uProgress, dyno.dynoFloat(SOFT_EDGE));
const reveal = dyno.sub(
dyno.dynoFloat(1),
dyno.smoothstep(edge0, uProgress, nd),
);
const notArrived = dyno.sub(dyno.dynoFloat(1), reveal);
// Height drop: un-arrived gaussians sit `DROP_FACTOR * radius` above their
// home along world-up, and fall into place as they reveal.
const fall = dyno.mul(
dyno.dynoVec3(upLocal),
dyno.mul(dyno.dynoFloat(radius * DROP_FACTOR), notArrived),
);
const newCenter = dyno.add(center, fall);
const newOpacity = dyno.mul(opacity, reveal);
const popScale = dyno.clamp(
reveal,
dyno.dynoFloat(MIN_SCALE),
dyno.dynoFloat(1),
);
const newScales = dyno.mul(scales, popScale);
return {
gsplat: dyno.combineGsplat({
gsplat,
center: newCenter,
opacity: newOpacity,
scales: newScales,
}),
};
},
);
splat.objectModifier = modifier;
splat.updateGenerator();
return {
setProgress: (t) => {
// Ease the wavefront so it settles softly, and push a touch past 1 so the
// outermost gaussians (nd ≈ 1) fully arrive by the end.
uProgress.value = easeInOutCubic(t) * (1 + SOFT_EDGE);
splat.updateGenerator();
},
finish: () => {
splat.objectModifier = previousModifier;
splat.updateGenerator();
},
};
}
@@ -0,0 +1,14 @@
// Shared contract for the two entrance-animation techniques (mesh mask-reveal,
// splat radial-settle). The coordinator drives `setProgress` each frame and
// calls `finish` once when the animation completes.
export interface EntranceHandle {
/**
* Advance the animation. `progress` is the eased 0..1 completion; `dt` is the
* frame delta in seconds (some techniques need it, some ignore it).
*/
setProgress(progress: number, dt: number): void;
/** Snap to fully-shown and release any temporary resources (clones, modifiers). */
finish(): void;
}
@@ -0,0 +1,114 @@
import * as THREE from "three";
import { SplatMesh } from "@sparkjsdev/spark";
import type { EntranceHandle } from "./entrance/types";
import { buildMeshDrop } from "./entrance/meshDrop";
import { buildSplatSettle } from "./entrance/splatSettle";
// Drives entrance animations for newly dropped objects. Two techniques, picked
// by object type:
// • Gaussian splats → a per-gaussian radial settle: gaussians rain in from a
// height, center-out, built in Spark's dyno shader graph. See splatSettle.ts.
// • Everything else → a transform-based drop with a rubbery landing squash.
// See meshDrop.ts.
//
// Both expose the same EntranceHandle. This coordinator advances raw linear time
// 0..1 each frame (each technique applies its own easing) and snaps to "shown" on
// completion. Saves in this editor are explicit (user-triggered), so the
// animation window is never persisted.
// Base durations (seconds). The mesh drop reads best snappy (its physical bounce
// would feel sluggish drawn out); the splat rain wants room to breathe. The dev
// panel can still scale these live.
export const ENTRANCE_BASE_DURATIONS = { mesh: 0.75, splat: 4.0 };
// Dev-only live tuning knob, driven by EntranceDebugPanel (mounted only in DEV).
// Defaults to 1 so the baked timing above is what ships; the panel scales it for
// experimentation. Read at play() time, so it applies to the next drop.
export const entranceDebugSettings = { durationScale: 1 };
const MAX_DELTA = 1 / 30; // clamp per-frame step so a paused/resumed loop doesn't jump
function prefersReducedMotion(): boolean {
return (
typeof window !== "undefined" &&
typeof window.matchMedia === "function" &&
window.matchMedia("(prefers-reduced-motion: reduce)").matches
);
}
interface Entry {
object: THREE.Object3D;
handle: EntranceHandle;
elapsed: number;
duration: number;
}
export class EntranceAnimator {
private entries: Entry[] = [];
/**
* Begin an entrance for `object`, treating its current transform as final.
* Call immediately after the object is positioned and added to the scene —
* before the next render — so it's never shown fully-formed first. No-ops
* (leaves the object shown) when the user prefers reduced motion.
*/
play(object: THREE.Object3D): void {
if (prefersReducedMotion()) return;
const isSplat = object instanceof SplatMesh;
const handle = isSplat
? buildSplatSettle(object)
: buildMeshDrop(object);
if (!handle) return;
// Run the first frame hidden so nothing flashes at full size before the
// render loop's first tick.
handle.setProgress(0, 0);
const base = isSplat
? ENTRANCE_BASE_DURATIONS.splat
: ENTRANCE_BASE_DURATIONS.mesh;
this.entries = this.entries.filter((entry) => entry.object !== object);
this.entries.push({
object,
handle,
elapsed: 0,
duration: base * entranceDebugSettings.durationScale,
});
}
/** Advance all active entrances. Call once per frame from the render loop. */
tick(delta: number): void {
if (this.entries.length === 0) return;
const dt = Math.min(delta, MAX_DELTA);
const remaining: Entry[] = [];
for (const entry of this.entries) {
entry.elapsed += dt;
const t = Math.min(entry.elapsed / entry.duration, 1);
// Pass raw linear time; each technique applies its own easing.
entry.handle.setProgress(t, dt);
if (t < 1) {
remaining.push(entry);
} else {
entry.handle.finish();
}
}
this.entries = remaining;
}
/** Finish an object's entrance immediately and stop tracking it. */
cancel(object: THREE.Object3D): void {
const entry = this.entries.find((candidate) => candidate.object === object);
if (!entry) return;
entry.handle.finish();
this.entries = this.entries.filter((candidate) => candidate !== entry);
}
/** Finish all entrances (e.g. on scene clear/teardown). */
clear(): void {
for (const entry of this.entries) entry.handle.finish();
this.entries = [];
}
}