Webapp stylings (#1916)

This commit is contained in:
BFlat authored and GitHub committed 2026-09-01 10:25:21 +07:00
1 parent d2ad5820e0
commit 60bcfffc8d
156 files changed
+2512 -2753

No files matched your search

@@ -1,8 +1,8 @@
import * as React from "react"
import { CheckIcon, MinusIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import * as React from "react";
import { CheckIcon, MinusIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Checkbox({
className,
@@ -14,8 +14,8 @@ function Checkbox({
data-slot="checkbox"
checked={checked}
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary",
className
"peer size-4 shrink-0 rounded-[3px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary",
className,
)}
{...props}
>
@@ -30,7 +30,7 @@ function Checkbox({
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };
@@ -59,7 +59,9 @@ export function UserSignups() {
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setUsers((prev) => (append ? [...prev, ...resp.data!.users] : resp.data!.users));
setUsers((prev) =>
append ? [...prev, ...resp.data!.users] : resp.data!.users,
);
setNextCursor(resp.data.next_cursor);
} else {
setError(resp.errorMessage || "Failed to load users");
@@ -169,7 +171,9 @@ export function UserSignups() {
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
<IconRefresh
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
@@ -188,7 +192,8 @@ export function UserSignups() {
All Users
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground ml-1">
({users.length}{nextCursor != null ? "+" : ""})
({users.length}
{nextCursor != null ? "+" : ""})
</span>
)}
</h3>
@@ -234,15 +239,33 @@ export function UserSignups() {
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="size-4 rounded-[4px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
<TableCell><Skeleton className="h-4 w-36" /></TableCell>
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-5 w-20 rounded-full" /></TableCell>
<TableCell><Skeleton className="h-6 w-20 rounded" /></TableCell>
<TableCell>
<Skeleton className="size-4 rounded-[3px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-36" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-20 rounded" />
</TableCell>
</TableRow>
))}
</TableBody>
@@ -305,12 +328,21 @@ export function UserSignups() {
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{user.display_name || <span className="text-muted-foreground/30">&mdash;</span>}
{user.display_name || (
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
</TableCell>
<TableCell className="text-sm">
<span className="text-muted-foreground">{user.email_address}</span>
<span className="text-muted-foreground">
{user.email_address}
</span>
{!user.email_confirmed && (
<Badge variant="outline" className="ml-2 text-[10px] text-amber-400 border-amber-400/30">
<Badge
variant="outline"
className="ml-2 text-[10px] text-amber-400 border-amber-400/30"
>
Unconfirmed
</Badge>
)}
@@ -330,40 +362,65 @@ export function UserSignups() {
{user.maybe_source}
</Badge>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
</TableCell>
<TableCell className="text-xs">
{user.maybe_signup_method ? (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
<Badge
variant="outline"
className="text-[10px] text-muted-foreground"
>
{user.maybe_signup_method}
</Badge>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
</TableCell>
<TableCell className="text-xs">
<div className="flex items-center gap-1.5">
{user.is_temporary && (
<Badge variant="secondary" className="text-[10px] bg-amber-500/10 text-amber-400 border-transparent">
<Badge
variant="secondary"
className="text-[10px] bg-amber-500/10 text-amber-400 border-transparent"
>
Temp
</Badge>
)}
{user.is_without_password && (
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-400 border-transparent">
<Badge
variant="secondary"
className="text-[10px] bg-orange-500/10 text-orange-400 border-transparent"
>
No Password
</Badge>
)}
{!user.is_temporary && !user.is_without_password && (
<Badge variant="secondary" className="text-[10px] bg-emerald-500/10 text-emerald-400 border-transparent">
<Badge
variant="secondary"
className="text-[10px] bg-emerald-500/10 text-emerald-400 border-transparent"
>
Active
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-xs">
<Button variant="ghost" size="sm" asChild className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
<a href={`/user/profile/${user.username}`} target="_blank" rel="noopener noreferrer">
<Button
variant="ghost"
size="sm"
asChild
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<a
href={`/user/profile/${user.username}`}
target="_blank"
rel="noopener noreferrer"
>
<IconExternalLink className="size-3.5" />
View Profile
</a>
@@ -380,7 +437,9 @@ export function UserSignups() {
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">Scroll for more</span>
<span className="text-muted-foreground/50">
Scroll for more
</span>
)}
</TableCell>
</TableRow>
+1 -1
View File
@@ -65,7 +65,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&family=Instrument+Serif:ital@0;1&display=swap"
href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&family=Instrument+Serif:ital@0;1&family=Geist+Mono:wght@300..800&display=swap"
rel="stylesheet"
/>
</head>
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -61,10 +61,10 @@ function ScrollToTop() {
function AuthCheckSpinner() {
return (
<div className="flex h-screen items-center justify-center bg-[#101014]">
<div className="flex h-screen items-center justify-center bg-ui-background">
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
className="animate-spin text-4xl text-white/70" />
</div>
);
}
@@ -114,7 +114,7 @@ function ProtectedContent() {
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
>
{!hideTopBar && <TopBar />}
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-[#121212]">
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-ui-background">
<PageTransition>
<Outlet />
</PageTransition>
@@ -60,15 +60,15 @@ export function GoogleLoginButton({
className={`group relative w-full h-10 transition-transform duration-150 active:scale-95 ${className}`}
>
{/* Custom visual button - shown behind */}
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 bg-white text-black rounded-full font-medium border border-gray-200 group-hover:border-white/80 group-hover:bg-white/80 transition-colors">
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 rounded-[3px] bg-white text-black font-medium group-hover:bg-white/80 transition-colors">
{isLoading ? (
<LoaderCircleIcon
className="animate-spin text-lg text-gray-600" />
<LoaderCircleIcon className="animate-spin text-lg text-black/60" />
) : (
<>
<GoogleIcon />
<span className="text-sm font-semibold">Continue with Google</span>
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Continue with Google
</span>
</>
)}
</div>
@@ -31,7 +31,7 @@ export const AuthLayout = () => {
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#101014] p-4 text-white">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ui-background p-4 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
@@ -48,16 +48,7 @@ export const AuthLayout = () => {
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden rounded-3xl border border-white/[4%] bg-[#1C1C20] shadow-2xl lg:min-h-[640px]">
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden border border-white/15 bg-[#101014] lg:min-h-[640px]">
{/* Form pane — only the inner content swaps per route (via Outlet), so
this pane and the showcase beside it stay mounted across the
login/signup toggle. */}
@@ -66,7 +57,7 @@ export const AuthLayout = () => {
type="button"
onClick={handleBack}
aria-label="Go back"
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center rounded-full text-white/60 transition-colors hover:bg-white/10 hover:text-white"
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white"
>
<ArrowLeftIcon />
</button>
@@ -4,7 +4,7 @@ const FEATURED_VIDEO_URL =
export const AuthShowcase = () => {
return (
<div
className="absolute inset-2 overflow-hidden bg-black rounded-2xl"
className="absolute inset-2 overflow-hidden bg-black"
style={{ containerType: "size" }}
>
{/* Vimeo background embed (no controls) scaled to cover the pane. The
@@ -30,7 +30,7 @@ export const AuthShowcase = () => {
{/* Caption */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-8">
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-primary-300">
<p className="mb-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
One of the cheapest
</p>
<h2 className="text-2xl font-bold leading-tight">
@@ -107,7 +107,7 @@ export const SignupForm = ({
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
@@ -123,7 +123,7 @@ export const SignupForm = ({
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
Email
</label>
<Input
@@ -132,13 +132,13 @@ export const SignupForm = ({
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</Reveal>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
<div className="flex justify-between items-center">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
Password
</label>
</div>
@@ -148,7 +148,7 @@ export const SignupForm = ({
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -163,12 +163,12 @@ export const SignupForm = ({
<Reveal className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Create account"
)}
@@ -190,7 +190,7 @@ export const SignupForm = ({
<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">
<span className="relative bg-[#101014] px-4 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
or
</span>
</Reveal>
@@ -51,7 +51,7 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-2xl w-full max-w-2xl max-h-[100vh] overflow-y-auto overflow-x-hidden border border-white/5 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
className="w-full max-w-2xl max-h-[100vh] overflow-y-auto overflow-x-hidden border border-white/15 bg-ui-modal p-0"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
@@ -60,16 +60,6 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
backdropClassName="bg-black/80"
>
<div className="relative overflow-hidden">
{/* Off-center ambient glow, feels designed, not generic */}
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-11 sm:pb-9">
<h2 className="font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
Buy <span className="text-primary">credits</span>
@@ -89,23 +79,21 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
onClick={() => handlePurchase(pack)}
disabled={purchasingId !== null}
className={twMerge(
"group relative flex flex-col gap-6 rounded-2xl border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
"group relative flex flex-col gap-6 border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
isPopular
? "border-primary/50 bg-primary/[0.07] hover:border-primary"
: "border-white/10 bg-white/[0.02] hover:border-white/25 hover:bg-white/[0.04]",
? "border-white/60 bg-white/[0.06] hover:border-white"
: "border-white/15 bg-white/[0.02] hover:border-white/30 hover:bg-white/[0.04]",
)}
>
{pack.badge && (
<span className="absolute -top-2.5 right-3 rounded-full bg-primary px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-white shadow-lg">
<span className="absolute -top-2.5 right-3 bg-white px-2.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-black">
{pack.badge}
</span>
)}
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<CoinsIcon
className="text-primary text-lg" />
<span className="flex h-12 w-12 shrink-0 items-center justify-center bg-white/10">
<CoinsIcon className="text-white text-lg" />
</span>
<div className="min-w-0">
<div className="text-4xl font-bold leading-none tracking-tight text-white">
@@ -119,17 +107,13 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
<span className="text-2xl font-semibold text-white/80">
${pack.priceUsd}
</span>
<span className="flex items-center gap-1.5 text-base font-semibold text-primary-400">
<span className="flex items-center gap-1.5 text-base font-semibold text-white">
{isLoading ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
<>
Buy
<ArrowRightIcon
className="text-xs transition-transform group-hover:translate-x-0.5" />
<ArrowRightIcon className="text-xs transition-transform group-hover:translate-x-0.5" />
</>
)}
</span>
@@ -80,10 +80,10 @@ export function CreateMediaPageShell({
if (!authChecked) {
return (
<div className="flex h-full items-center justify-center bg-[#101014]">
<div className="flex h-full items-center justify-center bg-ui-background">
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
className="animate-spin text-4xl text-white/60" />
</div>
);
}
@@ -99,7 +99,7 @@ export function CreateMediaPageShell({
goToHistory: () => setMobileTab("history"),
}}
>
<div className="flex h-full w-full flex-col bg-[#101014] text-white">
<div className="flex h-full w-full flex-col bg-ui-background text-white">
<Seo title={title} description={description} />
{/* Flex (not absolute) keeps the toggle cluster from overlapping
@@ -150,27 +150,20 @@ export function CreateMediaPageShell({
}
return (
<div className="flex h-full w-full bg-[#101014] text-white">
<div className="flex h-full w-full bg-ui-background text-white">
<Seo title={title} description={description} />
{/* Decorative background, empty state only. Not rendered on mobile
devices: the glow orbs use filter: blur(120px) and the truchet uses
mask-image, both of which are full-screen GPU layers that iOS Safari
must re-rasterize when an overlay (mobile menu, modal) composites over
them, causing multi-second hangs. (This is distinct from
backdrop-filter.) We gate on the actual device (react-device-detect's
user-agent check) rather than a viewport breakpoint so the layers
never mount on a real phone/tablet, and a narrow desktop window still
gets the decoration. */}
devices: the truchet uses mask-image, a full-screen GPU layer that
iOS Safari must re-rasterize when an overlay (mobile menu, modal)
composites over it, causing multi-second hangs. (This is distinct
from backdrop-filter.) We gate on the actual device
(react-device-detect's user-agent check) rather than a viewport
breakpoint so the layers never mount on a real phone/tablet, and a
narrow desktop window still gets the decoration. */}
{!hasContent && !isMobile && (
<>
{glowOrbs ?? (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
<div className="absolute bottom-[-15%] right-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-purple-600 via-blue-500 to-[#00AABA] opacity-[0.08] blur-[120px] transform-gpu" />
<div className="absolute bottom-[20%] left-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
</div>
)}
{glowOrbs}
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
@@ -193,7 +186,7 @@ export function CreateMediaPageShell({
<div className="flex h-full w-full flex-col">
{!hasContent && (
<div className="flex flex-1 items-center justify-center">
<div className="animate-fade-in-up relative z-20 mb-32 flex flex-col items-center justify-center text-center drop-shadow-xl">
<div className="animate-fade-in-up relative z-20 mb-32 flex flex-col items-center justify-center text-center">
<h1 className="text-5xl font-semibold text-white md:text-7xl">
{emptyStateTitle}
</h1>
@@ -219,7 +212,7 @@ export function CreateMediaPageShell({
{hasContent && (
<div
aria-hidden
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-[#101014] to-transparent"
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-ui-background to-transparent"
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
/>
)}
@@ -83,7 +83,7 @@ export function GallerySelectionDownloadBar({
type="button"
onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
className="flex items-center gap-2 border border-white/15 bg-ui-controls/60 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60 disabled:opacity-60"
>
<DynamicIcon
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
@@ -1,4 +1,11 @@
import { ArrowDownToLineIcon, CheckIcon, LinkIcon, LoaderCircleIcon, RotateCwIcon, VideoIcon } from "lucide-react";
import {
ArrowDownToLineIcon,
CheckIcon,
LinkIcon,
LoaderCircleIcon,
RotateCwIcon,
VideoIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
@@ -85,7 +92,7 @@ function CardRecreateButton({
type="button"
onClick={handleRecreate}
disabled={isRecreating}
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
className="flex items-center gap-1.5 border border-white/15 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
>
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
@@ -103,7 +110,7 @@ function CardRecreateButton({
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
className="flex h-7 w-7 shrink-0 items-center justify-center text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
>
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
@@ -136,7 +143,7 @@ function CardActions({
} = useGalleryItemActions(item, { enableMakeVideo });
const buttonClass =
"flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
"flex h-7 w-7 items-center justify-center text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
return (
<>
@@ -164,7 +171,7 @@ function CardActions({
onClick={handleMakeVideo}
aria-label="Make Video"
>
<VideoIcon className="text-sm" />
<VideoIcon className="text-sm" />
</button>
</Tooltip>
)}
@@ -103,8 +103,8 @@ function RowRecreateButton({
aria-label="Recreate"
className={
kind === "pending"
? "flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
? "flex h-7 w-7 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
: "flex h-7 w-7 shrink-0 items-center justify-center text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
}
>
<DynamicIcon
@@ -152,7 +152,7 @@ function GalleryRowActions({
} = actions;
const buttonClass =
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
"flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
return (
<>
@@ -128,7 +128,7 @@ export function InsufficientCreditsModal() {
<Modal
isOpen={isOpen}
onClose={close}
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/5 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
className="w-full max-w-md overflow-hidden border border-white/15 bg-ui-modal p-0"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
@@ -137,16 +137,6 @@ export function InsufficientCreditsModal() {
backdropClassName="bg-black/80"
>
<div className="relative overflow-hidden">
{/* Off-center ambient glow, feels designed, not generic */}
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
{copy.heading}
@@ -161,10 +151,8 @@ export function InsufficientCreditsModal() {
key={perk}
className="flex items-start gap-3 text-[14px] text-white/75"
>
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/15">
<CheckIcon
className="text-[9px] text-primary" />
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center bg-white/10">
<CheckIcon className="text-[9px] text-white" />
</span>
<span>{perk}</span>
</li>
@@ -176,10 +164,7 @@ export function InsufficientCreditsModal() {
<>
{!isOnBiggestPlan && (
<Link to="/pricing" onClick={close} className="block">
<Button
variant="primary"
className="w-full h-10 text-sm font-semibold rounded-full"
>
<Button variant="primary" className="w-full h-10">
Upgrade plan
</Button>
</Link>
@@ -187,17 +172,14 @@ export function InsufficientCreditsModal() {
<Button
variant={isOnBiggestPlan ? "primary" : "secondary"}
onClick={handleBuyCredits}
className="w-full h-10 text-sm font-semibold rounded-full"
className="w-full h-10"
>
Buy more credits
</Button>
</>
) : (
<Link to="/pricing" onClick={close} className="block">
<Button
variant="primary"
className="w-full h-10 text-sm font-semibold rounded-full"
>
<Button variant="primary" className="w-full h-10">
Upgrade plan
</Button>
</Link>
@@ -4,12 +4,26 @@ import { Button } from "@storyteller/ui-button";
import { toast } from "../toast/toast";
import { Gravatar } from "@storyteller/ui-gravatar";
import type { UserInfo } from "@storyteller/api";
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ImageIcon, ImagesIcon, InfoIcon, LinkIcon, LoaderCircleIcon, PauseIcon, PencilIcon, PlayIcon, RotateCwIcon, Trash2Icon, UserIcon, VideoIcon, XIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import {
getContextImageThumbnail,
THUMBNAIL_SIZES,
} from "@storyteller/common";
ArrowDownToLineIcon,
CheckIcon,
CopyIcon,
ImageIcon,
ImagesIcon,
InfoIcon,
LinkIcon,
LoaderCircleIcon,
PauseIcon,
PencilIcon,
PlayIcon,
RotateCwIcon,
Trash2Icon,
UserIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { getContextImageThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import {
getCreatorIconPathForModelId,
getModelDisplayName,
@@ -29,16 +43,12 @@ import {
// ── InfoRow (private to this file; other consumers do not need it) ─────────
function InfoRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
<span className="text-sm text-white/60 font-medium">{label}</span>
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 last:border-0">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
{label}
</span>
<span className="text-sm text-white font-medium flex items-center gap-2">
{value}
</span>
@@ -84,7 +94,9 @@ export function LightboxDetails({
const promptCopy = useCopyFeedback();
const shareCopy = useCopyFeedback();
const [isDownloading, setIsDownloading] = useState(false);
const [playingAudioToken, setPlayingAudioToken] = useState<string | null>(null);
const [playingAudioToken, setPlayingAudioToken] = useState<string | null>(
null,
);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const stopAudio = useCallback(() => {
@@ -174,14 +186,14 @@ export function LightboxDetails({
!!createdAt;
return (
<div className="relative flex w-full sm:w-[320px] shrink-0 flex-col bg-ui-panel rounded-b-xl sm:rounded-b-none sm:rounded-r-xl min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
<div className="relative flex w-full sm:w-[320px] shrink-0 flex-col bg-ui-panel min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
{onClose && (
<button
onClick={onClose}
className="absolute top-3 right-3 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
className="absolute top-3 right-3 z-20 flex h-8 w-8 items-center justify-center bg-black/40 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
aria-label="Close"
>
<XIcon className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</button>
)}
@@ -189,20 +201,20 @@ export function LightboxDetails({
{promptData.loading ? (
<div className="space-y-6 animate-pulse">
<div className="space-y-2">
<div className="h-4 w-20 bg-white/10 rounded" />
<div className="h-20 w-full bg-white/10 rounded-lg" />
<div className="h-4 w-20 bg-white/10" />
<div className="h-20 w-full bg-white/10" />
</div>
<div className="space-y-2">
<div className="h-4 w-32 bg-white/10 rounded" />
<div className="h-10 w-full bg-white/10 rounded-lg" />
<div className="h-10 w-full bg-white/10 rounded-lg" />
<div className="h-4 w-32 bg-white/10" />
<div className="h-10 w-full bg-white/10" />
<div className="h-10 w-full bg-white/10" />
</div>
</div>
) : (
<>
{creator && (
<div
className={`sticky -top-4 z-10 -mx-4 -mt-4 mb-1 flex items-center gap-3 bg-ui-panel px-4 pt-4 pb-3 border-b border-white/5 ${onClose ? "pr-10" : ""}`}
className={`sticky -top-4 z-10 -mx-4 -mt-4 mb-1 flex items-center gap-3 bg-ui-panel px-4 pt-4 pb-3 border-b border-white/10 ${onClose ? "pr-10" : ""}`}
>
{creator.core_info ? (
<Gravatar
@@ -213,10 +225,10 @@ export function LightboxDetails({
backgroundIndex={
creator.core_info.default_avatar.color_index
}
className="rounded-xl border-white/10"
className="rounded-none border-white/15"
/>
) : (
<div className="h-9 w-9 shrink-0 flex items-center justify-center rounded-xl bg-white/10 text-white/50 border border-white/5">
<div className="h-9 w-9 shrink-0 flex items-center justify-center bg-white/10 text-white/50 border border-white/15">
<UserIcon />
</div>
)}
@@ -224,7 +236,7 @@ export function LightboxDetails({
<span className="text-white text-sm font-semibold leading-none">
{creator.display_name}
</span>
<span className="text-white/60 text-xs font-medium">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
Author
</span>
</div>
@@ -234,7 +246,7 @@ export function LightboxDetails({
{promptData.hasToken && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
<PencilIcon />
<span>Prompt</span>
</div>
@@ -252,7 +264,7 @@ export function LightboxDetails({
)}
</div>
<div className="text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
<div className="text-sm text-white/90 break-words px-4 py-3 bg-black/20 leading-relaxed border border-white/10">
<div
ref={promptRef}
className={!isPromptExpanded ? "line-clamp-4" : ""}
@@ -274,73 +286,78 @@ export function LightboxDetails({
</div>
)}
{promptData.contextImages && promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<ImageIcon />
<span>Reference Media</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map((contextImage, index) => {
const isAudio = contextImage.semantic === "audioref";
const isVideoRef = contextImage.semantic === "vid_ref";
const { thumbnail } = getContextImageThumbnail(
contextImage,
{ size: THUMBNAIL_SIZES.SMALL },
);
const isPlayingThis = playingAudioToken === contextImage.media_token;
if (isAudio) {
return (
<button
key={contextImage.media_token}
type="button"
onClick={() => handleAudioToggle(contextImage.media_token, contextImage.media_links.cdn_url)}
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors flex items-center justify-center bg-white/5"
>
<DynamicIcon
icon={isPlayingThis ? PauseIcon : PlayIcon}
className="text-white/70 text-lg"
/>
</button>
{promptData.contextImages &&
promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
<ImageIcon />
<span>Reference Media</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map((contextImage, index) => {
const isAudio = contextImage.semantic === "audioref";
const isVideoRef = contextImage.semantic === "vid_ref";
const { thumbnail } = getContextImageThumbnail(
contextImage,
{ size: THUMBNAIL_SIZES.SMALL },
);
}
const isPlayingThis =
playingAudioToken === contextImage.media_token;
return (
<a
key={contextImage.media_token}
href={`${SHARE_URL_BASE}${contextImage.media_token}`}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors block"
>
<img
src={thumbnail}
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
{isVideoRef && (
<div className="absolute inset-0 flex items-center justify-center">
<VideoIcon
className="text-white/80 text-sm drop-shadow-lg" />
</div>
)}
</a>
);
})}
if (isAudio) {
return (
<button
key={contextImage.media_token}
type="button"
onClick={() =>
handleAudioToggle(
contextImage.media_token,
contextImage.media_links.cdn_url,
)
}
className="relative aspect-square overflow-hidden border border-white/15 hover:border-white/40 transition-colors flex items-center justify-center bg-white/5"
>
<DynamicIcon
icon={isPlayingThis ? PauseIcon : PlayIcon}
className="text-white/70 text-lg"
/>
</button>
);
}
return (
<a
key={contextImage.media_token}
href={`${SHARE_URL_BASE}${contextImage.media_token}`}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square overflow-hidden border border-white/15 hover:border-white/40 transition-colors block"
>
<img
src={thumbnail}
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
{isVideoRef && (
<div className="absolute inset-0 flex items-center justify-center">
<VideoIcon className="text-white/80 text-sm drop-shadow-lg" />
</div>
)}
</a>
);
})}
</div>
</div>
</div>
)}
)}
{hasInfoSection && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
<InfoIcon />
<span>Information</span>
</div>
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
<div className="flex flex-col bg-black/20 border border-white/10 overflow-hidden">
{promptData.modelType && (
<InfoRow
label="Model"
@@ -421,7 +438,7 @@ export function LightboxDetails({
)}
</div>
<div className="p-4 space-y-2 border-t border-white/5">
<div className="p-4 space-y-2 border-t border-white/10">
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
@@ -446,7 +463,7 @@ export function LightboxDetails({
setIsDownloading(false);
}
}}
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
className={`w-full inline-flex items-center justify-center gap-2 px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors border border-ui-panel-border ${
mediaUrl
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
@@ -462,7 +479,7 @@ export function LightboxDetails({
{onMakeVideo && (
<Button
icon={VideoIcon}
className="w-full shadow-lg shadow-brand-primary/20"
className="w-full"
variant="primary"
onClick={onMakeVideo}
>
@@ -512,7 +529,7 @@ export function LightboxDetails({
{showDownloadAppCta && (
<Button
icon={ArrowDownToLineIcon}
className="w-full shadow-lg shadow-brand-primary/20"
className="w-full"
variant="primary"
onClick={() => {
window.location.href = "/download";
@@ -357,13 +357,13 @@ export function Lightbox({
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-xl h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/5 shadow-2xl"
className="h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/15"
backdropClassName="!bg-black/80"
showClose={false}
>
<div className="flex flex-col sm:flex-row h-full">
{/* Media preview panel */}
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden rounded-t-xl sm:rounded-l-xl sm:rounded-tr-none bg-black">
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden bg-black">
{!selectedImageUrl ? (
<div className="flex h-full w-full items-center justify-center">
<span className="text-base-fg/60">Media not available</span>
@@ -419,8 +419,8 @@ export function Lightbox({
</video>
) : isAudio ? (
<div className="flex h-full w-full items-center justify-center px-4 sm:px-8">
<div className="w-full max-w-xl rounded-2xl border border-white/10 bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02] px-4 py-10 sm:px-6">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15">
<div className="w-full max-w-xl border border-white/15 bg-ui-controls px-4 py-10 sm:px-6">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center border border-white/15 bg-white/10">
<MusicIcon
className="text-2xl text-white/70" />
@@ -485,9 +485,9 @@ export function Lightbox({
key={`${url}-thumb-${idx}`}
type="button"
onClick={() => onThumbClick(idx)}
className={`relative h-16 w-16 flex-[0_0_4rem] overflow-hidden rounded-md border-2 transition-all ${
className={`relative h-16 w-16 flex-[0_0_4rem] overflow-hidden border transition-all ${
idx === selectedIndex
? "border-primary-400 opacity-100"
? "border-white opacity-100"
: "border-transparent opacity-60 hover:border-white/40 hover:opacity-100"
}`}
>
@@ -518,7 +518,7 @@ export function Lightbox({
e.stopPropagation();
onNavigatePrev();
}}
className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
aria-label="Previous item"
>
<ChevronLeftIcon className="text-lg" />
@@ -530,7 +530,7 @@ export function Lightbox({
e.stopPropagation();
onNavigateNext();
}}
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
aria-label="Next item"
>
<ChevronRightIcon className="text-lg" />
@@ -1,16 +1,25 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Modal } from "@storyteller/ui-modal";
import { ArrowLeftIcon, EyeIcon, LoaderCircleIcon, PenIcon, PlusIcon, Trash2Icon, UploadIcon, UsersIcon, XIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import {
CharactersApi,
Character,
MediaUploadApi,
} from "@storyteller/api";
ArrowLeftIcon,
EyeIcon,
LoaderCircleIcon,
PenIcon,
PlusIcon,
Trash2Icon,
UploadIcon,
UsersIcon,
XIcon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { CharactersApi, Character, MediaUploadApi } from "@storyteller/api";
import { toast } from "../toast/toast";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@storyteller/ui-button";
import { useCharactersStore, DeckPreviewModal } from "@storyteller/ui-promptbox";
import {
useCharactersStore,
DeckPreviewModal,
} from "@storyteller/ui-promptbox";
import { useIsMobile } from "../ui/use-mobile";
import { SettingsDrawer } from "./mobile/SettingsDrawer";
@@ -324,7 +333,7 @@ const CharacterListView = ({
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
className="flex flex-col overflow-hidden border border-transparent bg-white/[0.05]"
>
<div className="aspect-square w-full overflow-hidden">
<div
@@ -336,7 +345,7 @@ const CharacterListView = ({
</div>
<div className="px-2 py-1.5 flex justify-center bg-white/[0.04]">
<div
className="h-3 w-2/3 rounded bg-white/[0.08]"
className="h-3 w-2/3 bg-white/[0.08]"
style={{
animation: `charPulse 1.8s ease-in-out ${i * 0.07 + 0.1}s infinite`,
}}
@@ -356,10 +365,10 @@ const CharacterListView = ({
{/* Create New card */}
<button
onClick={onCreateClick}
className="flex flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border-2 border-dashed border-white/10 bg-white/[0.05] text-white/60 transition-colors hover:border-white/25 hover:text-white/80"
className="flex flex-col items-center justify-center gap-2 overflow-hidden border-2 border-dashed border-white/15 bg-white/[0.05] text-white/60 transition-colors hover:border-white/30 hover:text-white/80"
>
<div className="flex aspect-square w-full flex-col items-center justify-center gap-2">
<PlusIcon className="text-lg" />
<PlusIcon className="text-lg" />
<span className="text-sm font-medium">Create New</span>
</div>
</button>
@@ -368,7 +377,7 @@ const CharacterListView = ({
{pendingCharacters.map((pending) => (
<div
key={`pending-${pending.name}`}
className="relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
className="relative flex flex-col overflow-hidden border border-transparent bg-white/[0.05]"
>
<div className="aspect-square w-full overflow-hidden bg-white/[0.05]">
{pending.previewUrl ? (
@@ -379,13 +388,11 @@ const CharacterListView = ({
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<UsersIcon className="text-2xl" />
<UsersIcon className="text-2xl" />
</div>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/40">
<LoaderCircleIcon
className="text-lg text-white/80 animate-spin" />
<LoaderCircleIcon className="text-lg text-white/80 animate-spin" />
<span className="text-xs font-medium text-white/80">
Creating...
</span>
@@ -405,7 +412,7 @@ const CharacterListView = ({
return (
<div
key={character.token}
className="group relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05] transition-colors hover:border-white/25 hover:bg-white/10"
className="group relative flex flex-col overflow-hidden border border-transparent bg-white/[0.05] transition-colors hover:border-white/30 hover:bg-white/10"
>
<button
onClick={() => onSelectCharacter?.(character)}
@@ -421,9 +428,7 @@ const CharacterListView = ({
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<UsersIcon
className="text-2xl" />
<UsersIcon className="text-2xl" />
</div>
)}
</div>
@@ -445,9 +450,9 @@ const CharacterListView = ({
setPreviewCharacter(character);
}}
title="View full size"
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<EyeIcon className="text-[10px]" />
<EyeIcon className="text-[10px]" />
</button>
)}
{isUserCreated && (
@@ -457,22 +462,18 @@ const CharacterListView = ({
e.stopPropagation();
onEditCharacter(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<PenIcon
className="text-[10px]" />
<PenIcon className="text-[10px]" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-red-500"
>
<Trash2Icon
className="text-[10px]" />
<Trash2Icon className="text-[10px]" />
</button>
</>
)}
@@ -486,28 +487,25 @@ const CharacterListView = ({
{/* Delete confirmation overlay */}
{confirmDelete && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-xl border border-white/10 bg-[#1e1e22] p-6 shadow-2xl">
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60">
<div className="mx-4 w-full max-w-sm border border-white/15 bg-ui-modal p-6">
<h3 className="mb-2 text-lg font-semibold text-white">
Delete character?
</h3>
<p className="mb-5 text-sm text-white/70">
This will permanently delete{" "}
<strong>{confirmDelete.name}</strong>. This action cannot be
undone.
This will permanently delete <strong>{confirmDelete.name}</strong>
. This action cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
className="border-none"
onClick={() => setConfirmDelete(null)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="primary"
className="bg-red-500 hover:bg-red-600"
variant="destructive"
onClick={() => handleDelete(confirmDelete)}
loading={isDeleting}
disabled={isDeleting}
@@ -608,7 +606,7 @@ const EditCharacterView = ({
{/* Avatar preview */}
{character.maybe_avatar?.cdn_url && (
<div className="flex h-56 max-h-56 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/[0.05]">
<div className="flex h-56 max-h-56 shrink-0 items-center justify-center overflow-hidden bg-white/[0.05]">
<img
src={character.maybe_avatar.cdn_url}
alt={character.name}
@@ -619,7 +617,10 @@ const EditCharacterView = ({
{/* Name input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-character-name" className="text-sm font-medium text-white/80">
<label
htmlFor="edit-character-name"
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
>
Name
</label>
<input
@@ -628,14 +629,18 @@ const EditCharacterView = ({
onChange={(e) => setName(e.target.value)}
placeholder="Character name"
autoComplete="off"
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
className="w-full border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
/>
</div>
{/* Description input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-character-description" className="text-sm font-medium text-white/80">
Description <span className="font-normal text-white/40">(optional)</span>
<label
htmlFor="edit-character-description"
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
>
Description{" "}
<span className="font-normal text-white/40">(optional)</span>
</label>
<textarea
id="edit-character-description"
@@ -644,14 +649,14 @@ const EditCharacterView = ({
placeholder="Description..."
rows={3}
autoComplete="off"
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
className="w-full resize-none border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" className="border-none" onClick={onBack}>
<Button variant="secondary" onClick={onBack}>
Cancel
</Button>
<Button
@@ -859,8 +864,8 @@ const NewCharacterView = ({
<div
ref={dropZoneRef}
className={twMerge(
"flex h-56 max-h-56 shrink-0 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/20 bg-white/[0.05] transition-colors overflow-hidden",
isDragging && "border-blue-400 bg-blue-500/10",
"flex h-56 max-h-56 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[3px] border-2 border-dashed border-white/15 bg-white/[0.05] transition-colors overflow-hidden",
isDragging && "border-white/60 bg-white/10",
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
@@ -880,9 +885,7 @@ const NewCharacterView = ({
/>
{!images[0]!.mediaToken && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<LoaderCircleIcon
className="text-white animate-spin" />
<LoaderCircleIcon className="text-white animate-spin" />
</div>
)}
<button
@@ -890,16 +893,14 @@ const NewCharacterView = ({
e.stopPropagation();
removeImage(0);
}}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
>
<XIcon className="text-sm" />
<XIcon className="text-sm" />
</button>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
<UploadIcon
className="mb-2 text-xl text-white/40" />
<UploadIcon className="mb-2 text-xl text-white/40" />
<p className="text-sm">Upload reference image</p>
<p className="mb-3 text-xs text-white/40">
Click or drag an image here
@@ -918,7 +919,10 @@ const NewCharacterView = ({
{/* Name input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="character-name" className="text-sm font-medium text-white/80">
<label
htmlFor="character-name"
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
>
Name
</label>
<input
@@ -927,14 +931,18 @@ const NewCharacterView = ({
onChange={(e) => setName(e.target.value)}
placeholder="Character name"
autoComplete="off"
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
className="w-full border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
/>
</div>
{/* Description input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="character-description" className="text-sm font-medium text-white/80">
Description <span className="font-normal text-white/40">(optional)</span>
<label
htmlFor="character-description"
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
>
Description{" "}
<span className="font-normal text-white/40">(optional)</span>
</label>
<textarea
id="character-description"
@@ -943,14 +951,14 @@ const NewCharacterView = ({
placeholder="Description..."
rows={3}
autoComplete="off"
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
className="w-full resize-none border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" className="border-none" onClick={onBack}>
<Button variant="secondary" onClick={onBack}>
Cancel
</Button>
<Button
@@ -6,7 +6,13 @@ import {
type CSSProperties,
type SyntheticEvent,
} from "react";
import { ImageIcon, ImagesIcon, LoaderCircleIcon, PlusIcon, XIcon } from "lucide-react";
import {
ImageIcon,
ImagesIcon,
LoaderCircleIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Modal } from "@storyteller/ui-modal";
@@ -234,10 +240,7 @@ export const ImagePromptRow = ({
)}
<div
ref={rootRef}
className={twMerge(
"glass flex flex-col sm:flex-row rounded-2xl sm:rounded-t-2xl sm:rounded-b-none",
className,
)}
className={twMerge("glass flex flex-col sm:flex-row", className)}
onMouseDown={stopIfInside}
onClick={stopIfInside}
onPointerDown={stopIfInside}
@@ -245,8 +248,8 @@ export const ImagePromptRow = ({
<div className="flex min-w-0 flex-1 gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<ImageIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
<ImageIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
{sectionLabel}
{showCount && (
<span className="font-semibold text-white/60">
@@ -322,10 +325,9 @@ export const ImagePromptRow = ({
<div className="hidden sm:block w-[1px] bg-white/10" />
<div className="flex grow flex-col gap-1 sm:p-2">
<div className="flex items-center gap-2 text-white/90">
<ImageIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
End Frame{" "}
<span className="text-xs text-white/60">(optional)</span>
<ImageIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
End Frame <span className="text-white/60">(optional)</span>
</span>
</div>
<span className="text-[13px] text-white/60">
@@ -336,7 +338,7 @@ export const ImagePromptRow = ({
<div className="flex items-center gap-2">
{endFrameImage ? (
<div
className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all cursor-pointer hover:border-white/80"
className="group relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all cursor-pointer hover:border-white/40"
onClick={() => setPreviewImage(endFrameImage)}
>
<img
@@ -349,17 +351,14 @@ export const ImagePromptRow = ({
e.stopPropagation();
setEndFrameImage?.(undefined);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
) : uploadingEndFrame ? (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<LoaderCircleIcon
spin
className="h-5 w-5 text-white" />
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
</div>
) : (
<AddButton
@@ -385,7 +384,7 @@ export const ImagePromptRow = ({
<img
src={previewImage.fullUrl || previewImage.url}
alt="Preview"
className="max-w-[90vw] max-h-[90vh] object-contain drop-shadow-2xl rounded-lg"
className="max-w-[90vw] max-h-[90vh] object-contain"
/>
</div>
)}
@@ -397,7 +396,7 @@ export const ImagePromptRow = ({
// ── Sub-components ───────────────────────────────────────────────────────
const ADD_BUTTON_CLASS =
"flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
"flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
// Upload / Pick-from-library affordance. On desktop it's a hover tooltip; on
// mobile that tooltip auto-opens from the emulated mouseenter fired on
@@ -414,9 +413,7 @@ export const AddButton = ({
const isMobile = useIsMobile();
const [drawerOpen, setDrawerOpen] = useState(false);
const plus = (
<PlusIcon className="text-2xl text-white/80" />
);
const plus = <PlusIcon className="text-2xl text-white/80" />;
if (!onPickFromLibrary) {
return (
@@ -460,7 +457,7 @@ export const AddButton = ({
<Button
variant="action"
icon={ImagesIcon}
className="w-full bg-white/15 hover:bg-white/20"
className="w-full"
onClick={() => {
setDrawerOpen(false);
onPickFromLibrary();
@@ -495,7 +492,7 @@ export const AddButton = ({
variant="action"
onClick={onPickFromLibrary}
icon={ImagesIcon}
className="w-full bg-white/15 hover:bg-white/20"
className="w-full"
>
Pick from library
</Button>
@@ -519,7 +516,7 @@ const ImageThumbnail = ({
onPreview?: (image: RefImage) => void;
}) => (
<div
className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all cursor-pointer hover:border-white/80"
className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all cursor-pointer hover:border-white/40"
onClick={() => onPreview?.(image)}
>
<img
@@ -532,9 +529,9 @@ const ImageThumbnail = ({
e.stopPropagation();
onRemove(image.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
);
@@ -572,11 +569,11 @@ const SortableImage = ({
{...(allowReorder ? { ...attributes, ...listeners } : {})}
onClick={() => onPreview?.(image)}
className={twMerge(
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-opacity",
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-opacity",
allowReorder
? "cursor-move hover:border-white/80"
: "cursor-pointer hover:border-white/80",
isDragging && "opacity-50 shadow-lg",
? "cursor-move hover:border-white/40"
: "cursor-pointer hover:border-white/40",
isDragging && "opacity-50",
)}
>
<img
@@ -591,9 +588,9 @@ const SortableImage = ({
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
);
@@ -603,7 +600,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
const previewUrl = useMemo(() => URL.createObjectURL(file), [file]);
useEffect(() => () => URL.revokeObjectURL(previewUrl), [previewUrl]);
return (
<div className="glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30">
<div className="glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15">
<div className="absolute inset-0">
<img
src={previewUrl}
@@ -612,10 +609,7 @@ const UploadingThumbnail = ({ file }: { file: File }) => {
/>
</div>
<div className="absolute inset-0 flex items-center justify-center bg-black/20">
<LoaderCircleIcon
spin
className="h-6 w-6 text-white" />
<LoaderCircleIcon spin className="h-6 w-6 text-white" />
</div>
</div>
);
@@ -1,5 +1,12 @@
import { useCallback, useRef, useState } from "react";
import { LoaderCircleIcon, MusicIcon, PlayIcon, SquareIcon, VideoIcon, XIcon } from "lucide-react";
import {
LoaderCircleIcon,
MusicIcon,
PlayIcon,
SquareIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
@@ -214,10 +221,7 @@ export const MediaReferenceRow = ({
onChange={handleAudioUpload}
/>
<div
className={twMerge(
"glass flex flex-col sm:flex-row rounded-2xl",
className,
)}
className={twMerge("glass flex flex-col sm:flex-row", className)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
@@ -227,8 +231,8 @@ export const MediaReferenceRow = ({
<div className="flex grow gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex items-center gap-2 text-white/90">
<VideoIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
<VideoIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Video Ref
<span className="font-semibold text-white/60">
({referenceVideos.length}/{maxVideoCount})
@@ -248,11 +252,8 @@ export const MediaReferenceRow = ({
/>
))}
{uploadingVideo && (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<LoaderCircleIcon
spin
className="h-5 w-5 text-white/60" />
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
<LoaderCircleIcon spin className="h-5 w-5 text-white/60" />
</div>
)}
{canAddVideo && (
@@ -275,8 +276,8 @@ export const MediaReferenceRow = ({
<div className="flex grow gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex items-center gap-2 text-white/90">
<MusicIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
<MusicIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Audio Ref
<span className="font-semibold text-white/60">
({referenceAudios.length}/{maxAudioCount})
@@ -297,11 +298,8 @@ export const MediaReferenceRow = ({
/>
))}
{uploadingAudio && (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<LoaderCircleIcon
spin
className="h-5 w-5 text-white/60" />
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
<LoaderCircleIcon spin className="h-5 w-5 text-white/60" />
</div>
)}
{canAddAudio && (
@@ -328,7 +326,7 @@ const VideoRefTile = ({
video: RefVideo;
onRemove: (id: string) => void;
}) => (
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all hover:border-white/40">
<video
src={video.url}
muted
@@ -343,9 +341,9 @@ const VideoRefTile = ({
e.stopPropagation();
onRemove(video.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
);
@@ -382,7 +380,7 @@ const AudioRefTile = ({
);
return (
<div className="group relative flex aspect-square w-10 sm:w-14 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div className="group relative flex aspect-square w-10 sm:w-14 cursor-pointer items-center justify-center overflow-hidden border border-white/15 transition-all hover:border-white/40">
<button
onClick={handleTogglePlay}
className="flex h-full w-full items-center justify-center"
@@ -403,9 +401,9 @@ const AudioRefTile = ({
e.stopPropagation();
onRemove(audio.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
);
@@ -8,7 +8,13 @@ import {
type ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import { ChevronDownIcon, ChevronUpIcon, MusicIcon, UsersIcon, VideoIcon } from "lucide-react";
import {
ChevronDownIcon,
ChevronUpIcon,
MusicIcon,
UsersIcon,
VideoIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { GenerateIconButton } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -231,8 +237,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
// mode renders no video/audio deck, so those kinds only land in
// reference mode where the user can see (and remove) them.
const isKeyframeMode = !!isVideo && !isReferenceMode;
const dropAcceptsImages =
!!supportsImagePrompts && maxImagePromptCount > 0;
const dropAcceptsImages = !!supportsImagePrompts && maxImagePromptCount > 0;
const dropAcceptsVideos =
!isKeyframeMode && !!videoRefsSupported && !!onReferenceVideosChange;
const dropAcceptsAudio =
@@ -344,8 +349,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
const deckAddActions: DeckAddAction[] = [];
if (
supportsImagePrompts &&
referenceImages.length + deck.uploadingImages.length <
maxImagePromptCount
referenceImages.length + deck.uploadingImages.length < maxImagePromptCount
) {
deckAddActions.push({
key: "upload-image",
@@ -769,8 +773,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
<div
className={twMerge(
"glass rounded-2xl p-3 sm:p-4 !transition-all duration-200",
isFocused && "ring-1 ring-primary",
"glass p-3 sm:p-4 !transition-all duration-200",
isFocused && "border-primary",
)}
{...drop.dropZoneProps}
>
@@ -849,8 +853,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
/>
{mentionOpen && filteredMentionItems.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-ui-panel-border bg-ui-controls shadow-lg backdrop-blur-xl">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-[3px] border border-ui-panel-border bg-ui-controls">
<div className="px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
Mentions
</div>
{filteredMentionItems.map((item, i) => (
@@ -868,7 +872,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
}}
onMouseEnter={() => setMentionIndex(i)}
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/20 bg-black/20">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden border border-white/15 bg-black/20">
{(item.type === "image" ||
item.type === "character") &&
item.preview ? (
@@ -885,13 +889,13 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
className="h-full w-full object-cover"
/>
) : item.type === "character" ? (
<UsersIcon
className="h-3.5 w-3.5 text-white/60" />
<UsersIcon className="h-3.5 w-3.5 text-white/60" />
) : (
<DynamicIcon
icon={
item.type === "video" ? VideoIcon : MusicIcon
item.type === "video"
? VideoIcon
: MusicIcon
}
className="h-3.5 w-3.5 text-white/60"
/>
@@ -245,15 +245,15 @@ export function PromptBoxDropOverlay({
return (
<div
className={twMerge(
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-[#161618]/85 backdrop-blur-sm",
rejected ? "border-red-400/80" : "border-primary",
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 border-2 border-dashed bg-ui-controls/85",
rejected ? "border-red-400/80" : "border-white/60",
)}
>
<div className="flex items-center gap-2">
{kinds.map((kind, i) => (
<div
key={kind.label}
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-base-fg/90"
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center bg-white/10 text-base-fg/90"
style={{ animationDelay: `${i * 50}ms` }}
>
<DynamicIcon icon={kind.icon} className="text-sm" />
@@ -7,7 +7,7 @@ import { Maximize2Icon } from "lucide-react";
// Webapp "focus mode" for the prompt editor. Mirrors the desktop lib's
// (@storyteller/ui-promptbox) fullscreen feature but lives here because the
// webapp has its own PromptBox. Reuses the shared @storyteller/ui-modal for a
// Trello-card-style centered panel + blurred backdrop. The overlay renders a
// Trello-card-style centered panel. The overlay renders a
// fresh editor bound to the SAME value + onChange, so text/mentions stay
// continuous across open/close.
@@ -32,7 +32,7 @@ export const PromptFullscreenButton = ({
type="button"
aria-label="Expand prompt to focus mode"
onClick={onClick}
className="flex h-6 w-6 items-center justify-center rounded-full bg-base-fg/5 text-base-fg/50 transition-colors hover:bg-base-fg/10 hover:text-base-fg/90 focus:outline-none"
className="flex h-6 w-6 items-center justify-center bg-base-fg/5 text-base-fg/50 transition-colors hover:bg-base-fg/10 hover:text-base-fg/90 focus:outline-none"
>
<Maximize2Icon
@@ -96,7 +96,6 @@ export const PromptFullscreenModal = ({
closeOnOutsideClick
closeOnEsc
className="w-full max-w-4xl"
backdropClassName="backdrop-blur-md"
>
{/* Explicit inline height (not a Tailwind arbitrary class) so the column
is reliably bounded across the modal's nested wrappers — that's what
@@ -19,8 +19,8 @@ export function DrawerOptionList({ items, onSelect }: DrawerOptionListProps) {
disabled={item.disabled}
onClick={() => !item.disabled && onSelect(item)}
className={twMerge(
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-colors",
item.selected ? "bg-primary/15" : "hover:bg-white/5",
"flex w-full items-center gap-3 px-3 py-3 text-left transition-colors",
item.selected ? "bg-white/10" : "hover:bg-white/5",
item.disabled && "cursor-not-allowed opacity-50",
)}
>
@@ -42,8 +42,8 @@ export function DrawerOptionList({ items, onSelect }: DrawerOptionListProps) {
{item.trailing}
{item.selected && (
<CheckIcon
className="h-3.5 w-3.5 shrink-0 text-primary" />
className="h-3.5 w-3.5 shrink-0 text-white" />
)}
</button>
))}
@@ -9,7 +9,7 @@ interface DrawerSectionProps {
export function DrawerSection({ label, children }: DrawerSectionProps) {
return (
<div className="py-1">
<span className="px-3 text-[11px] font-semibold uppercase tracking-wider text-base-fg/45">
<span className="px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/45">
{label}
</span>
{children}
@@ -33,14 +33,14 @@ export function MobileCountStepper({
};
return (
<div className="flex h-11 shrink-0 items-center rounded-xl border border-ui-panel-border bg-ui-controls px-1">
<div className="flex h-11 shrink-0 items-center rounded-[3px] border border-ui-panel-border bg-ui-controls px-1">
<button
type="button"
onClick={() => step(-1)}
disabled={index <= 0}
className="flex h-9 w-8 items-center justify-center rounded-lg text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
className="flex h-9 w-8 items-center justify-center text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
>
<MinusIcon className="h-3 w-3" />
<MinusIcon className="h-3 w-3" />
</button>
<span className="min-w-10 text-center text-sm font-semibold tabular-nums text-base-fg">
{value}/{maxValue}
@@ -49,9 +49,9 @@ export function MobileCountStepper({
type="button"
onClick={() => step(1)}
disabled={index === allowed.length - 1}
className="flex h-9 w-8 items-center justify-center rounded-lg text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
className="flex h-9 w-8 items-center justify-center text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
>
<PlusIcon className="h-3 w-3" />
<PlusIcon className="h-3 w-3" />
</button>
</div>
);
@@ -26,7 +26,7 @@ export function MobileFieldButton({
onClick={onClick}
disabled={disabled}
className={twMerge(
"flex w-full items-center gap-3 rounded-xl border border-ui-panel-border bg-ui-controls px-3.5 py-3 text-left transition-colors",
"flex w-full items-center gap-3 border border-ui-panel-border bg-ui-controls px-3.5 py-3 text-left transition-colors",
"hover:bg-ui-controls/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
@@ -77,7 +77,7 @@ export function MobilePromptForm({
{frames}
{mediaRefs}
<div className="rounded-xl border border-ui-panel-border bg-ui-controls p-3">
<div className="border border-ui-panel-border bg-ui-controls p-3">
<MentionTextarea
value={prompt}
onChange={onPromptChange}
@@ -100,7 +100,7 @@ export function MobilePromptForm({
<div className="flex items-center gap-2 border-t border-ui-panel-border bg-ui-panel px-3 py-3">
{countField}
<GenerateButton
className="h-11 rounded-xl flex-1 items-center justify-center border-none bg-primary text-base text-white disabled:cursor-not-allowed disabled:opacity-50"
className="h-11 flex-1 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleCreate}
disabled={!prompt.trim() || isSubmitting}
loading={isSubmitting}
@@ -64,13 +64,13 @@ export function SettingsDrawer({
aria-describedby={undefined}
modal={modal}
open={open}
className="ac-drawer-content max-h-[80vh] rounded-t-2xl bg-ui-panel pb-[max(1rem,env(safe-area-inset-bottom))]"
className="ac-drawer-content max-h-[80vh] bg-ui-panel pb-[max(1rem,env(safe-area-inset-bottom))]"
>
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white"
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white"
>
<XIcon className="text-sm" />
</button>
@@ -104,7 +104,7 @@ export function SettingsDrawer({
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-white/20" />
<div className="mx-auto mt-2 h-1 w-10 shrink-0 bg-white/20" />
<SheetHeader className={hideHeader ? "sr-only" : "select-none pb-2 pr-12"}>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
@@ -156,7 +156,7 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
maxLength={USERNAME_MAX}
autoComplete="username"
autoFocus
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
<Input
type="password"
@@ -164,7 +164,7 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Confirm password"
autoComplete="current-password"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
{error && <FormError message={error} />}
<FormActions
@@ -262,7 +262,7 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
maxLength={EMAIL_MAX}
autoComplete="email"
autoFocus
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
<Input
type="password"
@@ -270,7 +270,7 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Confirm password"
autoComplete="current-password"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
{error && <FormError message={error} />}
<FormActions
@@ -388,7 +388,7 @@ function PasswordForm({
placeholder="Current password"
autoComplete="current-password"
autoFocus
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
)}
<div className="grid grid-cols-2 gap-2">
@@ -400,7 +400,7 @@ function PasswordForm({
minLength={PASSWORD_MIN}
autoComplete="new-password"
autoFocus={passwordNotSet}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
<Input
type="password"
@@ -411,7 +411,7 @@ function PasswordForm({
}
minLength={PASSWORD_MIN}
autoComplete="new-password"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
</div>
{error && <FormError message={error} />}
@@ -450,7 +450,9 @@ function SectionShell({
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
{title}
</p>
<p className="text-xs opacity-70">
{isEditing ? editingDescription : collapsedDescription}
</p>
@@ -480,7 +482,7 @@ function SectionShell({
function CurrentValue({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/40">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/40">
{label}
</span>
<span className="text-sm font-medium text-white truncate">{value}</span>
@@ -514,11 +516,7 @@ function FormActions({
className="h-9 px-4"
disabled={submitting || disabled}
>
{submitting ? (
<LoaderCircleIcon className="animate-spin" />
) : (
"Save"
)}
{submitting ? <LoaderCircleIcon className="animate-spin" /> : "Save"}
</Button>
</div>
);
@@ -2,7 +2,13 @@ import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { Modal } from "@storyteller/ui-modal";
import { CheckIcon, CopyIcon, KeyIcon, LoaderCircleIcon, TrashIcon } from "lucide-react";
import {
CheckIcon,
CopyIcon,
KeyIcon,
LoaderCircleIcon,
TrashIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import {
UserApiKeysApi,
@@ -17,7 +23,7 @@ const DESCRIPTION_MAX = 512;
const PAGE_SIZE = 20;
const INPUT_CLASS =
"w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
"w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
interface ApiKeySectionProps {
user: UserInfo;
@@ -97,7 +103,9 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">API keys</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
API keys
</p>
<p className="text-xs opacity-70">
Create and manage keys to access the API programmatically.
</p>
@@ -131,7 +139,7 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
{loading ? (
<div className="py-6 text-center text-xs opacity-60">
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
</div>
) : keys.length === 0 ? (
<div className="py-6 text-center text-xs opacity-60">
@@ -169,7 +177,7 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
onClick={() => load(false)}
>
{loadingMore ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Load more"
)}
@@ -279,16 +287,16 @@ function NewKeyReveal({
};
return (
<div className="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/10 p-3">
<div className="flex flex-col gap-2 border border-white/30 bg-white/[0.06] p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<KeyIcon className="opacity-80" />
<KeyIcon className="opacity-80" />
<span>API key created</span>
</div>
<p className="text-xs opacity-80">
Copy your key now. For security, you won't be able to see it again.
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-white">
<code className="min-w-0 flex-1 truncate border border-white/15 bg-ui-controls px-3 py-2 font-mono text-xs text-white">
{created.api_key}
</code>
<Button
@@ -334,7 +342,7 @@ function ApiKeyRow({
onRequestDelete: () => void;
}) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/[0.03] p-3 transition-colors hover:border-white/[0.14]">
<div className="flex flex-col gap-2 border border-white/15 bg-white/[0.03] p-3 transition-colors hover:border-white/30">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="truncate text-sm font-medium">{item.name}</p>
@@ -346,10 +354,10 @@ function ApiKeyRow({
<div className="flex items-center gap-2 pt-1">
{/* Truncated prefix, display-only — the full secret is only shown on
create. The trailing ellipsis signals it's not the whole value. */}
<code className="min-w-0 truncate rounded bg-black/40 px-2 py-1 font-mono text-[11px] text-white/70">
<code className="min-w-0 truncate bg-ui-controls px-2 py-1 font-mono text-[11px] text-white/70">
{item.truncated_api_key}…
</code>
<span className="text-[10px] uppercase tracking-wider text-white/40">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-white/40">
{formatDate(item.created_at)}
</span>
</div>
@@ -497,13 +505,13 @@ function DeleteConfirmModal({
</Button>
<Button
type="button"
variant="primary"
className="h-9 px-4 bg-red-500 hover:bg-red-600"
variant="destructive"
className="h-9 px-4"
onClick={handleConfirm}
disabled={deleting}
>
{deleting ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Delete"
)}
@@ -543,7 +551,7 @@ function FormActions({
disabled={submitting || disabled}
>
{submitting ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
submitLabel
)}
@@ -44,7 +44,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<div className="flex h-full flex-col sm:grid sm:grid-cols-12 sm:gap-3">
<div className="relative shrink-0 border-b border-ui-panel-border p-4 sm:col-span-4 sm:border-b-0 sm:p-3 sm:pt-2 sm:after:absolute sm:after:right-0 sm:after:top-0 sm:after:h-full sm:after:w-px sm:after:bg-ui-panel-border">
<div className="hidden items-center gap-2.5 py-0.5 sm:flex">
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
Settings
</h2>
</div>
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
@@ -53,8 +55,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
key={t.id}
onClick={() => setTab(t.id)}
className={twMerge(
"h-9 shrink-0 rounded-lg px-3 text-left transition-colors sm:w-full sm:px-2",
tab === t.id ? "bg-[#63636B]/20" : "hover:bg-white/[0.04]",
"h-9 shrink-0 px-3 text-left transition-colors sm:w-full sm:px-2",
tab === t.id
? "bg-white/10 text-white"
: "text-white/70 hover:bg-white/[0.06] hover:text-white",
)}
>
<div className="flex items-center gap-2.5 whitespace-nowrap text-sm">
@@ -68,7 +72,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto sm:col-span-8 sm:h-full">
<div className="w-full border-b border-ui-panel-border px-4 py-2.5 sm:px-0">
<h2 className="text-[18px] font-semibold">{activeLabel}</h2>
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80">
{activeLabel}
</h2>
</div>
<div className="h-full p-4 text-sm sm:p-3 sm:ps-0">
{tab === "general" && <GeneralPanel />}
@@ -94,7 +100,9 @@ function GeneralPanel() {
<div className="space-y-4 text-base-fg">
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Enter to generate</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Enter to generate
</p>
<p className="text-xs opacity-70">
When on, pressing Enter submits the prompt and Shift+Enter adds a
new line. When off (default), both Enter and Shift+Enter add a new
@@ -109,7 +117,9 @@ function GeneralPanel() {
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Play videos with sound</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Play videos with sound
</p>
<p className="text-xs opacity-70">
When on (default), videos in the media viewer start unmuted so you
don't have to click unmute every time. When off, they start muted.
@@ -123,7 +133,9 @@ function GeneralPanel() {
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Group models by family</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Group models by family
</p>
<p className="text-xs opacity-70">
When on (default), the model picker groups models into submenus by
family, like Seedance or Veo. When off, every model shows in one
@@ -1,7 +1,24 @@
import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { BoxIcon, CircleHelpIcon, DownloadIcon, FilmIcon, GiftIcon, GlobeIcon, GraduationCapIcon, GroupIcon, HouseIcon, ImageIcon, ImagesIcon, MusicIcon, NewspaperIcon, PencilIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import {
BoxIcon,
CircleHelpIcon,
DownloadIcon,
FilmIcon,
GiftIcon,
GlobeIcon,
GraduationCapIcon,
GroupIcon,
HouseIcon,
ImageIcon,
ImagesIcon,
MusicIcon,
NewspaperIcon,
PencilIcon,
VideoIcon,
WandSparklesIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { DynamicIcon, DiscordIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
@@ -33,7 +50,9 @@ type NavItem = {
badge?: string;
};
const PRIMARY_ITEMS: NavItem[] = [{ label: "Home", href: "/", icon: HouseIcon }];
const PRIMARY_ITEMS: NavItem[] = [
{ label: "Home", href: "/", icon: HouseIcon },
];
// Generation entries — make something from a prompt.
const CREATE_ITEMS_STATIC: NavItem[] = [
@@ -150,13 +169,15 @@ function NavMenuItem({
interactive, kept subtle to stay within the "restrained chrome" lane. */}
<DynamicIcon
icon={item.icon}
className="transition-transform duration-200 ease-out group-hover/menu-item:scale-110"
className="transition-colors duration-150"
/>
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
{item.badge && (
<span
className={`ml-auto px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full ${
item.badge === "NEW" ? "bg-purple-600" : "bg-amber-600"
className={`ml-auto border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] leading-none group-data-[collapsible=icon]:hidden ${
item.badge === "NEW"
? "border-purple-400/40 text-purple-300"
: "border-amber-400/40 text-amber-300"
}`}
>
{item.badge}
@@ -173,7 +194,7 @@ function NavMenuItem({
{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"
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-0.5 bg-white"
transition={
reduceMotion
? { duration: 0 }
@@ -259,7 +280,7 @@ export function AppSidebar() {
className="flex items-center gap-2"
>
<img
src="/images/artcraft-logo.png"
src="/images/artcraft-logo-2.png"
alt="ArtCraft"
className="h-5 w-auto shrink-0"
/>
@@ -318,7 +339,7 @@ export function AppSidebar() {
onClick={() =>
window.open(DOWNLOAD_URL, "_blank", "noopener,noreferrer")
}
className="w-full justify-center h-9 text-sm font-semibold rounded-full"
className="w-full justify-center h-9"
>
Download ArtCraft
</Button>
@@ -81,7 +81,7 @@ export function LibraryFoldersNav({
type="button"
onClick={() => openNewFolderModal(null)}
aria-label="New folder"
className="mr-1 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
className="mr-1 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<PlusIcon className="text-xs" />
</button>
@@ -125,7 +125,7 @@ export function LibraryFoldersNav({
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse folders" : "Expand folders"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<ChevronRightIcon
@@ -63,7 +63,7 @@ export function LibraryTagsNav({
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse tags" : "Expand tags"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<ChevronRightIcon
@@ -103,7 +103,7 @@ export function LibraryTagsNav({
className="pl-5"
>
<TagIcon
className="text-violet-400" />
<span className="truncate">{tag.value}</span>
<span className="ml-auto text-[10px] text-sidebar-foreground/40 transition-opacity group-hover/menu-item:opacity-0">
@@ -123,7 +123,7 @@ export function LibraryTagsNav({
});
}}
aria-label={`Options for tag "${tag.value}"`}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<EllipsisIcon className="text-xs" />
</button>
@@ -32,10 +32,8 @@ export function MobileBottomNav() {
key={item.href}
to={item.href}
className={twMerge(
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors active:scale-95",
active
? "text-primary"
: "text-base-fg/55 hover:text-base-fg/80",
"flex flex-1 flex-col items-center justify-center gap-1 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] transition-colors",
active ? "text-white" : "text-base-fg/55 hover:text-base-fg/80",
)}
>
<DynamicIcon icon={item.icon} className="h-5 w-5" />
@@ -72,7 +72,7 @@ export function SignupCtaModal() {
<Modal
isOpen={isOpen}
onClose={close}
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/[5%] bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
className="w-full max-w-md overflow-hidden border border-white/15 bg-ui-modal p-0"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
@@ -81,16 +81,6 @@ export function SignupCtaModal() {
backdropClassName="bg-black/80"
>
<div className="relative overflow-hidden">
{/* Off-center ambient glow, feels designed, not generic */}
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
Start <span className="text-primary">crafting</span> in seconds.
@@ -105,10 +95,8 @@ export function SignupCtaModal() {
key={perk}
className="flex items-start gap-3 text-[14px] text-white/75"
>
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/15">
<CheckIcon
className="text-[9px] text-primary" />
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center bg-white/10">
<CheckIcon className="text-[9px] text-white" />
</span>
<span>{perk}</span>
</li>
@@ -117,16 +105,13 @@ export function SignupCtaModal() {
<div className="mt-8 flex flex-col gap-3">
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
<div className="border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
{error}
</div>
)}
<Link to={`/signup?from=${from}`} onClick={close} className="block">
<Button
variant="primary"
className="w-full h-10 text-sm font-semibold rounded-full"
>
<Button variant="primary" className="w-full h-10">
Create account with email
</Button>
</Link>
@@ -135,7 +120,7 @@ export function SignupCtaModal() {
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#161618] px-4 text-xs uppercase tracking-widest text-white/40">
<span className="relative bg-ui-modal px-4 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
or
</span>
</div>
@@ -88,10 +88,10 @@ export const ToastContainer = () => {
{toasts.map((toast) => (
<div
key={toast.id}
className="glass flex max-w-full items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-white animate-toast-in sm:max-w-md"
className="glass flex max-w-full items-center gap-3 rounded-[3px] px-4 py-2.5 text-sm text-white animate-toast-in sm:max-w-md"
>
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full ${
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center ${
toast.type === "success" ? "bg-green-500" : "bg-red-500"
}`}
>
@@ -107,7 +107,7 @@ export const ToastContainer = () => {
onClick={() => dismiss(toast.id)}
className="ml-1 text-white/40 transition-colors hover:text-white/80"
>
<XIcon className="h-3 w-3" />
<XIcon className="h-3 w-3" />
</button>
</div>
))}
@@ -121,7 +121,7 @@ export function ActiveSceneTitle() {
onClick={startEdit}
title="Rename scene"
aria-label="Rename scene"
className="flex min-w-0 max-w-[280px] items-center rounded-lg border border-transparent px-3 py-1.5 text-sm font-semibold text-white transition-all hover:cursor-text hover:bg-white/[0.08]"
className="flex min-w-0 max-w-[280px] items-center border border-transparent px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:cursor-text hover:bg-white/10"
>
<span className="truncate">{title}</span>
<PencilIcon
@@ -129,7 +129,7 @@ export function ActiveSceneTitle() {
className="ml-2 shrink-0 text-sm opacity-50" />
</button>
) : (
<div className="max-w-[280px] truncate rounded-lg border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
<div className="max-w-[280px] truncate border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
{title}
</div>
)}
@@ -157,8 +157,8 @@ export function ActiveSceneTitle() {
disabled={isSubmitting}
maxLength={120}
className={twMerge(
"h-[34px] w-fit max-w-full rounded-lg border border-white/[0.12] bg-white/[0.04] px-3 text-sm font-semibold text-white outline-none focus:outline focus:outline-2 focus:outline-brand-primary",
isSubmitting && "outline outline-2 outline-brand-secondary",
"h-[34px] w-fit max-w-full border border-white/15 bg-ui-controls px-3 text-sm font-semibold text-white outline-none focus:outline focus:outline-1 focus:outline-white/60",
isSubmitting && "outline outline-1 outline-white/30",
!isValid && "border-danger focus:outline-danger focus:outline-2",
)}
/>
@@ -14,7 +14,14 @@ import {
Transition,
} from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { CoinsIcon, GemIcon, GiftIcon, ImagesIcon, LifeBuoyIcon, SettingsIcon } from "lucide-react";
import {
CoinsIcon,
GemIcon,
GiftIcon,
ImagesIcon,
LifeBuoyIcon,
SettingsIcon,
} from "lucide-react";
import { DiscordIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { PopoverMenu } from "@storyteller/ui-popover";
@@ -99,9 +106,9 @@ export function TopBarActions({ className }: { className?: string }) {
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
>
<GemIcon className="text-[11px]" />
<GemIcon className="text-[11px]" />
Pricing
</Link>
@@ -118,7 +125,7 @@ export function TopBarActions({ className }: { className?: string }) {
variant="primary"
icon={GemIcon}
onClick={() => navigate("/pricing")}
className="h-8 px-3 text-sm font-semibold rounded-lg"
className="h-8 px-3"
>
Upgrade
</Button>
@@ -128,14 +135,14 @@ export function TopBarActions({ className }: { className?: string }) {
<Link
to="/library"
className="hidden sm:flex h-8 items-center gap-1.5 px-3 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-sm font-medium text-white/80 transition-colors"
className="hidden sm:flex h-8 items-center gap-1.5 px-3 rounded-[3px] border border-white/15 hover:bg-white/10 hover:border-white/30 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80 transition-colors"
>
<ImagesIcon className="text-[11px]" />
<ImagesIcon className="text-[11px]" />
My Library
</Link>
<Menu as="div" className="relative ml-1">
<MenuButton className="flex h-8 w-8 rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary/40 ring-offset-2 ring-offset-[#121212]">
<MenuButton className="flex h-8 w-8 overflow-hidden border border-white/15 focus:outline-none focus:ring-2 focus:ring-white/60 ring-offset-2 ring-offset-[#0b0b0c]">
<span className="sr-only">Open user menu</span>
<img
className="h-full w-full object-cover"
@@ -154,9 +161,9 @@ export function TopBarActions({ className }: { className?: string }) {
>
<MenuItems
modal={false}
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-xl bg-[#1a1a1a] border border-white/[0.08] shadow-xl focus:outline-none overflow-hidden"
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-[3px] bg-[#101014] border border-white/15 focus:outline-none overflow-hidden"
>
<div className="px-4 py-3 border-b border-white/[0.06]">
<div className="px-4 py-3 border-b border-white/15">
<p className="text-sm font-medium text-white truncate">
{user.display_name || user.username}
</p>
@@ -166,13 +173,11 @@ export function TopBarActions({ className }: { className?: string }) {
<button
onClick={() => setSettingsOpen(true)}
className={twMerge(
active ? "bg-white/[0.04]" : "",
active ? "bg-white/10" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<SettingsIcon
className="text-[11px] text-white/50" />
<SettingsIcon className="text-[11px] text-white/50" />
Settings
</button>
)}
@@ -182,13 +187,11 @@ export function TopBarActions({ className }: { className?: string }) {
<button
onClick={() => navigate("/support")}
className={twMerge(
active ? "bg-white/[0.04]" : "",
active ? "bg-white/10" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<LifeBuoyIcon
className="text-[11px] text-white/50" />
<LifeBuoyIcon className="text-[11px] text-white/50" />
Support
</button>
)}
@@ -201,13 +204,11 @@ export function TopBarActions({ className }: { className?: string }) {
<button
onClick={() => navigate("/referrals")}
className={twMerge(
active ? "bg-white/[0.04]" : "",
active ? "bg-white/10" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<GiftIcon
className="text-[11px] text-white/50" />
<GiftIcon className="text-[11px] text-white/50" />
Referrals
</button>
)}
@@ -220,13 +221,11 @@ export function TopBarActions({ className }: { className?: string }) {
target="_blank"
rel="noopener noreferrer"
className={twMerge(
active ? "bg-white/[0.04]" : "",
active ? "bg-white/10" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<DiscordIcon
className="text-[11px] text-white/50" />
<DiscordIcon className="text-[11px] text-white/50" />
Join Discord
</a>
)}
@@ -252,20 +251,20 @@ export function TopBarActions({ className }: { className?: string }) {
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
>
<GemIcon className="text-[11px]" />
<GemIcon className="text-[11px]" />
Pricing
</Link>
<Link
to="/login"
className="h-8 flex items-center px-3 rounded-lg text-sm font-medium text-white/80 hover:text-white hover:bg-white/[0.04] transition-all"
className="h-8 flex items-center px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80 hover:text-white hover:bg-white/10 transition-colors"
>
Login
</Link>
<Link
to="/signup"
className="h-8 flex items-center gap-1.5 px-3.5 rounded-full text-sm font-semibold text-black bg-white hover:bg-white/90 transition-all shadow-sm"
className="h-8 flex items-center gap-1.5 px-3.5 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black bg-white hover:bg-white/80 transition-colors"
>
Sign up
</Link>
@@ -297,16 +296,14 @@ function CreditsChip({
<PopoverMenu
position="bottom"
align="end"
triggerIcon={
<CoinsIcon className="text-primary text-[11px]" />
}
triggerIcon={<CoinsIcon className="text-primary text-[11px]" />}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
buttonClassName="h-8 px-3 ps-2.5 bg-transparent hover:bg-white/10 border border-white/15 hover:border-white/30 shadow-none text-white/80 rounded-[3px] gap-1.5"
panelClassName="mt-2 bg-[#101014] border border-white/15 text-white rounded-[3px]"
>
{(close) => (
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
@@ -325,7 +322,7 @@ function CreditsChip({
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
<CoinsIcon className="text-xl text-primary" />
<CoinsIcon className="text-xl text-primary" />
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
@@ -77,7 +77,7 @@ export function Breadcrumbs() {
if (crumbs.length === 0) return null;
return (
<nav className="hidden md:flex items-center gap-1.5 text-sm text-white/45 min-w-0">
<nav className="hidden md:flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/45 min-w-0">
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
@@ -1,6 +1,17 @@
import { Tooltip } from "@storyteller/ui-tooltip";
import { PopoverMenu } from "@storyteller/ui-popover";
import { BombIcon, BrushIcon, CheckIcon, CircleAlertIcon, CopyIcon, ListChecksIcon, LoaderCircleIcon, RotateCwIcon, Trash2Icon, XIcon } from "lucide-react";
import {
BombIcon,
BrushIcon,
CheckIcon,
CircleAlertIcon,
CopyIcon,
ListChecksIcon,
LoaderCircleIcon,
RotateCwIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Modal } from "@storyteller/ui-modal";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -180,7 +191,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
delay={300}
>
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
aria-label="Copy prompt"
onClick={(e) => {
e.stopPropagation();
@@ -220,7 +231,7 @@ const RecreateTaskButton = ({
delay={300}
>
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls disabled:opacity-60"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls disabled:opacity-60"
aria-label="Recreate"
disabled={isRecreating}
onClick={handleRecreate}
@@ -255,13 +266,13 @@ const InProgressCard = ({
: null;
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-ui-controls">
<LoaderCircleIcon
className="animate-spin text-base-fg/60"
size="lg" />
size="lg"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
@@ -285,9 +296,9 @@ const InProgressCard = ({
</div>
)}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 rounded bg-ui-controls">
<div className="h-1.5 min-w-0 flex-1 bg-ui-controls">
<div
className="h-1.5 rounded bg-primary-400"
className="h-1.5 bg-white"
style={{
width: `${Math.max(0, Math.min(100, task.progress))}%`,
}}
@@ -307,7 +318,7 @@ const InProgressCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -334,12 +345,12 @@ const CompletedCard = ({
}) => {
return (
<div
className="flex cursor-pointer items-center gap-2.5 rounded-md p-2 transition-colors hover:bg-ui-controls/40"
className="flex cursor-pointer items-center gap-2.5 p-2 transition-colors hover:bg-ui-controls/40"
onClick={onClick}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded bg-ui-controls">
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden bg-ui-controls">
{task.thumbnailUrl ? (
<img
src={task.thumbnailUrl}
@@ -379,7 +390,7 @@ const CompletedCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -403,13 +414,10 @@ const FailedCard = ({
}) => {
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
<CircleAlertIcon
className="text-red-400"
size="lg" />
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-red-500/10">
<CircleAlertIcon className="text-red-400" size="lg" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
@@ -423,7 +431,7 @@ const FailedCard = ({
</div>
)}
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
<span className="shrink-0 rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
<span className="shrink-0 bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
{statusLabel}
</span>
{task.failureReason && (
@@ -471,7 +479,7 @@ const FailedCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -1042,7 +1050,7 @@ export const TaskQueue = () => {
<div>
{inProgress.length > 0 && (
<div className="mb-4">
<div className="mb-1 px-1 text-xs uppercase tracking-wide text-base-fg/50">
<div className="mb-1 px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
In Progress
</div>
{inProgress.map((t) => (
@@ -1057,14 +1065,14 @@ export const TaskQueue = () => {
{failed.length > 0 && (
<div className="mb-4">
<div className="mb-1 flex items-center justify-between px-1">
<div className="text-xs font-semibold uppercase tracking-wide text-red-400/70">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-red-400/70">
Failed
</div>
<button
className="text-xs tracking-wide text-red-400/70 transition-colors hover:text-red-300"
onClick={() => handleClearFailed()}
>
<XIcon className="mr-1" />
<XIcon className="mr-1" />
Clear failed
</button>
</div>
@@ -1080,14 +1088,14 @@ export const TaskQueue = () => {
{completed.length > 0 && (
<div>
<div className="mb-1 flex items-center justify-between px-1">
<div className="text-xs font-semibold uppercase tracking-wide text-base-fg/50">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
Completed
</div>
<button
className="text-xs tracking-wide text-base-fg/50 transition-colors hover:text-base-fg/100"
onClick={() => handleClearCompleted()}
>
<XIcon className="mr-1" />
<XIcon className="mr-1" />
Clear completed
</button>
</div>
@@ -1122,23 +1130,21 @@ export const TaskQueue = () => {
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
<div className="relative task-queue-trigger">
{badgeCount > 0 && (
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center rounded-full bg-primary-400 px-1 text-[10px] font-semibold text-white ring-2 ring-[#121212]">
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center bg-white px-1 font-mono text-[10px] font-semibold text-black ring-2 ring-[#0b0b0c]">
{badgeCount}
</div>
)}
<PopoverMenu
mode="default"
buttonClassName="h-8 w-8 !p-0 relative bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/80 rounded-lg shadow-none"
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#1a1a1a] border border-white/[0.08] mt-2 rounded-xl"
buttonClassName="h-8 w-8 !p-0 relative bg-transparent hover:bg-white/10 border border-white/15 hover:border-white/30 text-white/80 rounded-[3px] shadow-none"
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#101014] border border-white/15 mt-2 rounded-[3px]"
position="bottom"
align="end"
triggerIcon={
inProgressCount > 0 ? (
<LoaderCircleIcon
className="animate-spin text-[11px]" />
<LoaderCircleIcon className="animate-spin text-[11px]" />
) : (
<ListChecksIcon className="text-[11px]" />
<ListChecksIcon className="text-[11px]" />
)
}
onOpenChange={handleOpenChange}
@@ -1175,36 +1181,36 @@ export const TaskQueue = () => {
showClose={false}
>
<div className="flex h-full flex-col">
<div className="rounded-t-xl border-ui-panel-border bg-ui-panel">
<div className="border-b border-ui-panel-border bg-ui-panel">
<div className="flex items-center justify-between p-3">
<h2 className="text-lg font-semibold">Task Queue</h2>
<div className="flex items-center gap-2">
<Button
className="rounded-full flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
className="flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
onClick={() => handleClearCompleted()}
>
<BrushIcon className="mr-1.5" />
<BrushIcon className="mr-1.5" />
Clear completed
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
className="flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
onClick={() => handleClearStale()}
>
<Trash2Icon className="mr-1.5" />
<Trash2Icon className="mr-1.5" />
Clear stale
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
onClick={() => handleClearFailed()}
>
<Trash2Icon className="mr-1.5" />
<Trash2Icon className="mr-1.5" />
Clear failed
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
onClick={() => handleRemoveAll()}
>
<BombIcon className="mr-1.5" />
<BombIcon className="mr-1.5" />
Remove all
</Button>
<div className="mr-2 h-4 w-[1px] bg-base-fg/10" />
@@ -24,7 +24,7 @@ export function TopBar() {
const showSelectToggle = showViewToggle;
return (
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/15 bg-ui-background px-3 pb-4 pt-3 sm:pt-6">
{/* Left: sidebar trigger (mobile only) + logo (when sidebar closed) + breadcrumbs */}
<div className="flex items-center gap-2 min-w-0 shrink-0">
<SidebarTrigger className="md:hidden" />
@@ -35,8 +35,8 @@ export function TopBar() {
<img
src={
isMobile
? "/images/artcraft-icon.png"
: "/images/artcraft-logo.png"
? "/images/artcraft-icon-2.png"
: "/images/artcraft-logo-2.png"
}
alt="ArtCraft"
className={isMobile ? "h-5 w-auto" : "h-5 w-auto"}
@@ -53,16 +53,16 @@ const SheetContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex flex-col bg-[#1F1F1F] shadow-lg transition ease-in-out",
"fixed z-50 flex flex-col bg-[#101014] transition ease-in-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
side === "left" &&
"inset-y-0 left-0 h-full border-r border-white/[0.08] data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
"inset-y-0 left-0 h-full border-r border-white/15 data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
side === "right" &&
"inset-y-0 right-0 h-full border-l border-white/[0.08] data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
"inset-y-0 right-0 h-full border-l border-white/15 data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
side === "top" &&
"inset-x-0 top-0 border-b border-white/[0.08] data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
"inset-x-0 top-0 border-b border-white/15 data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
side === "bottom" &&
"inset-x-0 bottom-0 border-t border-white/[0.08] data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
"inset-x-0 bottom-0 border-t border-white/15 data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
className,
)}
{...props}
@@ -234,7 +234,7 @@ const Sidebar = React.forwardRef<
/>
<div
className={cn(
"fixed inset-y-0 z-30 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-30 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex bg-background",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -247,7 +247,7 @@ const Sidebar = React.forwardRef<
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-[20px] group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:backdrop-blur-[7.5px] group-data-[variant=floating]:overflow-hidden group-data-[variant=floating]:shadow-[-20px_68px_20px_0px_rgba(0,0,0,0),-13px_43px_18px_0px_rgba(0,0,0,0.01),-7px_24px_15px_0px_rgba(0,0,0,0.04),-3px_11px_11px_0px_rgba(0,0,0,0.07),-1px_3px_6px_0px_rgba(0,0,0,0.08)]"
className="flex h-full w-full flex-col bg-sidebar rounded-[3px] group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:overflow-hidden"
>
{children}
</div>
@@ -274,7 +274,7 @@ const SidebarTrigger = React.forwardRef<
toggleSidebar();
}}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors",
"inline-flex h-8 w-8 items-center justify-center text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors",
className,
)}
{...props}
@@ -323,7 +323,7 @@ const SidebarInset = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0",
className,
)}
{...props}
@@ -397,7 +397,7 @@ const SidebarGroupLabel = React.forwardRef<
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/50 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"flex h-8 shrink-0 items-center px-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/50 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
@@ -447,7 +447,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-200 ease-out hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[3px] px-2 py-1.5 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-200 ease-out hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -15,7 +15,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[60] overflow-hidden rounded-md bg-[#1f1f1f] border border-white/[0.08] px-2.5 py-1.5 text-xs text-white shadow-md",
"z-[60] overflow-hidden rounded-[3px] bg-[#101014] border border-white/15 px-2.5 py-1.5 text-xs text-white",
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
@@ -7,24 +7,19 @@ import Seo from "../../components/seo";
const CheckoutCancel = () => {
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
<div className="relative min-h-screen overflow-hidden bg-ui-background text-white">
<Seo
title="Checkout Cancelled - ArtCraft"
description="Your checkout was cancelled. No payment was made."
/>
{/* Background gradient - subtle/neutral */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-gray-500/20 via-gray-600/10 to-gray-700/5 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
{/* Cancel Card */}
<div className="max-w-lg w-full">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
<div className="bg-[#101014] border border-white/15 p-8 md:p-12 text-center">
{/* Cancel Icon */}
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-white/10 flex items-center justify-center">
<div className="w-20 h-20 mx-auto border border-white/15 bg-white/10 flex items-center justify-center">
<CircleXIcon
className="text-5xl text-white/50" />
@@ -41,7 +36,7 @@ const CheckoutCancel = () => {
</p>
{/* Info Box */}
<div className="bg-[#252529] rounded-2xl p-5 mb-8 text-left">
<div className="bg-ui-controls border border-white/15 p-5 mb-8 text-left">
<p className="text-white/70 text-sm">
<span className="text-white font-medium">
Changed your mind?
@@ -56,14 +51,14 @@ const CheckoutCancel = () => {
<Button
as="link"
href="/pricing"
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
className="px-8 py-3 justify-center"
>
View Plans Again
</Button>
</div>
{/* Discord CTA */}
<div className="mt-8 pt-6 border-t border-white/10 flex flex-col gap-2 items-center">
<div className="mt-8 pt-6 border-t border-white/15 flex flex-col gap-2 items-center">
<div className="flex items-center justify-center gap-2 mb-2">
<span className="text-white/70 text-sm">
Need help deciding?
@@ -73,7 +68,7 @@ const CheckoutCancel = () => {
as="link"
href={SOCIAL_LINKS.DISCORD}
target="_blank"
className="rounded-full bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
className="bg-white text-black hover:bg-white/80 px-4 py-2 justify-center border-transparent"
>
<DiscordIcon />
Join Discord
@@ -1,4 +1,14 @@
import { ArrowRightIcon, BoxIcon, CircleCheckIcon, DownloadIcon, ImageIcon, MonitorIcon, RocketIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import {
ArrowRightIcon,
BoxIcon,
CircleCheckIcon,
DownloadIcon,
ImageIcon,
MonitorIcon,
RocketIcon,
VideoIcon,
WandSparklesIcon,
} from "lucide-react";
import { DynamicIcon, DiscordIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { Link, useSearchParams } from "react-router-dom";
@@ -59,35 +69,27 @@ const CheckoutSuccess = () => {
if (isCheckingOnboarding) {
return (
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
<div className="relative min-h-screen bg-ui-background text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
</div>
);
}
return (
<div className="relative min-h-full bg-[#101014] text-white">
<div className="relative min-h-full bg-ui-background text-white">
<Seo
title="Payment Successful - ArtCraft"
description="Your ArtCraft subscription is now active. Download and start creating!"
/>
{/* Background gradient — clipped to its own wrapper so the outer
page can grow naturally and let SidebarInset handle scrolling. */}
<div className="absolute inset-0 overflow-hidden flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
{/* Success Card */}
<div className="max-w-4xl w-full">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
<div className="bg-[#101014] border border-white/15 p-8 md:p-12 text-center">
{/* Success Icon */}
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
<CircleCheckIcon
className="text-5xl text-green-400" />
<div className="w-20 h-20 mx-auto border border-white/15 bg-green-500/10 flex items-center justify-center">
<CircleCheckIcon className="text-5xl text-green-400" />
</div>
</div>
@@ -102,12 +104,10 @@ const CheckoutSuccess = () => {
</p>
{/* Start creating in the browser */}
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
<div className="bg-ui-controls border border-white/15 p-6 mb-8 text-left">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<WandSparklesIcon
className="text-primary text-sm" />
<div className="w-8 h-8 bg-white/10 flex items-center justify-center">
<WandSparklesIcon className="text-white/70 text-sm" />
</div>
<h2 className="text-lg font-medium text-white">
Start creating right here
@@ -130,30 +130,26 @@ const CheckoutSuccess = () => {
<Link
key={item.to}
to={item.to}
className="group flex items-center gap-3 bg-[#1A1A1E] hover:bg-[#222227] border border-white/[0.06] hover:border-white/[0.12] rounded-xl px-4 py-3 transition-all"
className="group flex items-center gap-3 rounded-[3px] bg-ui-background hover:bg-white/10 border border-white/15 hover:border-white/30 px-4 py-3 transition-all"
>
<DynamicIcon
icon={item.icon}
className="text-primary text-base shrink-0"
className="text-white/70 text-base shrink-0"
/>
<span className="text-white/90 font-medium flex-1">
{item.label}
</span>
<ArrowRightIcon
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
<ArrowRightIcon className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
</Link>
))}
</div>
</div>
{/* Next Steps */}
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
<div className="bg-ui-controls border border-white/15 p-6 mb-8 text-left">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<RocketIcon
className="text-primary text-sm" />
<div className="w-8 h-8 bg-white/10 flex items-center justify-center">
<RocketIcon className="text-white/70 text-sm" />
</div>
<h2 className="text-lg font-medium text-white">Next Steps</h2>
</div>
@@ -166,7 +162,7 @@ const CheckoutSuccess = () => {
"Start creating with all premium features!",
].map((step, idx) => (
<div key={idx} className="flex items-start gap-3">
<div className="w-6 h-6 shrink-0 rounded-full bg-primary/50 flex items-center justify-center text-xs font-bold text-white mt-0.5">
<div className="w-6 h-6 shrink-0 bg-white/10 border border-white/15 flex items-center justify-center font-mono text-[11px] font-semibold text-white mt-0.5">
{idx + 1}
</div>
<span className="text-white/80">{step}</span>
@@ -181,16 +177,16 @@ const CheckoutSuccess = () => {
<Button
as="link"
href={downloadUrl}
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
className="px-8 py-3 justify-center"
>
<DownloadIcon className="mr-2" />
<DownloadIcon className="mr-2" />
Download ArtCraft
</Button>
</div>
) : (
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
<div className="bg-[#431407] border border-orange-900/50 p-6 text-orange-200 text-sm leading-relaxed">
<div className="flex items-center justify-center mb-3 text-orange-400">
<MonitorIcon className="text-2xl" />
<MonitorIcon className="text-2xl" />
</div>
ArtCraft is a desktop application. <br />
Please head to your computer to download and start creating.
@@ -204,21 +200,21 @@ const CheckoutSuccess = () => {
href={DOWNLOAD_LINKS.WINDOWS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
<span className="w-1.5 h-1.5 bg-current"></span>
Windows
</a>
<a
href={DOWNLOAD_LINKS.MACOS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
<span className="w-1.5 h-1.5 bg-current"></span>
Mac
</a>
</div>
)}
{/* Discord CTA */}
<div className="mt-8 pt-6 border-t border-white/10 flex flex-col gap-2 items-center">
<div className="mt-8 pt-6 border-t border-white/15 flex flex-col gap-2 items-center">
<div className="flex items-center justify-center gap-2 mb-2">
<span className="text-white/70 text-sm">
Join our community
@@ -228,7 +224,7 @@ const CheckoutSuccess = () => {
as="link"
href={SOCIAL_LINKS.DISCORD}
target="_blank"
className="rounded-full bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
className="bg-white text-black hover:bg-white/80 px-4 py-2 justify-center border-transparent"
>
<DiscordIcon />
Join Discord
@@ -77,6 +77,16 @@ const AUDIO_REF_MAX_DURATION_SECONDS = 600;
const supports = (flag: boolean | null | undefined): boolean => flag === true;
// The lib ToggleButton still carries the old glassy look (2px transparent
// border, grey translucent blur). These overrides align the audio toggles
// with their sibling toolbar controls (the PopoverMenu triggers): flat
// ui-controls surface, 1px hairline border, 4px corners. Active keeps the
// brand tint as the on-state affordance.
const toolbarToggleClassName = (isActive: boolean): string =>
isActive
? "rounded-[3px] border border-ui-controls-border backdrop-blur-none hover:border-ui-controls-border"
: "rounded-[3px] border border-ui-controls-border bg-ui-controls backdrop-blur-none hover:bg-ui-controls/80";
// Store API model data alongside popover items via a lookup map
let _modelLookup = new Map<string, OmniGenAudioModelDetails>();
@@ -490,6 +500,7 @@ export default function CreateAudio() {
icon={MicOffIcon}
activeIcon={MicOffIcon}
label="Instrumental"
className={toolbarToggleClassName(ui.isInstrumental)}
onClick={() => setUi({ isInstrumental: !ui.isInstrumental })}
/>
</Tooltip>
@@ -506,6 +517,7 @@ export default function CreateAudio() {
icon={MicIcon}
activeIcon={MicIcon}
label="Keep lyrics"
className={toolbarToggleClassName(ui.keepLyrics)}
onClick={() => setUi({ keepLyrics: !ui.keepLyrics })}
/>
</Tooltip>
@@ -522,6 +534,7 @@ export default function CreateAudio() {
icon={RepeatIcon}
activeIcon={RepeatIcon}
label="Loop"
className={toolbarToggleClassName(ui.isLoopable)}
onClick={() => setUi({ isLoopable: !ui.isLoopable })}
/>
</Tooltip>
@@ -630,7 +643,7 @@ export default function CreateAudio() {
settingsFields={
<>
{styleSupported && (
<div className="rounded-xl border border-ui-panel-border bg-ui-controls px-3 py-1.5">
<div className="rounded-[3px] border border-ui-panel-border bg-ui-controls px-3 py-1.5">
<StylePromptRow
value={ui.stylePrompt}
onChange={setStylePrompt}
@@ -774,7 +787,7 @@ export default function CreateAudio() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -953,7 +966,7 @@ function MobileSliderRow({
<button
type="button"
onClick={onReset}
className="rounded bg-white/5 px-2 py-0.5 text-xs font-medium text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg"
className="bg-white/5 px-2 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg"
>
Auto
</button>
@@ -37,7 +37,6 @@ export const AspectRatioIcon = ({
y={y}
width={w}
height={h}
rx={1.5}
stroke="currentColor"
strokeWidth={1.5}
/>
@@ -680,7 +680,7 @@ export default function CreateImage() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -2,7 +2,11 @@ import { useRef, useState } from "react";
import { BoxIcon, ImageIcon, LoaderCircleIcon, XIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { AddButton, type RefImage } from "../../../components/prompt-box";
import { MESH_FILE_ACCEPT, uploadMeshFile, uploadViewImage } from "./mesh-upload";
import {
MESH_FILE_ACCEPT,
uploadMeshFile,
uploadViewImage,
} from "./mesh-upload";
// Multi-view side inputs (front/back/left/right) and the mesh-to-mesh input
// file — the mobile form band. The desktop prompt box renders the same store
@@ -53,59 +57,61 @@ export function MeshInputsRow({
return (
// Title on the left, upload slots right-aligned + top-aligned (matches
// ImagePromptRow).
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2">
<div className="glass flex items-start gap-3 px-3 py-2">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<DynamicIcon icon={titleIcon} className="h-3.5 w-3.5" />
<span className="text-sm font-medium">{title}</span>
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
{title}
</span>
</div>
<span className="text-[13px] text-white/60">{subtitle}</span>
</div>
<div className="flex flex-wrap items-end justify-end gap-3">
{showMultiView && (
<>
<ImageSlot
label="Front"
image={frontImage}
onChange={(img) => onChange({ frontImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("front")
: undefined
}
/>
<ImageSlot
label="Back"
image={backImage}
onChange={(img) => onChange({ backImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("back")
: undefined
}
/>
<ImageSlot
label="Left"
image={leftImage}
onChange={(img) => onChange({ leftImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("left")
: undefined
}
/>
<ImageSlot
label="Right"
image={rightImage}
onChange={(img) => onChange({ rightImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("right")
: undefined
}
/>
</>
)}
<>
<ImageSlot
label="Front"
image={frontImage}
onChange={(img) => onChange({ frontImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("front")
: undefined
}
/>
<ImageSlot
label="Back"
image={backImage}
onChange={(img) => onChange({ backImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("back")
: undefined
}
/>
<ImageSlot
label="Left"
image={leftImage}
onChange={(img) => onChange({ leftImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("left")
: undefined
}
/>
<ImageSlot
label="Right"
image={rightImage}
onChange={(img) => onChange({ rightImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("right")
: undefined
}
/>
</>
)}
{showMeshInput && (
<MeshFileSlot
mesh={inputMesh}
@@ -120,7 +126,7 @@ export function MeshInputsRow({
// ── Slots ──────────────────────────────────────────────────────────────────
const SLOT_CLASS =
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
function SlotShell({
label,
@@ -132,7 +138,9 @@ function SlotShell({
return (
<div className="flex flex-col items-center gap-1">
{children}
<span className="text-[11px] text-white/60">{label}</span>
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/60">
{label}
</span>
</div>
);
}
@@ -171,7 +179,7 @@ function ImageSlot({
onChange={handleUpload}
/>
{image ? (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
<div className="group relative aspect-square w-14 overflow-hidden rounded-[3px] border border-white/30">
<img
src={image.url}
alt={`${label} view`}
@@ -179,17 +187,14 @@ function ImageSlot({
/>
<button
onClick={() => onChange(undefined)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
<div className={SLOT_CLASS}>
<LoaderCircleIcon
spin
className="h-5 w-5 text-white" />
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
</div>
) : (
<AddButton
@@ -231,28 +236,25 @@ function MeshFileSlot({
onChange={handleUpload}
/>
{mesh ? (
<div className="group relative flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/10">
<BoxIcon className="h-5 w-5 text-white/80" />
<div className="group relative flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-white/30 bg-white/10">
<BoxIcon className="h-5 w-5 text-white/80" />
<button
onClick={() => onChange(undefined)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
<div className={SLOT_CLASS}>
<LoaderCircleIcon
spin
className="h-5 w-5 text-white" />
<LoaderCircleIcon spin className="h-5 w-5 text-white" />
</div>
) : (
<button
onClick={() => inputRef.current?.click()}
className={SLOT_CLASS}
>
<BoxIcon className="text-xl text-white/80" />
<BoxIcon className="text-xl text-white/80" />
</button>
)}
</SlotShell>
@@ -625,7 +625,7 @@ export default function CreateObject() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -247,16 +247,16 @@ export default function CreateVFX() {
if (!authChecked) {
return (
<div className="flex h-screen items-center justify-center bg-[#101014]">
<div className="flex h-screen items-center justify-center bg-ui-background">
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
</div>
);
}
return (
<div className="relative h-full w-full bg-[#101014] text-white">
<div className="relative h-full w-full bg-ui-background text-white">
<Seo
title="Background Change - ArtCraft"
description="Swap the backdrop of a video using a reference image."
@@ -304,7 +304,7 @@ export default function CreateVFX() {
<div
aria-hidden
className="pointer-events-none fixed bottom-0 right-0 z-20 h-72 bg-gradient-to-t from-[#101014] via-[#101014]/85 to-transparent transition-[left] duration-200 ease-linear"
className="pointer-events-none fixed bottom-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background via-ui-background/85 to-transparent transition-[left] duration-200 ease-linear"
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
/>
@@ -338,7 +338,7 @@ interface EmptyStateProps {
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 ring-1 ring-white/10">
<div className="flex h-12 w-12 items-center justify-center border border-white/15 bg-white/5">
<SparklesIcon className="text-2xl text-white/40" />
</div>
<h3 className="text-2xl font-bold text-white">{title}</h3>
@@ -1,6 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { AudioLinesIcon, ClockIcon, InfoIcon, SparklesIcon } from "lucide-react";
import {
AudioLinesIcon,
ClockIcon,
InfoIcon,
SparklesIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
import type { OmniGenVideoModelInfo } from "@storyteller/api";
@@ -1487,10 +1492,8 @@ export default function CreateVideo() {
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
banner={
requiresImageInput ? (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/40 bg-amber-900/80 px-3.5 py-2.5 text-xs text-amber-100">
<InfoIcon
className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<div className="flex items-start gap-2.5 border border-amber-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-amber-100">
<InfoIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<span>
This model can&apos;t generate from text alone — add a starting
frame to animate your prompt.
@@ -1506,13 +1509,13 @@ export default function CreateVideo() {
key={item.label}
type="button"
onClick={() => handleInputModeChange(item)}
className={`rounded-xl border px-3 py-2.5 text-left transition-colors ${
className={`border px-3 py-2.5 text-left transition-colors ${
item.selected
? "border-primary bg-primary/15"
? "border-white bg-white/10"
: "border-ui-panel-border bg-ui-controls hover:bg-ui-controls/80"
}`}
>
<span className="block text-sm font-semibold text-base-fg">
<span className="block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
{item.label}
</span>
<span className="block text-xs text-base-fg/55">
@@ -1566,9 +1569,9 @@ export default function CreateVideo() {
/>
)}
{hasSound && (
<div className="flex items-center justify-between rounded-xl border border-ui-panel-border bg-ui-controls px-3.5 py-2.5">
<div className="flex items-center justify-between rounded-[3px] border border-ui-panel-border bg-ui-controls px-3.5 py-2.5">
<span className="flex items-center gap-2 text-sm font-medium text-base-fg/70">
<AudioLinesIcon className="h-4 w-4" />
<AudioLinesIcon className="h-4 w-4" />
Audio
</span>
<ToggleButton
@@ -1584,7 +1587,7 @@ export default function CreateVideo() {
<>
<MobileFieldButton
label="Output"
icon={<ClockIcon className="h-4 w-4" />}
icon={<ClockIcon className="h-4 w-4" />}
value={outputSummary}
onClick={() => setIsOutputDrawerOpen(true)}
/>
@@ -1653,7 +1656,7 @@ export default function CreateVideo() {
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-11 items-center justify-center gap-1 rounded-xl border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all hover:bg-ui-controls/80 active:scale-95"
className="flex h-11 items-center justify-center gap-1 border border-ui-controls-border bg-ui-controls px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
>
@Characters
</button>
@@ -1664,14 +1667,6 @@ export default function CreateVideo() {
// ── Render ────────────────────────────────────────────────────────────
const videoGlowOrbs = (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
<div className="absolute bottom-[-15%] left-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-purple-600 opacity-[0.08] blur-[120px] transform-gpu" />
<div className="absolute bottom-[10%] right-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
</div>
);
return (
<CreateMediaPageShell
title="Create Video - ArtCraft"
@@ -1688,7 +1683,7 @@ export default function CreateVideo() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -1697,7 +1692,6 @@ export default function CreateVideo() {
bottomOffset={promptHeight + 24}
modelItems={modelItems}
onModelChange={handleModelChange}
glowOrbs={videoGlowOrbs}
promptForm={mobileForm}
gridContent={
<GenerationGallery
@@ -1726,7 +1720,7 @@ export default function CreateVideo() {
}}
>
{/* {selectedModel?.model === "seedance_2p0" && (
<div className="mb-2 flex items-start gap-2.5 rounded-lg border border-yellow-500/40 px-3.5 py-2.5 text-xs text-yellow-200 shadow-lg backdrop-blur-xl bg-yellow-800/60">
<div className="mb-2 flex items-start gap-2.5 border border-yellow-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-yellow-200">
<TriangleAlertIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-yellow-400" />
<span>
Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages.
@@ -1735,10 +1729,8 @@ export default function CreateVideo() {
</div>
)} */}
{requiresImageInput && (
<div className="mb-2 flex items-start gap-2.5 rounded-lg border border-amber-500/40 bg-amber-900/80 px-3.5 py-2.5 text-xs text-amber-100 shadow-lg backdrop-blur-sm">
<InfoIcon
className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<div className="mb-2 flex items-start gap-2.5 border border-amber-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-amber-100">
<InfoIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<span>
This model can&apos;t generate from text alone — add a starting
frame to animate your prompt.
@@ -1900,11 +1892,7 @@ export default function CreateVideo() {
<PopoverMenu
mode="default"
panelTitle="Duration"
triggerIcon={
<ClockIcon
className="h-3.5 w-3.5" />
}
triggerIcon={<ClockIcon className="h-3.5 w-3.5" />}
triggerLabel={`${effectiveDuration}s`}
>
<div className="w-[min(16rem,calc(100vw-2rem))] pb-0.5">
@@ -1948,7 +1936,7 @@ export default function CreateVideo() {
}
className={
generateWithSound
? "bg-primary/40 hover:bg-primary/50 border-primary/30"
? "bg-white/10 hover:bg-white/15 border-white/40"
: undefined
}
/>
@@ -1973,7 +1961,7 @@ export default function CreateVideo() {
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg shadow-sm transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
className="flex h-9 items-center justify-center gap-1 border border-ui-controls-border bg-ui-controls px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors duration-150 hover:bg-ui-controls/80"
>
@Characters
</button>
@@ -11,7 +11,7 @@ import type { RefVideo } from "../../../components/prompt-box";
// the desktop prompt box renders the same ref in its reference deck).
const SLOT_CLASS =
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
export function ReferenceVideoSlot({
video,
@@ -62,13 +62,17 @@ export function ReferenceVideoSlot({
return (
// Title on the left, upload slot right-aligned + top-aligned (matches
// ImagePromptRow).
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2">
<div className="glass flex items-start gap-3 px-3 py-2">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<VideoIcon className="h-3.5 w-3.5" />
<span className="text-sm font-medium">Reference video</span>
<VideoIcon className="h-3.5 w-3.5" />
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Reference video
</span>
</div>
<span className="text-[13px] text-white/60">Guide the world (optional)</span>
<span className="text-[13px] text-white/60">
Guide the world (optional)
</span>
</div>
<input
type="file"
@@ -78,7 +82,7 @@ export function ReferenceVideoSlot({
onChange={handleUpload}
/>
{video ? (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
<div className="group relative aspect-square w-14 overflow-hidden rounded-[3px] border border-white/30">
<video
src={video.url}
muted
@@ -87,9 +91,9 @@ export function ReferenceVideoSlot({
/>
<button
onClick={handleRemove}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
@@ -97,8 +101,11 @@ export function ReferenceVideoSlot({
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white" />
</div>
) : (
<button onClick={() => inputRef.current?.click()} className={SLOT_CLASS}>
<PlusIcon className="text-xl text-white/80" />
<button
onClick={() => inputRef.current?.click()}
className={SLOT_CLASS}
>
<PlusIcon className="text-xl text-white/80" />
</button>
)}
</div>
@@ -478,7 +478,7 @@ export default function CreateWorld() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -41,22 +41,14 @@ const ForgotPassword = () => {
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<Seo
title="Reset Password - ArtCraft"
description="Reset your ArtCraft password."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
{!submitted ? (
<>
<div className="text-center mb-8">
@@ -80,13 +72,13 @@ const ForgotPassword = () => {
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Email or Username
</label>
<Input
@@ -95,21 +87,19 @@ const ForgotPassword = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="pt-2">
<Button
id="send-reset-btn"
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Send Reset Code"
)}
@@ -119,8 +109,8 @@ const ForgotPassword = () => {
</>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<MailIcon className="text-2xl" />
<div className="w-16 h-16 border border-white/15 bg-green-500/10 flex items-center justify-center mx-auto mb-4 text-green-500">
<MailIcon className="text-2xl" />
</div>
<h3 className="text-xl font-medium mb-2">Check your email</h3>
<p className="text-white/60 text-sm mb-6">
@@ -128,12 +118,12 @@ const ForgotPassword = () => {
<span className="text-white font-medium">{email}</span>
</p>
<Link to="/forgot-password/verify">
<Button className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10 mb-3">
<Button className="w-full justify-center h-10 mb-3">
Enter Verification Code
</Button>
</Link>
<Button
className="rounded-full w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10"
className="w-full bg-transparent border border-white/15 hover:bg-white/10 text-white justify-center h-10"
onClick={() => setSubmitted(false)}
>
Try another email
@@ -1,4 +1,10 @@
import { ArrowLeftIcon, CircleCheckIcon, EyeIcon, EyeOffIcon, LoaderCircleIcon } from "lucide-react";
import {
ArrowLeftIcon,
CircleCheckIcon,
EyeIcon,
EyeOffIcon,
LoaderCircleIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
@@ -96,22 +102,14 @@ const VerifyReset = () => {
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<Seo
title="Verify Password Reset - ArtCraft"
description="Enter your verification code and new password."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
{!success ? (
<>
<div className="text-center mb-8">
@@ -137,14 +135,14 @@ const VerifyReset = () => {
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
{/* Verification Code */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Verification Code
</label>
<Input
@@ -154,7 +152,7 @@ const VerifyReset = () => {
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter verification code"
isError={!!fieldErrors.verificationCode}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
{fieldErrors.verificationCode && (
<p className="text-red-400 text-xs ml-1">
@@ -165,7 +163,7 @@ const VerifyReset = () => {
{/* New Password */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
New Password
</label>
<div className="relative">
@@ -176,7 +174,7 @@ const VerifyReset = () => {
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
isError={!!fieldErrors.newPassword}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -197,7 +195,7 @@ const VerifyReset = () => {
{/* Verify New Password */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Verify New Password
</label>
<div className="relative">
@@ -208,7 +206,7 @@ const VerifyReset = () => {
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Enter new password again"
isError={!!fieldErrors.confirmPassword}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -232,14 +230,12 @@ const VerifyReset = () => {
<div className="pt-2">
<Button
id="change-password-btn"
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Change Password"
)}
@@ -259,8 +255,8 @@ const VerifyReset = () => {
) : (
<>
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<CircleCheckIcon className="text-2xl" />
<div className="w-16 h-16 border border-white/15 bg-green-500/10 flex items-center justify-center mx-auto mb-4 text-green-500">
<CircleCheckIcon className="text-2xl" />
</div>
<h3 className="text-xl font-medium mb-2">
Password Reset Successfully
@@ -271,7 +267,7 @@ const VerifyReset = () => {
</p>
<Button
id="back-to-homepage-btn"
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
onClick={() => navigate(redirectTo)}
>
{redirectLabel}
@@ -43,9 +43,9 @@ export const ExtractionPanel = ({
const burstAvailable = Number.isFinite(duration) && duration > 0;
return (
<div className="flex h-fit flex-col divide-y divide-ui-divider rounded-xl border border-ui-panel-border bg-ui-panel">
<div className="flex h-fit flex-col divide-y divide-ui-divider border border-ui-panel-border bg-ui-panel">
<div className="space-y-3 p-4">
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
<div className="flex items-center justify-between font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
<span>Playhead</span>
<span className="font-mono text-xs normal-case tracking-normal text-base-fg/80">
{disabled ? "--:--.---" : formatTimePrecise(currentTime)}
@@ -56,7 +56,7 @@ export const ExtractionPanel = ({
icon={CameraIcon}
onClick={onCaptureCurrent}
disabled={disabled || isExtracting}
className="w-full py-2 text-sm font-medium"
className="w-full py-2"
>
Capture frame
</Button>
@@ -72,14 +72,12 @@ export const ExtractionPanel = ({
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-base-fg/75 transition-colors hover:text-base-fg disabled:cursor-default disabled:opacity-40"
>
<span className="flex items-center gap-2">
<LayersIcon
className="text-xs text-base-fg/40" />
<LayersIcon className="text-xs text-base-fg/40" />
Burst capture
</span>
<ChevronDownIcon
className={`text-xs text-base-fg/40 transition-transform ${burstOpen ? "rotate-180" : ""}`} />
className={`text-xs text-base-fg/40 transition-transform ${burstOpen ? "rotate-180" : ""}`}
/>
</button>
<AnimatePresence initial={false}>
{burstOpen && !disabled && burstAvailable && (
@@ -108,10 +106,13 @@ export const ExtractionPanel = ({
disabled={isExtracting}
onChange={(e) =>
onNumFramesChange(
Math.max(1, Math.min(50, parseInt(e.target.value) || 1)),
Math.max(
1,
Math.min(50, parseInt(e.target.value) || 1),
),
)
}
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
className="w-full border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-white/40 focus:outline-none"
/>
</label>
<label className="block">
@@ -126,18 +127,21 @@ export const ExtractionPanel = ({
disabled={isExtracting}
onChange={(e) =>
onSpacingChange(
Math.max(1, Math.min(10000, parseInt(e.target.value) || 1)),
Math.max(
1,
Math.min(10000, parseInt(e.target.value) || 1),
),
)
}
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
className="w-full border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-white/40 focus:outline-none"
/>
</label>
</div>
{isExtracting && progress ? (
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
<div className="h-1.5 flex-1 overflow-hidden bg-white/10">
<div
className="h-full rounded-full bg-primary transition-all"
className="h-full bg-white transition-all"
style={{
width: `${(progress.done / progress.total) * 100}%`,
}}
@@ -148,10 +152,10 @@ export const ExtractionPanel = ({
</span>
<button
onClick={onCancel}
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/50 transition-colors hover:bg-white/10 hover:text-base-fg"
className="flex h-6 w-6 items-center justify-center text-base-fg/50 transition-colors hover:bg-white/10 hover:text-base-fg"
aria-label="Cancel extraction"
>
<XIcon className="text-xs" />
<XIcon className="text-xs" />
</button>
</div>
) : (
@@ -159,7 +163,7 @@ export const ExtractionPanel = ({
variant="action"
onClick={onExtractBurst}
disabled={isExtracting}
className="w-full py-1.5 text-sm font-medium"
className="w-full py-1.5"
>
Extract {numFrames} {numFrames === 1 ? "frame" : "frames"}
</Button>
@@ -174,13 +178,17 @@ export const ExtractionPanel = ({
<div className="flex items-center justify-between">
<span>Duration</span>
<span className="font-mono text-base-fg/70">
{!disabled && Number.isFinite(duration) ? formatTime(duration) : "—"}
{!disabled && Number.isFinite(duration)
? formatTime(duration)
: "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Resolution</span>
<span className="font-mono text-base-fg/70">
{!disabled && resolution ? `${resolution.w} × ${resolution.h}` : "—"}
{!disabled && resolution
? `${resolution.w} × ${resolution.h}`
: "—"}
</span>
</div>
</div>
@@ -436,7 +436,7 @@ export default function FrameExtractor() {
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="relative min-h-full w-full shrink-0 bg-[#101014] px-3 pb-8 sm:px-4 md:px-8 lg:px-12">
<div className="relative min-h-full w-full shrink-0 bg-ui-background px-3 pb-8 sm:px-4 md:px-8 lg:px-12">
<Seo
title="Video Frame Extractor - ArtCraft"
description="Grab a still frame from any video — capture, save to your library, or use it as an image prompt reference."
@@ -452,7 +452,7 @@ export default function FrameExtractor() {
variant="action"
icon={RotateCwIcon}
onClick={clearSource}
className="px-3 py-1.5 text-sm"
className="px-3 py-1.5"
>
Switch Video
</Button>
@@ -43,7 +43,7 @@ export const FramesGrid = ({
const hasUnsaved = frames.some((frame) => !actionState[frame.id]?.saved);
return (
<div className="rounded-xl border border-ui-panel-border bg-ui-panel p-4">
<div className="border border-ui-panel-border bg-ui-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-base-fg">
Captured frames
@@ -56,7 +56,7 @@ export const FramesGrid = ({
<button
onClick={onSaveAll}
disabled={isSavingAll}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/70 transition-colors hover:bg-white/5 hover:text-base-fg disabled:opacity-50"
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-base-fg/70 transition-colors hover:bg-white/5 hover:text-base-fg disabled:opacity-50"
>
<DynamicIcon
icon={isSavingAll ? LoaderCircleIcon : SaveIcon}
@@ -67,7 +67,7 @@ export const FramesGrid = ({
)}
<button
onClick={onClear}
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/50 transition-colors hover:bg-white/5 hover:text-base-fg"
className="px-2.5 py-1.5 text-xs font-medium text-base-fg/50 transition-colors hover:bg-white/5 hover:text-base-fg"
>
Clear
</button>
@@ -86,7 +86,7 @@ export const FramesGrid = ({
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.18, ease: EASE_EMPHASIS }}
className="group/frame overflow-hidden rounded-lg border border-ui-panel-border bg-ui-background"
className="group/frame overflow-hidden border border-ui-panel-border bg-ui-background"
>
<div className="relative aspect-video bg-black">
<img
@@ -96,12 +96,12 @@ export const FramesGrid = ({
/>
<button
onClick={() => onRemove(frame)}
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white/60 opacity-0 backdrop-blur-sm transition-all hover:text-white group-hover/frame:opacity-100"
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center bg-black/60 text-white/60 opacity-0 transition-all hover:text-white group-hover/frame:opacity-100"
aria-label="Remove frame"
>
<XIcon className="text-xs" />
</button>
<span className="absolute bottom-1.5 left-1.5 rounded bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white/85 backdrop-blur-sm">
<span className="absolute bottom-1.5 left-1.5 bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white/85">
{formatTimePrecise(frame.timestamp)}
</span>
</div>
@@ -158,14 +158,14 @@ const FrameAction = ({
disabled,
success,
}: FrameActionProps) => (
// fixed strategy: the card is overflow-hidden (rounded corners), which
// would clip an absolutely-positioned tooltip.
// fixed strategy: the card is overflow-hidden, which would clip an
// absolutely-positioned tooltip.
<Tooltip content={tooltip} position="top" strategy="fixed" className="z-50">
<button
onClick={onClick}
disabled={loading || disabled}
aria-label={tooltip}
className={`flex h-7 w-8 items-center justify-center rounded-md text-xs transition-colors disabled:cursor-default ${
className={`flex h-7 w-8 items-center justify-center text-xs transition-colors disabled:cursor-default ${
success
? "text-green"
: "text-base-fg/60 hover:bg-white/5 hover:text-base-fg"
@@ -24,11 +24,11 @@ export const VideoDropZone = ({
};
return (
<div className="overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-3">
<div className="overflow-hidden border border-ui-panel-border bg-ui-panel p-3">
<div
className={twMerge(
"flex aspect-video flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-ui-border bg-ui-background/40 p-6 text-center transition-colors",
isDragActive && "border-primary/60 bg-primary/5",
"flex aspect-video flex-col items-center justify-center gap-4 rounded-[3px] border border-dashed border-ui-border bg-ui-background/40 p-6 text-center transition-colors",
isDragActive && "border-white/60 bg-white/5",
)}
onDragEnter={(e) => {
e.preventDefault();
@@ -65,7 +65,7 @@ export const VideoDropZone = ({
variant="primary"
icon={UploadIcon}
onClick={() => fileInputRef.current?.click()}
className="px-4 py-1.5 text-sm font-medium"
className="px-4 py-1.5"
>
Select video
</Button>
@@ -73,7 +73,7 @@ export const VideoDropZone = ({
variant="action"
icon={ImagesIcon}
onClick={onPickFromLibrary}
className="px-4 py-1.5 text-sm font-medium"
className="px-4 py-1.5"
>
From library
</Button>
@@ -228,7 +228,7 @@ export const VideoScrubber = ({
];
return (
<div className="group/scrubber overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel">
<div className="group/scrubber overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="relative aspect-video w-full bg-black">
<video
ref={videoRef}
@@ -243,7 +243,7 @@ export const VideoScrubber = ({
{!isPlaying && !disabled && (
<button
onClick={togglePlayPause}
className="absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/scrubber:opacity-100"
className="absolute inset-0 m-auto flex h-12 w-12 items-center justify-center bg-black/55 text-white opacity-0 transition-opacity group-hover/scrubber:opacity-100"
aria-label="Play"
>
<PlayIcon className="ml-0.5" />
@@ -261,21 +261,21 @@ export const VideoScrubber = ({
onPointerCancel={handleTimelinePointerUp}
onPointerLeave={() => setHoverRatio(null)}
>
<div className="absolute top-1/2 h-2 w-full -translate-y-1/2 rounded-full bg-white/10 transition-all group-hover/timeline:h-3">
<div className="absolute top-1/2 h-2 w-full -translate-y-1/2 bg-white/10 transition-all group-hover/timeline:h-3">
<div
className="h-full rounded-full bg-primary"
className="h-full bg-white"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div
className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 bg-white"
style={{ left: `${progressPercent}%` }}
/>
{hoverRatio !== null &&
Number.isFinite(duration) &&
duration > 0 && (
<div
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded-md bg-black/80 px-2 py-0.5 font-mono text-[10px] text-white"
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded-[3px] bg-black/80 px-2 py-0.5 font-mono text-[10px] text-white"
style={{ left: `${hoverRatio * 100}%` }}
>
{formatTimePrecise(hoverRatio * duration)}
@@ -289,7 +289,7 @@ export const VideoScrubber = ({
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
<button
onClick={() => seekBy(step.delta)}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
className="flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={step.tooltip}
>
<DynamicIcon icon={step.icon} className="text-xs" />
@@ -299,7 +299,7 @@ export const VideoScrubber = ({
<Tooltip content="Play/Pause (Space)" position="top" className="z-50">
<button
onClick={togglePlayPause}
className="mx-0.5 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
className="mx-0.5 flex h-9 w-9 items-center justify-center bg-white/10 text-white transition-colors hover:bg-white/20"
aria-label="Play or pause"
>
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} />
@@ -309,7 +309,7 @@ export const VideoScrubber = ({
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
<button
onClick={() => seekBy(step.delta)}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
className="flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={step.tooltip}
>
<DynamicIcon icon={step.icon} className="text-xs" />
@@ -322,7 +322,7 @@ export const VideoScrubber = ({
<div className="hidden items-center gap-2 sm:flex">
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white"
className="flex h-7 w-7 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white"
aria-label={isMuted ? "Unmute" : "Mute"}
>
<DynamicIcon
@@ -340,7 +340,7 @@ export const VideoScrubber = ({
const video = videoRef.current;
if (video) video.volume = parseFloat(e.target.value);
}}
className="w-20 accent-primary"
className="w-20 accent-white"
aria-label="Volume"
/>
</div>
@@ -1,22 +1,28 @@
import { Link } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { ArrowRightIcon, BoxIcon, FilmIcon, GlobeIcon, GroupIcon, ImageIcon, ImagesIcon, MusicIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import {
ArrowRightIcon,
BoxIcon,
FilmIcon,
GlobeIcon,
GroupIcon,
ImageIcon,
ImagesIcon,
MusicIcon,
VideoIcon,
WandSparklesIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
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;
description: string;
href: string;
icon: LucideIcon;
accent: string;
iconBg: string;
iconColor: string;
iconTile: string;
hoverBorder: string;
badge?: string;
};
@@ -26,72 +32,64 @@ const APPS: AppCard[] = [
description: "Generate images from prompts and references.",
href: "/create-image",
icon: ImageIcon,
accent: "from-blue-500/20 to-blue-500/0",
iconBg: "bg-blue-500/25 border-blue-400/30",
iconColor: "text-blue-300",
iconTile: "border-blue-400/40 bg-blue-500/20 text-blue-300",
hoverBorder: "hover:border-blue-400/60",
},
{
label: "Video",
description: "Generate cinematic clips from text or images.",
href: "/create-video",
icon: VideoIcon,
accent: "from-purple-500/20 to-purple-500/0",
iconBg: "bg-purple-500/25 border-purple-400/30",
iconColor: "text-purple-300",
iconTile: "border-purple-400/40 bg-purple-500/20 text-purple-300",
hoverBorder: "hover:border-purple-400/60",
},
{
label: "Audio",
description: "Generate songs and sound effects from prompts.",
href: "/create-audio",
icon: MusicIcon,
accent: "from-pink-500/20 to-pink-500/0",
iconBg: "bg-pink-500/25 border-pink-400/30",
iconColor: "text-pink-300",
iconTile: "border-pink-400/40 bg-pink-500/20 text-pink-300",
hoverBorder: "hover:border-pink-400/60",
},
{
label: "3D Object",
description: "Turn a prompt or image into a 3D model.",
href: "/create-object",
icon: BoxIcon,
accent: "from-cyan-500/20 to-cyan-500/0",
iconBg: "bg-cyan-500/25 border-cyan-400/30",
iconColor: "text-cyan-300",
iconTile: "border-cyan-400/40 bg-cyan-500/20 text-cyan-300",
hoverBorder: "hover:border-cyan-400/60",
},
{
label: "3D World",
description: "Turn a prompt or image into an explorable world.",
href: "/create-world",
icon: GlobeIcon,
accent: "from-teal-500/20 to-teal-500/0",
iconBg: "bg-teal-500/25 border-teal-400/30",
iconColor: "text-teal-300",
iconTile: "border-teal-400/40 bg-teal-500/20 text-teal-300",
hoverBorder: "hover:border-teal-400/60",
},
{
label: "Edit 3D",
description: "Compose 3D scenes and render with AI cameras.",
href: "/edit-3d",
icon: BoxIcon,
accent: "from-amber-500/20 to-amber-500/0",
iconBg: "bg-amber-500/25 border-amber-400/30",
iconColor: "text-amber-300",
iconTile: "border-amber-400/40 bg-amber-500/20 text-amber-300",
hoverBorder: "hover:border-amber-400/60",
},
{
label: "Background Change",
description: "Swap or remove backgrounds with AI VFX.",
href: "/background-change",
icon: WandSparklesIcon,
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/25 border-emerald-400/30",
iconColor: "text-emerald-300",
iconTile: "border-emerald-400/40 bg-emerald-500/20 text-emerald-300",
hoverBorder: "hover:border-emerald-400/60",
},
{
label: "Edit Video",
description: "Trim, arrange, and edit clips on a timeline.",
href: "/video-editor",
icon: FilmIcon,
accent: "from-rose-500/20 to-rose-500/0",
iconBg: "bg-rose-500/25 border-rose-400/30",
iconColor: "text-rose-300",
iconTile: "border-rose-400/40 bg-rose-500/20 text-rose-300",
hoverBorder: "hover:border-rose-400/60",
badge: "BETA",
},
{
@@ -99,9 +97,8 @@ const APPS: AppCard[] = [
description: "Collect references and ideas to steer a generation.",
href: "/moodboard",
icon: GroupIcon,
accent: "from-indigo-500/20 to-indigo-500/0",
iconBg: "bg-indigo-500/25 border-indigo-400/30",
iconColor: "text-indigo-300",
iconTile: "border-indigo-400/40 bg-indigo-500/20 text-indigo-300",
hoverBorder: "hover:border-indigo-400/60",
badge: "BETA",
},
{
@@ -109,27 +106,35 @@ const APPS: AppCard[] = [
description: "Grab a still frame from any video.",
href: "/frame-extractor",
icon: ImagesIcon,
accent: "from-orange-500/20 to-orange-500/0",
iconBg: "bg-orange-500/25 border-orange-400/30",
iconColor: "text-orange-300",
iconTile: "border-orange-400/40 bg-orange-500/20 text-orange-300",
hoverBorder: "hover:border-orange-400/60",
badge: "NEW",
},
];
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."
/>
<Reveal as="h1" inView={false} y={20} 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?
</Reveal>
<section className="py-12">
<Reveal as="h2" inView={false} delay={0.08} className="text-sm font-semibold text-white/85 mb-4">
<Reveal
as="h2"
inView={false}
delay={0.08}
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-4"
>
Create
</Reveal>
<RevealGroup
@@ -140,37 +145,36 @@ export function Home() {
>
{APPS.map((app) => (
<Reveal key={app.href} y={20}>
<MotionLink
<Link
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}
className={`group relative flex h-full border rounded-[3px] border-white/10 bg-white/5 p-5 transition-colors duration-150 ${app.hoverBorder}`}
>
<div
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`}
className={`flex h-11 w-11 shrink-0 items-center justify-center border ${app.iconTile}`}
>
<DynamicIcon 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">
<h3 className=" text-sm font-bold uppercase tracking-[0.08em] 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">
<span
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] leading-none ${
app.badge === "NEW"
? "border-purple-400/40 text-purple-300"
: "border-amber-400/40 text-amber-300"
}`}
>
{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">
<ArrowRightIcon className="text-xs" />
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[3px] border border-white/15 text-white/40 transition-colors duration-150 group-hover:border-white/40 group-hover:text-white">
<ArrowRightIcon className="text-xs" />
</span>
</div>
<p className="mt-1 text-sm text-white/55 leading-snug">
@@ -178,7 +182,7 @@ export function Home() {
</p>
</div>
</div>
</MotionLink>
</Link>
</Reveal>
))}
</RevealGroup>
@@ -35,7 +35,31 @@ import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { ArrowDownToLineIcon, BoxIcon, CloudIcon, EllipsisIcon, FolderIcon, FolderOpenIcon, FolderPlusIcon, GlobeIcon, Grid3x3Icon, ImageIcon, ImagesIcon, ListIcon, LoaderCircleIcon, MusicIcon, PencilIcon, PlusIcon, RefreshCwIcon, StarIcon, TagIcon, TagsIcon, Trash2Icon, VideoIcon, XIcon } from "lucide-react";
import {
ArrowDownToLineIcon,
BoxIcon,
CloudIcon,
EllipsisIcon,
FolderIcon,
FolderOpenIcon,
FolderPlusIcon,
GlobeIcon,
Grid3x3Icon,
ImageIcon,
ImagesIcon,
ListIcon,
LoaderCircleIcon,
MusicIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
StarIcon,
TagIcon,
TagsIcon,
Trash2Icon,
VideoIcon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Lightbox } from "../../components/lightbox/lightbox";
import { toast } from "../../components/toast/toast";
@@ -988,7 +1012,7 @@ export default function Library() {
// ── Not logged in / loading auth ──────────────────────────────────────────
if (isLoggedIn === false) {
return (
<div className="relative min-h-full w-full bg-[#101014] flex items-center justify-center px-4">
<div className="relative min-h-full w-full bg-ui-background flex items-center justify-center px-4">
<div className="text-center space-y-6">
<h1 className="text-3xl font-medium text-white">My Library</h1>
<p className="text-white/60 text-lg max-w-md mx-auto">
@@ -996,18 +1020,12 @@ export default function Library() {
</p>
<div className="flex gap-3 justify-center">
<Link to="/login">
<Button
variant="primary"
className="rounded-full bg-white text-black hover:bg-white/90 text-sm font-semibold px-6 py-2.5"
>
<Button variant="primary" className="px-6 py-2.5">
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="rounded-full text-sm font-semibold px-6 py-2.5"
>
<Button variant="secondary" className="px-6 py-2.5">
Sign up
</Button>
</Link>
@@ -1019,7 +1037,7 @@ export default function Library() {
if (isLoggedIn === null) {
return (
<div className="relative min-h-full w-full bg-[#101014] flex items-center justify-center">
<div className="relative min-h-full w-full bg-ui-background flex items-center justify-center">
<LoadingSpinner className="h-10 w-10 text-white/60" />
</div>
);
@@ -1048,7 +1066,9 @@ export default function Library() {
gridColumns={4}
scrollRoot={null}
>
<h3 className="text-sm font-medium text-white/50 mb-2">{date}</h3>
<h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/50 mb-2">
{date}
</h3>
<div className={GRID_CLASS}>
{dateItems.map((item) => (
<LibraryTile
@@ -1084,7 +1104,7 @@ export default function Library() {
// `select-none` blocks the blue text/image highlight when sweeping a
// marquee or fat-fingering a click; native selection would otherwise also
// hijack the drag. Form fields opt back in so dialog inputs stay editable.
className="relative min-h-full w-full shrink-0 select-none [&_input]:select-text [&_textarea]:select-text bg-[#101014] pb-8 px-3 sm:px-4 md:px-8 lg:px-12"
className="relative min-h-full w-full shrink-0 select-none [&_input]:select-text [&_textarea]:select-text bg-ui-background pb-8 px-3 sm:px-4 md:px-8 lg:px-12"
onPointerDown={handleMarqueePointerDown}
>
<div className="mx-auto max-w-[1600px]">
@@ -1094,124 +1114,116 @@ export default function Library() {
// z-10 keeps the sticky header above the (static) grid tiles while
// staying below the app topbar (z-20), so topbar popovers (credits,
// avatar) aren't painted under this bar.
className="sticky top-0 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014] mb-4 sm:mb-6"
className="sticky top-0 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-ui-background mb-4 sm:mb-6"
>
<div className="flex flex-col gap-6">
{/* Tabs + actions */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1">
<Link
to="/library"
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 ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tab === "unsorted"
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tab === "unsorted" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<Grid3x3Icon
className="relative z-10 text-xs" />
<Grid3x3Icon className="relative z-10 text-xs" />
<span className="relative z-10">All Assets</span>
</Link>
<Link
to="/library/folders"
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 ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tab === "folders"
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tab === "folders" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FolderIcon
className="relative z-10 text-xs" />
<FolderIcon className="relative z-10 text-xs" />
<span className="relative z-10">Folders</span>
</Link>
<Link
to="/library/folderless"
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 ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tab === "folderless"
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tab === "folderless" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FolderOpenIcon
className="relative z-10 text-xs" />
<FolderOpenIcon className="relative z-10 text-xs" />
<span className="relative z-10">Unfoldered</span>
</Link>
<Link
to="/library/tags"
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 ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tab === "tags"
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tab === "tags" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<TagIcon
className="relative z-10 text-xs" />
<TagIcon className="relative z-10 text-xs" />
<span className="relative z-10">Tags</span>
</Link>
</div>
{(tab === "unsorted" || tab === "folderless") && (
<button
onClick={refreshRoot}
className="h-8 w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-8 w-8 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Refresh library"
>
<RefreshCwIcon
className={`text-sm ${initialLoading ? "animate-spin" : ""}`} />
className={`text-sm ${initialLoading ? "animate-spin" : ""}`}
/>
</button>
)}
</div>
<div className="flex items-center gap-2">
{tab === "folderless" && (
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
{FOLDERLESS_CLASS_FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => setFolderlessClass(filter.id)}
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 ${
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
folderlessClass === filter.id
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{folderlessClass === filter.id && (
<motion.span
layoutId="library-folderless-filter-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
@@ -1227,21 +1239,21 @@ export default function Library() {
</div>
)}
{tab === "unsorted" && (
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
{FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => navigate(filter.route)}
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 ${
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
activeFilter === filter.id
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{activeFilter === filter.id && (
<motion.span
layoutId="library-filter-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
@@ -1261,7 +1273,7 @@ export default function Library() {
variant="primary"
icon={FolderPlusIcon}
onClick={() => openNewFolderModal(null)}
className="rounded-full text-xs sm:text-sm px-3 sm:px-4 py-2"
className="px-3 sm:px-4 py-2"
>
New folder
</Button>
@@ -1269,7 +1281,7 @@ export default function Library() {
{tab === "tags" && !activeTagToken && tags.length > 0 && (
<>
{tagView === "list" && (
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
{(
[
["count", "Most used"],
@@ -1279,16 +1291,16 @@ export default function Library() {
<button
key={id}
onClick={() => setTagSort(id)}
className={`relative 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 ${
className={`relative px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tagSort === id
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tagSort === id && (
<motion.span
layoutId="library-tag-sort-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{
duration: 0.32,
ease: EASE_EMPHASIS,
@@ -1300,7 +1312,7 @@ export default function Library() {
))}
</div>
)}
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1">
{(
[
["cloud", CloudIcon, "Cloud"],
@@ -1311,16 +1323,16 @@ export default function Library() {
key={id}
onClick={() => setTagView(id)}
title={label}
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 ${
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tagView === id
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tagView === id && (
<motion.span
layoutId="library-tag-view-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{
duration: 0.32,
ease: EASE_EMPHASIS,
@@ -1368,17 +1380,17 @@ export default function Library() {
</h1>
<button
onClick={() => startRename(activeFolderId!)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Rename folder"
>
<PencilIcon className="text-xs" />
<PencilIcon className="text-xs" />
</button>
<button
onClick={() => openNewFolderModal(activeFolderId)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="New subfolder"
>
<FolderPlusIcon className="text-xs" />
<FolderPlusIcon className="text-xs" />
</button>
</div>
)}
@@ -1404,17 +1416,17 @@ export default function Library() {
)}
<button
onClick={() => setTagRenameTarget(activeTagToken)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Rename tag"
>
<PencilIcon className="text-xs" />
<PencilIcon className="text-xs" />
</button>
<button
onClick={() => confirmDeleteTag(activeTagToken)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-red hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-red hover:bg-ui-controls/40 transition-colors"
title="Delete tag"
>
<Trash2Icon className="text-xs" />
<Trash2Icon className="text-xs" />
</button>
</div>
)}
@@ -1427,7 +1439,7 @@ export default function Library() {
{tab === "folders" && currentSubfolders.length > 0 && (
<div>
{inFolder && (
<h3 className="text-sm font-medium text-white/50 mb-2">
<h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/50 mb-2">
Folders
</h3>
)}
@@ -1451,17 +1463,15 @@ export default function Library() {
/* ── Folders tab, root: cards only (above) ── */
currentSubfolders.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-ui-controls/30">
<FolderOpenIcon
className="text-2xl text-white/40" />
<div className="flex h-14 w-14 items-center justify-center border border-white/15 bg-ui-controls/30">
<FolderOpenIcon className="text-2xl text-white/40" />
</div>
<p className="text-white/40 text-sm">No folders yet.</p>
<Button
variant="primary"
icon={FolderPlusIcon}
onClick={() => openNewFolderModal(null)}
className="rounded-full text-sm px-4 py-2"
className="px-4 py-2"
>
New folder
</Button>
@@ -1482,18 +1492,12 @@ export default function Library() {
</p>
<div className="flex gap-3">
<Link to="/create-image">
<Button
variant="primary"
className="rounded-full text-sm px-4 py-2"
>
<Button variant="primary" className="px-4 py-2">
Create Image
</Button>
</Link>
<Link to="/create-video">
<Button
variant="secondary"
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
>
<Button variant="secondary" className="px-4 py-2">
Create Video
</Button>
</Link>
@@ -1517,10 +1521,8 @@ export default function Library() {
</div>
) : tags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-ui-controls/30">
<TagsIcon
className="text-2xl text-white/40" />
<div className="flex h-14 w-14 items-center justify-center border border-white/15 bg-ui-controls/30">
<TagsIcon className="text-2xl text-white/40" />
</div>
<p className="text-white/40 text-sm">No tags yet.</p>
<p className="text-white/30 text-xs max-w-xs text-center">
@@ -1541,7 +1543,7 @@ export default function Library() {
{sortedTags.map((t) => (
<div
key={t.token}
className="flex items-center rounded-full bg-ui-controls/40 hover:bg-ui-controls/70 transition-colors"
className="flex items-center rounded-[3px] border border-white/15 bg-ui-controls/40 hover:bg-ui-controls/70 transition-colors"
>
<button
type="button"
@@ -1556,9 +1558,7 @@ export default function Library() {
}}
className="flex items-center gap-2 pl-4 pr-1 py-2 text-sm font-medium text-white"
>
<TagIcon
className="text-xs text-violet-400" />
<TagIcon className="text-xs text-violet-400" />
<span className="max-w-[14rem] truncate">{t.value}</span>
<span className="text-xs text-white/40">
{t.useCount}
@@ -1576,9 +1576,9 @@ export default function Library() {
}}
aria-label={`Options for tag "${t.value}"`}
title="Rename or delete"
className="mr-1.5 flex h-6 w-6 items-center justify-center rounded-full text-white/40 hover:bg-white/10 hover:text-white transition-colors"
className="mr-1.5 flex h-6 w-6 items-center justify-center text-white/40 hover:bg-white/10 hover:text-white transition-colors"
>
<EllipsisIcon className="text-xs" />
<EllipsisIcon className="text-xs" />
</button>
</div>
))}
@@ -1609,7 +1609,7 @@ export default function Library() {
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
<div>
<div
className="h-4 w-24 rounded mb-3"
className="h-4 w-24 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%)",
@@ -1619,10 +1619,7 @@ export default function Library() {
/>
<div className={GRID_CLASS}>
{Array.from({ length: 15 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg overflow-hidden"
>
<div key={i} className="aspect-square overflow-hidden">
<div
className="h-full w-full"
style={{
@@ -1648,18 +1645,12 @@ export default function Library() {
</p>
<div className="flex gap-3">
<Link to="/create-image">
<Button
variant="primary"
className="rounded-full text-sm px-4 py-2"
>
<Button variant="primary" className="px-4 py-2">
Create Image
</Button>
</Link>
<Link to="/create-video">
<Button
variant="secondary"
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
>
<Button variant="secondary" className="px-4 py-2">
Create Video
</Button>
</Link>
@@ -1687,7 +1678,7 @@ export default function Library() {
<div
ref={marqueeRef}
style={{ display: "none" }}
className="pointer-events-none fixed z-40 rounded-sm border border-primary/60 bg-primary/10"
className="pointer-events-none fixed z-40 border border-white/60 bg-white/10"
/>
{/* Bulk selection bar — subscribes to the selection store itself so
@@ -1772,7 +1763,7 @@ export default function Library() {
}}
/>
<div
className="fixed z-[9999] min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
className="fixed z-[9999] min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{(() => {
@@ -1783,7 +1774,7 @@ export default function Library() {
<>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => {
setFolderStar(
contextMenu.folderId,
@@ -1793,8 +1784,8 @@ export default function Library() {
}}
>
<StarIcon
className={`w-4 ${menuFolder?.hasStar ? "text-amber-400" : "text-base-fg/40"}`} />
className={`w-4 ${menuFolder?.hasStar ? "text-amber-400" : "text-base-fg/40"}`}
/>
<span>{menuFolder?.hasStar ? "Unstar" : "Star"}</span>
</button>
<FolderColorRow
@@ -1809,32 +1800,32 @@ export default function Library() {
})()}
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => {
openNewFolderModal(contextMenu.folderId);
}}
>
<FolderPlusIcon className="w-4" />
<FolderPlusIcon className="w-4" />
<span>New subfolder</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => startRename(contextMenu.folderId)}
>
<PencilIcon className="w-4" />
<PencilIcon className="w-4" />
<span>Rename</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-red"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-red"
onClick={() => {
const folderId = contextMenu.folderId;
setContextMenu(null);
confirmDeleteFolder(folderId);
}}
>
<Trash2Icon className="w-4" />
<Trash2Icon className="w-4" />
<span>Delete folder</span>
</button>
</div>
@@ -1855,27 +1846,27 @@ export default function Library() {
}}
/>
<div
className="fixed z-[9999] min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
className="fixed z-[9999] min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1"
style={{ left: tagContextMenu.x, top: tagContextMenu.y }}
>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => setTagRenameTarget(tagContextMenu.tagToken)}
>
<PencilIcon className="w-4" />
<PencilIcon className="w-4" />
<span>Rename</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-red"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-red"
onClick={() => {
const token = tagContextMenu.tagToken;
setTagContextMenu(null);
confirmDeleteTag(token);
}}
>
<Trash2Icon className="w-4" />
<Trash2Icon className="w-4" />
<span>Delete tag</span>
</button>
</div>
@@ -2051,7 +2042,7 @@ const SEND_TO_TARGETS: {
// Shared styling for the bar's action pills. `whitespace-nowrap` keeps labels
// on a single line — the bar grows instead of squishing buttons into two rows.
const BAR_BUTTON_CLASS =
"flex items-center gap-2 whitespace-nowrap rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors";
"flex items-center gap-2 whitespace-nowrap border border-white/15 bg-ui-controls/60 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white hover:bg-ui-controls/90 transition-colors";
interface BulkSelectionBarProps {
allItems: GalleryItem[];
@@ -2180,7 +2171,7 @@ function BulkSelectionBar({
return (
<div
data-no-marquee
className="fixed bottom-20 sm:bottom-4 z-30 flex w-fit -translate-x-1/2 items-center gap-2 rounded-full border border-ui-panel-border bg-ui-panel/95 px-2.5 py-2 shadow-xl backdrop-blur"
className="fixed bottom-20 sm:bottom-4 z-30 flex w-fit -translate-x-1/2 items-center gap-2 border border-ui-panel-border bg-ui-panel px-2.5 py-2"
style={{
// Center within the content area (viewport minus the app sidebar).
left: "calc(50% + var(--ac-sidebar-offset, 0px) / 2)",
@@ -2191,7 +2182,7 @@ function BulkSelectionBar({
<BulkThumb key={si.id} item={si} />
))}
{selectedItems.length > 4 && (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 border-ui-panel bg-black/20">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center border-2 border-ui-panel bg-black/20">
<span className="text-[11px] text-white/70">
+{selectedItems.length - 4}
</span>
@@ -2225,8 +2216,8 @@ function BulkSelectionBar({
className="fixed inset-0 z-[59]"
onClick={() => setSendToOpen(false)}
/>
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
Use as reference in
</div>
{SEND_TO_TARGETS.filter(
@@ -2240,7 +2231,7 @@ function BulkSelectionBar({
sendToPrompt(selectedItems, target.destination, navigate);
clear();
}}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
>
<DynamicIcon icon={target.icon} className="w-4 text-xs" />
<span>{target.label}</span>
@@ -2265,7 +2256,7 @@ function BulkSelectionBar({
}}
className={BAR_BUTTON_CLASS}
>
<ImagesIcon className="text-xs" />
<ImagesIcon className="text-xs" />
<span className="hidden sm:inline">Extract frames</span>
</button>
)}
@@ -2278,7 +2269,7 @@ function BulkSelectionBar({
onClick={() => setPopoverOpen((v) => !v)}
className={BAR_BUTTON_CLASS}
>
<FolderPlusIcon className="text-xs" />
<FolderPlusIcon className="text-xs" />
{/* Icon-only on phones — five labeled buttons overflow the bar. */}
<span className="hidden sm:inline">Add to folder</span>
</button>
@@ -2288,8 +2279,8 @@ function BulkSelectionBar({
className="fixed inset-0 z-[59]"
onClick={() => setPopoverOpen(false)}
/>
<div className="absolute bottom-full right-0 z-[60] mb-2 max-h-72 w-56 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
<div className="absolute bottom-full right-0 z-[60] mb-2 max-h-72 w-56 overflow-y-auto rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
Folders
</div>
{sortedFolders.length === 0 ? (
@@ -2306,10 +2297,9 @@ function BulkSelectionBar({
setPopoverOpen(false);
clear();
}}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
>
<FolderIcon
className={
folder.colorCode ? "text-xs" : "text-xs text-primary"
}
@@ -2317,7 +2307,8 @@ function BulkSelectionBar({
folder.colorCode
? { color: folder.colorCode }
: undefined
} />
}
/>
<span className="truncate">{folder.name}</span>
</button>
))
@@ -2329,9 +2320,9 @@ function BulkSelectionBar({
setPopoverOpen(false);
onNewFolder(activeFolderId, Array.from(ids));
}}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white/70 hover:bg-ui-controls/50 transition-colors"
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white/70 hover:bg-ui-controls/50 transition-colors"
>
<PlusIcon className="w-4 text-xs" />
<PlusIcon className="w-4 text-xs" />
<span>Create new folder</span>
</button>
</div>
@@ -2353,7 +2344,7 @@ function BulkSelectionBar({
}}
className={BAR_BUTTON_CLASS}
>
<TagsIcon className="text-xs" />
<TagsIcon className="text-xs" />
<span className="hidden sm:inline">Add tags</span>
</button>
{tagsOpen && (
@@ -2362,8 +2353,8 @@ function BulkSelectionBar({
className="fixed inset-0 z-[59]"
onClick={() => setTagsOpen(false)}
/>
<div className="absolute bottom-full right-0 z-[60] mb-2 w-72 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
<div className="absolute bottom-full right-0 z-[60] mb-2 w-72 rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
Add tags
</div>
<TagChipInput
@@ -2380,7 +2371,7 @@ function BulkSelectionBar({
disabled={tagChips.length === 0}
loading={addingTags}
onClick={handleAddTags}
className="mt-2 w-full justify-center rounded-md text-sm py-1.5"
className="mt-2 w-full justify-center py-1.5"
>
Tag {ids.size} {ids.size === 1 ? "item" : "items"}
</Button>
@@ -2407,16 +2398,16 @@ function BulkSelectionBar({
<button
type="button"
onClick={onDeleteSelected}
className="flex items-center gap-2 whitespace-nowrap rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white hover:bg-red transition-colors"
className="flex items-center gap-2 whitespace-nowrap bg-red/90 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white hover:bg-red transition-colors"
>
<Trash2Icon className="text-xs" />
<Trash2Icon className="text-xs" />
Delete
</button>
<button
type="button"
onClick={clear}
aria-label="Clear selection"
className="flex h-8 w-8 items-center justify-center rounded-full bg-ui-controls/60 text-white hover:bg-ui-controls/90 transition-colors"
className="flex h-8 w-8 items-center justify-center border border-white/15 bg-ui-controls/60 text-white hover:bg-ui-controls/90 transition-colors"
>
<XIcon />
</button>
@@ -2438,7 +2429,7 @@ function BulkThumb({ item }: { item: GalleryItem }) {
: ImageIcon;
const showImage = !!item.thumbnail && !failed;
return (
<div className="-ml-2 h-8 w-8 flex-shrink-0 overflow-hidden rounded border-2 border-ui-panel bg-black/30 first:ml-0">
<div className="-ml-2 h-8 w-8 flex-shrink-0 overflow-hidden border-2 border-ui-panel bg-black/30 first:ml-0">
{showImage ? (
<img
src={item.thumbnail!}
@@ -5,7 +5,11 @@ import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
import {
AuthHeader,
AuthFooter,
GoogleLoginButton,
} from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { refreshSession } from "../../lib/session";
@@ -76,7 +80,7 @@ const Login = () => {
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
@@ -93,7 +97,7 @@ const Login = () => {
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 ml-1">
Email or Username
</label>
<Input
@@ -101,12 +105,12 @@ const Login = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</Reveal>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
Password
</label>
<Link
@@ -122,7 +126,7 @@ const Login = () => {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -137,12 +141,12 @@ const Login = () => {
<Reveal className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Log in"
)}
@@ -157,9 +161,9 @@ const Login = () => {
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
<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 className="w-full border-t border-white/15" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
<span className="relative bg-[#101014] px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
or
</span>
</Reveal>
@@ -129,9 +129,9 @@ export default function MediaPage() {
description="View shared media from ArtCraft."
/>
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden rounded-xl border border-white/[2%]">
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden border border-white/15">
{/* Media Preview Area */}
<div className="relative flex-1 bg-black/20 backdrop-blur-lg flex items-center justify-center overflow-hidden min-h-[30vh] lg:min-h-0">
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden min-h-[30vh] lg:min-h-0">
{mediaRecordLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-white/60" />
@@ -285,30 +285,25 @@ const Onboarding = () => {
if (isLoading) {
return (
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
<div className="relative min-h-screen bg-ui-background text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
</div>
);
}
if (currentStep === "complete") {
return (
<div className="relative min-h-screen bg-[#101014] text-white">
<div className="relative min-h-screen bg-ui-background text-white">
<Seo
title="Setup Complete - ArtCraft"
description="Your account setup is complete."
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<div className="max-w-md w-full text-center">
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
<CircleCheckIcon
className="text-5xl text-green-400" />
<div className="w-20 h-20 mx-auto border border-white/15 bg-green-500/10 flex items-center justify-center">
<CircleCheckIcon className="text-5xl text-green-400" />
</div>
</div>
<h1 className="text-3xl md:text-4xl font-medium mb-4 text-white">
@@ -322,28 +317,20 @@ const Onboarding = () => {
}
return (
<div className="relative min-h-screen bg-[#101014] text-white">
<div className="relative min-h-screen bg-ui-background text-white">
<Seo
title="Complete Your Setup - ArtCraft"
description="Just a few more details to get you started with ArtCraft."
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<div className="max-w-md w-full">
{isNewAccount && (
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-start gap-3 animate-fade-in shadow-lg shadow-blue-900/10">
<InfoIcon
className="text-blue-400 mt-1 flex-shrink-0 text-lg" />
<div className="bg-white/5 border border-white/15 p-4 mb-6 flex items-start gap-3 animate-fade-in">
<InfoIcon className="text-white/70 mt-1 flex-shrink-0 text-lg" />
<div>
<h3 className="text-blue-200 font-medium mb-1">
Account Created
</h3>
<p className="text-blue-300/80 text-sm leading-relaxed">
<h3 className="text-white font-medium mb-1">Account Created</h3>
<p className="text-white/60 text-sm leading-relaxed">
We've automatically created an account for you. Please
complete your setup below to secure it.
</p>
@@ -351,7 +338,7 @@ const Onboarding = () => {
</div>
)}
<div className="bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="bg-[#101014] border border-white/15 p-6 py-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold mb-2">
{currentStep === "password" &&
@@ -376,7 +363,7 @@ const Onboarding = () => {
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
@@ -384,7 +371,7 @@ const Onboarding = () => {
{currentStep === "password" && (
<>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Password
</label>
<Input
@@ -393,13 +380,13 @@ const Onboarding = () => {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Enter your password"
minLength={8}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Confirm Password
</label>
<Input
@@ -408,7 +395,7 @@ const Onboarding = () => {
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
required
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Confirm your password"
minLength={8}
/>
@@ -418,7 +405,7 @@ const Onboarding = () => {
{currentStep === "email" && (
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Email Address
</label>
<Input
@@ -427,7 +414,7 @@ const Onboarding = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="your@email.com"
/>
</div>
@@ -435,7 +422,7 @@ const Onboarding = () => {
{currentStep === "username" && (
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Username
</label>
<Input
@@ -443,7 +430,7 @@ const Onboarding = () => {
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Your display name"
/>
</div>
@@ -452,13 +439,11 @@ const Onboarding = () => {
<div className="flex gap-3 pt-2">
<Button
type="submit"
className="flex-1 bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="flex-1 justify-center h-10"
disabled={isSubmitting}
>
{isSubmitting ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Continue"
)}
@@ -467,7 +452,7 @@ const Onboarding = () => {
<Button
type="button"
onClick={handleSkip}
className="bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10 px-6"
className="bg-transparent border border-white/15 hover:bg-white/10 text-white justify-center h-10 px-6"
>
Skip
</Button>
@@ -8,14 +8,16 @@
// - start from a blank canvas → BlankCanvasModal (from the lib)
import { useState } from "react";
import { ImagesIcon, LoaderCircleIcon, MaximizeIcon, PencilIcon } from "lucide-react";
import {
ImagesIcon,
LoaderCircleIcon,
MaximizeIcon,
PencilIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { UploaderStates } from "@storyteller/common";
import { MediaFilesApi } from "@storyteller/api";
import {
GalleryModal,
type GalleryItem,
} from "@storyteller/ui-gallery-modal";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import {
BlankCanvasModal,
type BaseSelectorImage,
@@ -141,10 +143,10 @@ export function BaseImageSelector({
}}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed p-8 text-center transition-colors ${
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center gap-4 rounded-[3px] border border-dashed p-8 text-center transition-colors ${
isDragging
? "border-primary-400 bg-primary/10"
: "border-ui-panel-border bg-ui-background hover:border-primary-400/50"
? "border-white bg-white/10"
: "border-white/15 bg-ui-background hover:border-white/40"
} ${busy ? "pointer-events-none opacity-60" : ""}`}
>
<input
@@ -154,7 +156,7 @@ export function BaseImageSelector({
onChange={onFileInput}
disabled={busy}
/>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary/30 text-primary-300">
<div className="flex h-14 w-14 items-center justify-center border border-primary-400/30 bg-primary/30 text-primary-300">
<DynamicIcon
icon={busy ? LoaderCircleIcon : PencilIcon}
className={`text-xl ${busy ? "animate-spin" : ""}`}
@@ -174,7 +176,7 @@ export function BaseImageSelector({
setIsGalleryOpen(true);
}}
disabled={busy}
className="flex items-center gap-2 rounded-lg border border-ui-controls-border bg-ui-controls px-3 py-2 text-sm font-medium text-base-fg transition-colors hover:bg-ui-controls/80"
className="flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
>
<ImagesIcon />
Pick from Library
@@ -186,7 +188,7 @@ export function BaseImageSelector({
setIsBlankCanvasOpen(true);
}}
disabled={busy}
className="flex items-center gap-2 rounded-lg border border-ui-controls-border bg-ui-controls px-3 py-2 text-sm font-medium text-base-fg transition-colors hover:bg-ui-controls/80"
className="flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
>
<MaximizeIcon />
Blank Canvas
@@ -28,7 +28,7 @@ export default function PageDraw() {
transform: "translateZ(0)",
// Recolors the lib's `.pegboard-bg` canvas backdrop (the surface
// behind the artboard) to the webapp's page color.
"--st-canvas": "#101014",
"--st-canvas": "#0b0b0c",
// With the global top bar hidden but the bottom prompt bar still
// present, the usable canvas area's center sits above the geometric
// center. Pull the side panels (toolbar + history) up by half the
@@ -39,7 +39,7 @@ export default function PageDraw() {
>
<PageDrawLib
adapter={adapter}
backgroundClassName="bg-[#101014]"
backgroundClassName="bg-ui-background"
fillParentHeight={!isMobile}
// Cost/help live in the webapp's own chrome, not over the canvas.
showBottomRightControls={false}
@@ -1,5 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { ImageIcon, MaximizeIcon, MinimizeIcon, VideoIcon, WandSparklesIcon, XIcon } from "lucide-react";
import {
ImageIcon,
MaximizeIcon,
MinimizeIcon,
VideoIcon,
WandSparklesIcon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { MediaFilesApi, PromptsApi } from "@storyteller/api";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
@@ -154,7 +161,7 @@ export function DemoOutputOverlay({
if (isExpanded) {
return (
<div className="pointer-events-auto absolute inset-0 z-30 flex items-center justify-center bg-black/70 p-8 backdrop-blur-sm animate-in fade-in duration-200">
<div className="pointer-events-auto absolute inset-0 z-30 flex items-center justify-center bg-black/70 p-8 animate-in fade-in duration-200">
<button
type="button"
aria-label="Close expanded view"
@@ -209,9 +216,9 @@ function ShowOutputPill({ onClick }: { onClick: () => void }) {
type="button"
onClick={onClick}
aria-label="Show rendered output"
className="pointer-events-auto absolute right-2 top-16 z-30 flex items-center gap-2 rounded-xl border border-ui-controls-border bg-ui-controls px-3 py-1.5 text-sm font-medium text-base-fg shadow-xl transition-colors duration-150 hover:bg-ui-controls/80 animate-in fade-in slide-in-from-right-4"
className="pointer-events-auto absolute right-2 top-16 z-30 flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors duration-150 hover:bg-ui-controls/80 animate-in fade-in slide-in-from-right-4"
>
<ImageIcon className="h-3 w-3 text-primary" />
<ImageIcon className="h-3 w-3 text-primary" />
Show output
</button>
);
@@ -223,9 +230,9 @@ interface ViewToggleProps {
}
// Single-button sliding toggle. The whole pill is the click target —
// clicking anywhere on it flips between video and image. A primary-
// coloured indicator translates between the two icons to show which
// side is currently active.
// clicking anywhere on it flips between video and image. A white
// indicator translates between the two icons to show which side is
// currently active.
function ViewToggle({ view, onChange }: ViewToggleProps) {
const next: View = view === "video" ? "image" : "video";
return (
@@ -233,11 +240,11 @@ function ViewToggle({ view, onChange }: ViewToggleProps) {
type="button"
aria-label={view === "video" ? "Switch to image" : "Switch to video"}
onClick={() => onChange(next)}
className="relative flex h-7 w-14 shrink-0 items-center rounded-full border border-ui-controls-border bg-black/30 p-0.5 transition-colors hover:bg-black/40"
className="relative flex h-7 w-14 shrink-0 items-center border border-ui-controls-border bg-black/30 p-0.5 transition-colors hover:bg-black/40"
>
<span
aria-hidden="true"
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-0.125rem)] rounded-full bg-primary shadow-sm transition-transform duration-200 ease-out"
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-0.125rem)] bg-white transition-transform duration-200 ease-out"
style={{
transform: view === "image" ? "translateX(100%)" : "translateX(0%)",
}}
@@ -258,7 +265,7 @@ function ToggleIcon({ icon, active }: ToggleIconProps) {
<span
className={
"relative z-10 flex h-full flex-1 items-center justify-center transition-colors " +
(active ? "text-white" : "text-base-fg/60")
(active ? "text-black" : "text-base-fg/60")
}
>
<DynamicIcon icon={icon} className="h-3 w-3" />
@@ -297,14 +304,12 @@ function Card({
onToggleExpanded();
};
return (
<div className="glass pointer-events-auto overflow-hidden rounded-xl shadow-xl border-2 border-primary">
<div className="glass pointer-events-auto overflow-hidden border border-primary">
<div className="flex items-center justify-between gap-3 border-b border-ui-controls-border/60 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<WandSparklesIcon
className="h-3 w-3 shrink-0 text-primary" />
<WandSparklesIcon className="h-3 w-3 shrink-0 text-primary" />
<div className="min-w-0 leading-tight">
<div className="text-xs font-semibold uppercase tracking-wider text-base-fg">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
Rendered Output
</div>
<div className="truncate text-[10px] text-base-fg/50">
@@ -322,9 +327,9 @@ function Card({
? "Collapse to picture-in-picture"
: "Hide rendered output"
}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
className="flex h-6 w-6 shrink-0 items-center justify-center text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
>
<XIcon className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</button>
</div>
</div>
@@ -339,8 +344,9 @@ function Card({
onToggleExpanded();
}
}}
className={`relative aspect-video w-full bg-black/40 ${isExpanded ? "cursor-zoom-out" : "cursor-zoom-in"
}`}
className={`relative aspect-video w-full bg-black/40 ${
isExpanded ? "cursor-zoom-out" : "cursor-zoom-in"
}`}
>
{children}
<button
@@ -349,8 +355,10 @@ function Card({
e.stopPropagation();
onToggleExpanded();
}}
aria-label={isExpanded ? "Collapse output view" : "Expand output view"}
className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-md border border-white/10 bg-black/60 text-white/80 shadow-lg backdrop-blur-md transition-colors duration-150 hover:bg-black/80 hover:text-white"
aria-label={
isExpanded ? "Collapse output view" : "Expand output view"
}
className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center border border-white/15 bg-black/60 text-white/80 transition-colors duration-150 hover:bg-black/80 hover:text-white"
>
<DynamicIcon
icon={isExpanded ? MinimizeIcon : MaximizeIcon}
@@ -63,9 +63,9 @@ function CollapsedPill({ onClick }: { onClick: () => void }) {
<button
type="button"
onClick={onClick}
className="flex items-center gap-2.5 rounded-full border border-primary/40 bg-gradient-to-r from-primary/90 to-primary px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:from-primary hover:to-primary animate-in fade-in slide-in-from-bottom-2 duration-300"
className="flex items-center gap-2.5 bg-white px-5 py-2.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors hover:bg-white/80 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<BoxesIcon className="h-3.5 w-3.5" />
<BoxesIcon className="h-3.5 w-3.5" />
<span>See other demo scenes</span>
</button>
);
@@ -79,13 +79,11 @@ interface ExpandedPanelProps {
function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
return (
<div className="glass w-[860px] max-w-[90vw] overflow-hidden rounded-xl border border-ui-controls-border shadow-2xl animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="glass w-[860px] max-w-[90vw] overflow-hidden rounded-[3px] border border-ui-controls-border animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="flex items-center justify-between border-b border-ui-controls-border/60 px-4 py-2.5">
<div className="flex items-center gap-2">
<LayersIcon
className="h-3 w-3 text-primary" />
<div className="text-xs font-semibold uppercase tracking-wider text-base-fg">
<LayersIcon className="h-3 w-3 text-primary" />
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
Other demo scenes
</div>
</div>
@@ -93,9 +91,9 @@ function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
type="button"
onClick={onClose}
aria-label="Close other scenes"
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
>
<XIcon className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3 p-3 sm:grid-cols-3">
@@ -386,7 +386,7 @@ function MobileGate() {
return (
<div className="flex h-full w-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/25 text-amber-300">
<div className="flex h-14 w-14 items-center justify-center border border-amber-400/30 bg-amber-500/25 text-amber-300">
<MonitorIcon className="text-xl" />
</div>
<h1 className="text-xl font-semibold text-white">
@@ -1,7 +1,7 @@
// One reusable card for the edit-3D splash. The blank-scene variant
// renders a dashed plus tile; the example variant renders a thumbnail
// of the scene's rendered output (with the prompt's first reference
// image cross-fading in on hover) over the accent gradient fallback.
// image cross-fading in on hover) over the accent surface fallback.
// Sharing one component keeps the hover/border/typography treatment
// consistent across the grid.
@@ -29,7 +29,7 @@ interface ExampleCardProps {
type SceneSplashCardProps = BlankCardProps | ExampleCardProps;
const SHELL_CLASS =
"group flex flex-col overflow-hidden rounded-xl border bg-white/[0.03] text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60";
"group flex flex-col overflow-hidden border bg-white/[0.03] text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60";
export function SceneSplashCard(props: SceneSplashCardProps) {
return (
@@ -53,8 +53,8 @@ function CardThumbnail(props: SceneSplashCardProps) {
if (props.variant === "blank") {
return (
<div className="flex aspect-video items-center justify-center bg-white/[0.02]">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-white/55 transition-colors group-hover:bg-primary/15 group-hover:text-primary">
<PlusIcon className="text-sm" />
<div className="flex h-9 w-9 items-center justify-center bg-white/5 text-white/55 transition-colors group-hover:bg-white/10 group-hover:text-white">
<PlusIcon className="text-sm" />
</div>
</div>
);
@@ -78,10 +78,7 @@ function ExampleThumbnail({
return (
<div
className={twMerge(
"relative aspect-video overflow-hidden bg-gradient-to-br",
accentClass,
)}
className={twMerge("relative aspect-video overflow-hidden", accentClass)}
>
{previewUrl && (
<img
@@ -99,7 +96,7 @@ function ExampleThumbnail({
className="absolute inset-0 h-full w-full select-none object-cover opacity-0 transition-opacity duration-200 group-hover:opacity-100"
/>
)}
<span className="absolute top-2 left-2 rounded-full bg-black/55 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white/75 backdrop-blur-sm">
<span className="absolute top-2 left-2 bg-black/55 px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/75">
Preview
</span>
</div>
@@ -125,7 +122,7 @@ function CardCaption({
// - previewUrl: the rendered output image (the "after"), shown by default
// - hoverUrl: the prompt's first reference image (the "before"), shown
// on hover so users can see what the scene generated FROM
// Failures stay silent — the card's accent gradient is a graceful fallback.
// Failures stay silent, the card's accent surface is a graceful fallback.
function useSceneCardImages(outputToken: string): {
previewUrl: string | null;
hoverUrl: string | null;
@@ -157,7 +154,7 @@ function useSceneCardImages(outputToken: string): {
promptResp?.data?.maybe_context_images?.[0]?.media_links?.cdn_url;
if (firstRef) setHoverUrl(firstRef);
} catch {
// Decorative imagery — gradient fallback is acceptable.
// Decorative imagery, the fallback surface is acceptable.
}
})();
@@ -1,6 +1,6 @@
// Edit-3D splash modal. Mirrors the design language of
// SignupCtaModal (bg-[#161618], border-white/10, off-center primary
// glow, font-display heading) so the editor's onboarding modal feels
// SignupCtaModal (flat near-black surface, hairline border,
// font-display heading) so the editor's onboarding modal feels
// like the rest of the webapp, not a one-off.
import { Modal } from "@storyteller/ui-modal";
@@ -22,7 +22,7 @@ export function SceneSplashModal({
<Modal
isOpen={isOpen}
onClose={close}
className="rounded-2xl w-full max-w-3xl overflow-hidden border border-white/10 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
className="w-full max-w-3xl overflow-hidden border border-white/15 bg-ui-modal p-0"
childPadding={false}
backdropClassName="!bg-black/80"
closeOnOutsideClick
@@ -32,11 +32,7 @@ export function SceneSplashModal({
<div className="relative overflow-hidden">
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/15"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
@@ -8,7 +8,7 @@ export interface ExampleScene {
id: string;
title: string;
description: string;
// Tailwind gradient class fragment used by the card's thumbnail. Kept
// Tailwind class fragment used by the card's thumbnail fallback. Kept
// as a class string (not a CSS color) so JIT picks it up at build time.
accentClass: string;
sceneToken: string;
@@ -27,7 +27,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
id: "lone-drifter",
title: "Lone Drifter",
description: "Sundown standoff, dust on the wind",
accentClass: "from-orange-500/30 to-amber-700/20",
accentClass: "bg-orange-500/15",
sceneToken: "m_ywnjq1bdjw2163p456tczbg5p7ean8",
outputToken: "m_9n2w5c4teefm21nhsceex3degeca9h",
},
@@ -35,7 +35,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
id: "black-sails",
title: "Black Sails",
description: "Galleon at dawn, salt on the bow",
accentClass: "from-slate-700/30 to-teal-800/20",
accentClass: "bg-teal-500/15",
sceneToken: PLACEHOLDER_SCENE_TOKEN,
outputToken: PLACEHOLDER_OUTPUT_TOKEN,
},
@@ -43,7 +43,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
id: "cold-brew-hero",
title: "Cold Brew",
description: "Studio bottle shot, soft rim light",
accentClass: "from-amber-900/30 to-stone-700/20",
accentClass: "bg-amber-500/15",
sceneToken: "m_c8earcb2pnhb4n6pnbcfn01hg9vn9w",
outputToken: "m_a5y9cvskk8xe2y4j3hjskrha86x65q",
},
@@ -14,10 +14,10 @@ import { Reveal } from "../../components/motion/reveal";
const SeedanceBanner = () => (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-200 bg-primary/[0.12] border border-primary/25 rounded-full px-3 py-1">
<span className="inline-flex items-center font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white bg-white/10 border border-white/30 px-3 py-1">
Early access
</span>
<span className="inline-flex items-center text-[11px] font-semibold uppercase tracking-[0.18em] text-white/65 bg-white/[0.04] border border-white/[0.08] rounded-full px-3 py-1">
<span className="inline-flex items-center font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/65 bg-white/5 border border-white/15 px-3 py-1">
Available today in ArtCraft
</span>
</div>
@@ -33,7 +33,7 @@ const SeedanceBanner = () => (
</p>
</div>
<div className="relative w-full rounded-2xl sm:rounded-[24px] overflow-hidden bg-[#080808] border border-white/[0.08]">
<div className="relative w-full overflow-hidden bg-[#080808] border border-white/15">
<div style={{ paddingTop: "56.25%" }} className="relative">
<iframe
src="https://player.vimeo.com/video/1169289718?autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
@@ -46,16 +46,16 @@ const SeedanceBanner = () => (
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-gradient-to-br from-primary/15 via-white/[0.03] to-white/[0.02] border border-primary/25 p-4">
<div className="text-[12px] font-semibold uppercase tracking-wider text-primary mb-1">
<div className="bg-[#101014] border border-white/30 p-4">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white mb-1">
Seedance video credits
</div>
<div className="text-white/55 text-sm leading-snug">
Included with every paid ArtCraft plan
</div>
</div>
<div className="rounded-xl bg-[#080808] border border-white/[0.08] p-4">
<div className="text-[12px] font-semibold uppercase tracking-wider text-white/70 mb-1">
<div className="bg-[#101014] border border-white/15 p-4">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 mb-1">
First in the world
</div>
<div className="text-white/55 text-sm leading-snug">
@@ -73,10 +73,10 @@ const BuyMoreCreditsCta = ({ onClick }: { onClick: () => void }) => (
<p className="text-base text-white/65">Need more credits?</p>
<Button
variant="secondary"
className="mt-3 gap-2 rounded-full border border-white/[0.1] bg-white/[0.06] hover:bg-white/[0.1] px-5 py-2 h-11 text-[14px] font-semibold text-white"
className="mt-3 gap-2 border border-white/15 bg-transparent hover:bg-white/10 px-5 py-2 h-11 text-white"
onClick={onClick}
>
<CoinsIcon className="text-primary text-[13px]" />
<CoinsIcon className="text-[13px]" />
Buy more credits
</Button>
</div>
@@ -113,25 +113,12 @@ const Pricing = () => {
}, []);
return (
<div className="relative min-h-full bg-[#101014] text-white">
<div className="relative min-h-full bg-ui-background text-white">
<Seo
title="Pricing - ArtCraft"
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
/>
{/* Subtle radial accent at top, matches landing3. Clipped to its own
wrapper so the outer page can grow naturally and let SidebarInset
handle scrolling. */}
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
<div
className="absolute inset-x-0 top-0 h-[700px]"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
</div>
<main className="relative z-10 px-4 sm:px-8 pt-10">
{isSeedanceRef ? (
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
@@ -152,7 +139,12 @@ const Pricing = () => {
) : (
<div className="max-w-6xl mx-auto">
<PricingPromoBanner className="mb-10 sm:mb-12" />
<Reveal as="div" inView={false} y={20} className="text-center mb-14">
<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>
@@ -187,12 +179,10 @@ const Pricing = () => {
</p>
<Button
variant="secondary"
className="mt-4 gap-2 rounded-full border border-white/[0.1] bg-white/[0.06] hover:bg-white/[0.1] px-5 py-2 h-11 text-[14px] font-semibold text-white"
className="mt-4 gap-2 border border-white/15 bg-transparent hover:bg-white/10 px-5 py-2 h-11 text-white"
onClick={() => setCreditsModalOpen(true)}
>
<CoinsIcon
className="text-primary text-[13px]" />
<CoinsIcon className="text-[13px]" />
Buy Credits
</Button>
</div>
@@ -120,7 +120,7 @@ export default function Referrals() {
if (!authChecked) {
return (
<div className="flex min-h-full items-center justify-center bg-[#101014]">
<div className="flex min-h-full items-center justify-center bg-ui-background">
<LoadingSpinner className="h-10 w-10 text-white/60" />
</div>
);
@@ -133,25 +133,16 @@ export default function Referrals() {
const profileLink = `${SHARE_ORIGIN}/?u=${encodeURIComponent(user.username)}`;
return (
<div className="relative min-h-full w-full bg-[#101014] text-white">
<div className="relative min-h-full w-full bg-ui-background text-white">
<Seo
title="Referrals - ArtCraft"
description="Create and share referral links to invite people to ArtCraft."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0 overflow-hidden"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 px-4 sm:px-8 pt-10 pb-10">
<div className="max-w-3xl mx-auto">
<header className="text-center mb-8">
<span className="inline-block text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-5">
<span className="inline-block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-5">
Share the craft
</span>
<h1 className="text-5xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
@@ -166,16 +157,16 @@ export default function Referrals() {
<div className="my-6 sm:my-8 flex items-center justify-center gap-3 text-white/40">
<div className="h-px w-10 bg-white/20" />
<span className="text-xs font-semibold uppercase tracking-[0.18em]">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Or
</span>
<div className="h-px w-10 bg-white/20" />
</div>
<section className="rounded-2xl bg-[#1C1C20] border border-white/10 p-6 sm:p-8 shadow-2xl">
<section className="bg-[#101014] border border-white/15 p-6 sm:p-8">
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary mb-2">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-2">
Option 2
</div>
<h2 className="text-lg font-medium text-white">
@@ -196,7 +187,7 @@ export default function Referrals() {
<LoadingSpinner className="h-7 w-7 text-white/60" />
</div>
) : listError ? (
<div className="rounded-xl bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
<div className="bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
{listError}
</div>
) : codes.length === 0 ? (
@@ -222,7 +213,7 @@ export default function Referrals() {
<form onSubmit={handleCreate} className="mt-6 space-y-2">
<label
htmlFor="new-referral-code"
className="block text-xs font-bold uppercase tracking-wide text-white/60 ml-1"
className="block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1"
>
New code
</label>
@@ -240,14 +231,14 @@ export default function Referrals() {
}
disabled={atLimit || creating}
maxLength={REFERRAL_CODE_MAX_LENGTH}
className="flex-1 h-11 rounded-xl bg-black/20 border border-white/10 focus:border-primary/50 px-4 text-sm text-white placeholder-white/25 outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 h-11 bg-ui-controls border border-white/15 focus:border-white/40 px-4 text-sm text-white placeholder-white/25 outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
type="submit"
disabled={submitDisabled}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-primary hover:bg-primary-600 px-6 text-[14px] font-semibold text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="inline-flex h-11 items-center justify-center gap-2 bg-white hover:bg-white/80 px-6 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<PlusIcon className="text-[12px]" />
<PlusIcon className="text-[12px]" />
{creating ? "Creating…" : "Create"}
</button>
</div>
@@ -275,9 +266,9 @@ export default function Referrals() {
function ProfileLinkPanel({ profileLink }: { profileLink: string }) {
return (
<section className="rounded-2xl bg-[#1C1C20] border border-white/10 p-6 sm:p-8 shadow-2xl">
<section className="bg-[#101014] border border-white/15 p-6 sm:p-8">
<div className="mb-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary mb-2">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-2">
Option 1
</div>
<h2 className="text-lg font-medium text-white">
@@ -316,13 +307,13 @@ function ReferralCodeRow({
});
return (
<li className="rounded-xl bg-black/20 border border-white/[0.08] px-4 py-3.5">
<li className="bg-ui-controls border border-white/15 px-4 py-3.5">
<div className="flex items-center gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="text-[15px] font-medium text-white truncate">
{entry.code}
</div>
<div className="mt-0.5 text-[11px] uppercase tracking-wide text-white/40">
<div className="mt-0.5 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
Created {createdLabel}
</div>
</div>
@@ -332,7 +323,7 @@ function ReferralCodeRow({
type="button"
onClick={onCancelDelete}
disabled={isDeleting}
className="h-9 px-3 rounded-full text-xs font-semibold text-white/70 hover:text-white hover:bg-white/[0.06] transition-colors disabled:opacity-50"
className="h-9 px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50"
>
Cancel
</button>
@@ -340,7 +331,7 @@ function ReferralCodeRow({
type="button"
onClick={onConfirmDelete}
disabled={isDeleting}
className="h-9 px-3.5 rounded-full text-xs font-semibold text-white bg-red-500/85 hover:bg-red-500 transition-colors disabled:opacity-50"
className="h-9 px-3.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white bg-red-500/85 hover:bg-red-500 transition-colors disabled:opacity-50"
>
{isDeleting ? "Deleting…" : "Delete"}
</button>
@@ -349,10 +340,10 @@ function ReferralCodeRow({
<button
type="button"
onClick={onRequestDelete}
className="h-9 w-9 inline-flex items-center justify-center rounded-full text-white/45 hover:text-white hover:bg-white/[0.06] transition-colors"
className="h-9 w-9 inline-flex items-center justify-center text-white/45 hover:text-white hover:bg-white/10 transition-colors"
title="Delete referral code"
>
<Trash2Icon className="text-[13px]" />
<Trash2Icon className="text-[13px]" />
</button>
)}
</div>
@@ -381,12 +372,12 @@ function CopyableLink({ value }: { value: string }) {
value={value}
readOnly
onFocus={(e) => e.currentTarget.select()}
className="flex-1 min-w-0 h-11 rounded-xl bg-black/20 border border-white/10 focus:border-primary/50 px-4 text-sm text-white/80 font-mono outline-none transition-colors"
className="flex-1 min-w-0 h-11 bg-ui-controls border border-white/15 focus:border-white/40 px-4 text-sm text-white/80 font-mono outline-none transition-colors"
/>
<button
type="button"
onClick={handleCopy}
className="inline-flex h-11 items-center gap-2 rounded-full bg-white/[0.06] hover:bg-white/[0.1] border border-white/[0.1] px-4 text-xs font-semibold text-white/85 transition-all"
className="inline-flex h-11 items-center gap-2 bg-transparent hover:bg-white/10 border border-white/15 px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/85 transition-colors"
>
<DynamicIcon
icon={copied ? CheckIcon : CopyIcon}
@@ -25,10 +25,8 @@ const SetPassword = () => {
// a password) from being misrouted here.
if (!authChecked) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#101014]">
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
<div className="flex min-h-screen items-center justify-center bg-ui-background">
<LoaderCircleIcon className="animate-spin text-4xl text-white/80" />
</div>
);
}
@@ -68,22 +66,14 @@ const SetPassword = () => {
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<Seo
title="Set Password - ArtCraft"
description="Set a password for your ArtCraft account."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
<div className="text-center mb-8">
<img
src="/images/artcraft-icon.png"
@@ -105,13 +95,13 @@ const SetPassword = () => {
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Password
</label>
<div className="relative">
@@ -121,7 +111,7 @@ const SetPassword = () => {
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
autoFocus
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -135,7 +125,7 @@ const SetPassword = () => {
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Confirm password
</label>
<div className="relative">
@@ -144,7 +134,7 @@ const SetPassword = () => {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter your password"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -161,14 +151,12 @@ const SetPassword = () => {
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Set password"
)}
@@ -44,7 +44,7 @@ const Support = () => {
"Get help with ArtCraft: browse the FAQ, join our Discord community, or email us directly.";
return (
<div className="relative min-h-screen bg-[#101014] text-white">
<div className="relative min-h-screen bg-ui-background text-white">
<Seo title={title} description={description} />
<main className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 pt-24 sm:pt-32 pb-20">
@@ -58,7 +58,7 @@ const Support = () => {
</p>
</header>
<ul className="divide-y divide-white/[0.06] border border-white/[0.06] rounded-xl overflow-hidden bg-white/[0.02]">
<ul className="divide-y divide-white/15 border border-white/15 overflow-hidden bg-[#101014]">
{ROWS.map((row) => (
<li key={row.title}>
<SupportLink row={row} />
@@ -72,13 +72,13 @@ const Support = () => {
const SupportLink = ({ row }: { row: SupportRow }) => {
const className =
"group flex items-center gap-4 px-4 py-4 sm:px-5 sm:py-5 transition-colors duration-150 hover:bg-white/[0.03]";
"group flex items-center gap-4 rounded-[3px] px-4 py-4 sm:px-5 sm:py-5 transition-colors duration-150 hover:bg-white/[0.03]";
const content = (
<>
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-md border transition-colors ${
row.iconBgClass ?? "bg-white/[0.04] border-white/[0.06]"
className={`flex h-10 w-10 shrink-0 items-center justify-center border transition-colors ${
row.iconBgClass ?? "bg-white/[0.04] border-white/15"
} ${row.iconColorClass ?? "text-white/70 group-hover:text-white"}`}
>
<DynamicIcon icon={row.icon} />
@@ -18,8 +18,8 @@ import {
// Landing view for /video-editor (no project in the URL): the user's saved
// video projects plus a "New project" tile. Styled to match the create
// pages and library: #101014 canvas, glow orbs, eyebrow + display heading,
// ui-controls cards with primary hover rings, kebab menus, and the app's
// pages and library: near-black canvas, eyebrow + display heading,
// ui-controls cards with hairline hover rings, kebab menus, and the app's
// standard confirm dialog.
const GRID_CLASSES =
@@ -128,14 +128,7 @@ export function VideoEditorProjectsLanding({
};
return (
<div className="relative h-full w-full overflow-y-auto bg-[#101014] text-white">
{/* Video-themed glow orbs (create-video recipe; absolute, not fixed,
since this page scrolls inside SidebarInset). */}
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 transform-gpu rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.10] blur-[120px]" />
<div className="absolute bottom-[-15%] left-[-10%] h-[500px] w-[500px] transform-gpu rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-purple-600 opacity-[0.07] blur-[120px]" />
</div>
<div className="relative h-full w-full overflow-y-auto bg-ui-background text-white">
{/* The global topbar is hidden on /video-editor routes; surface its
actions here like the editor header does. */}
<div className="absolute right-4 top-3 z-20">
@@ -144,7 +137,7 @@ export function VideoEditorProjectsLanding({
<div className="relative z-10 mx-auto w-full max-w-[1600px] px-3 pb-16 pt-10 sm:px-4 md:px-8 lg:px-12">
<header className="animate-fade-in-up">
<p className="text-[11px] font-semibold uppercase tracking-wider text-white/40">
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
Video editor
</p>
<h1 className="mt-1.5 font-display text-3xl font-semibold md:text-4xl">
@@ -193,7 +186,7 @@ export function VideoEditorProjectsLanding({
variant="primary"
icon={SparklesIcon}
onClick={onNewProject}
className="mt-8 h-12 rounded-full px-6 text-base font-semibold"
className="mt-8 h-12 px-6"
>
New project
</Button>
@@ -202,9 +195,7 @@ export function VideoEditorProjectsLanding({
{status === "error" && (
<div className="flex flex-col items-center justify-center gap-4 py-28 text-center">
<TriangleAlertIcon
className="text-3xl text-white/30" />
<TriangleAlertIcon className="text-3xl text-white/30" />
<div>
<div className="text-lg font-medium">
Couldn't load your projects
@@ -1,5 +1,11 @@
import { useState } from "react";
import { ClapperboardIcon, EllipsisIcon, PenIcon, PlusIcon, Trash2Icon } from "lucide-react";
import {
ClapperboardIcon,
EllipsisIcon,
PenIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { PopoverMenu } from "@storyteller/ui-popover";
import type { ProjectMeta } from "@storyteller/ui-video-editor";
@@ -7,19 +13,18 @@ import { formatTimeAgo } from "../../../lib/format-time-ago";
// Card, skeleton, and "new project" tile for the video projects landing.
// Visual language follows the library/create-page cards: ui-controls
// surface, primary hover ring, hover-revealed kebab menu, and the house
// surface, hairline hover ring, hover-revealed kebab menu, and the house
// dashed recipe for the new-item tile.
// Deterministic per-project placeholder art so thumbnail-less cards don't
// all look identical (blue/teal/purple/pink families, matching the
// create-page glow orbs).
const PLACEHOLDER_GRADIENTS = [
"from-blue-600/30 via-blue-500/15 to-[#00AABA]/20",
"from-purple-600/30 via-blue-500/15 to-pink-500/20",
"from-[#00AABA]/30 via-blue-600/15 to-purple-500/20",
"from-pink-500/25 via-purple-500/15 to-blue-600/20",
"from-indigo-500/30 via-blue-500/15 to-teal-500/20",
"from-sky-500/25 via-indigo-500/15 to-purple-600/20",
// all look identical (flat blue/teal/purple/pink tints).
const PLACEHOLDER_TINTS = [
"bg-blue-600/20",
"bg-purple-600/20",
"bg-[#00AABA]/20",
"bg-pink-500/15",
"bg-indigo-500/20",
"bg-sky-500/15",
];
const STAGGER_STEP_MS = 30;
@@ -48,7 +53,7 @@ export function VideoProjectCard({
return (
<div
className={twMerge(
"animate-fade-in-up group relative flex flex-col overflow-hidden rounded-xl bg-ui-controls/40 ring-1 ring-white/5 transition-all duration-200 hover:ring-2 hover:ring-primary-400/60",
"animate-fade-in-up group relative flex flex-col overflow-hidden rounded-[3px] bg-ui-controls/40 ring-1 ring-white/15 transition-all duration-200 hover:ring-white/40",
isDeleting && "pointer-events-none opacity-50",
)}
style={{
@@ -73,17 +78,15 @@ export function VideoProjectCard({
) : (
<div
className={twMerge(
"flex h-full w-full items-center justify-center bg-gradient-to-br",
gradientForProject(project.id),
"flex h-full w-full items-center justify-center",
tintForProject(project.id),
)}
>
<ClapperboardIcon
className="text-3xl text-white/25 transition-colors group-hover:text-white/40" />
<ClapperboardIcon className="text-3xl text-white/25 transition-colors group-hover:text-white/40" />
</div>
)}
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold text-white backdrop-blur-sm">
<span className="bg-white/15 px-3 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white">
Open project
</span>
</div>
@@ -106,10 +109,8 @@ export function VideoProjectCard({
position="bottom"
align="end"
mode="default"
triggerIcon={
<EllipsisIcon className="text-base-fg" />
}
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
triggerIcon={<EllipsisIcon className="text-base-fg" />}
buttonClassName="h-7 w-7 p-0 bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
panelClassName="w-max min-w-44 p-1"
closeOnUnhover
>
@@ -117,26 +118,26 @@ export function VideoProjectCard({
<div className="flex flex-col">
<button
type="button"
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-base-fg hover:bg-ui-controls/60"
className="flex w-full items-center gap-2 whitespace-nowrap px-2 py-2 text-sm text-base-fg hover:bg-ui-controls/60"
onClick={(e) => {
e.stopPropagation();
close();
onRename();
}}
>
<PenIcon className="w-3.5" />
<PenIcon className="w-3.5" />
Rename
</button>
<button
type="button"
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-red hover:bg-red/10"
className="flex w-full items-center gap-2 whitespace-nowrap px-2 py-2 text-sm text-red hover:bg-red/10"
onClick={(e) => {
e.stopPropagation();
close();
onDelete();
}}
>
<Trash2Icon className="w-3.5" />
<Trash2Icon className="w-3.5" />
Delete
</button>
</div>
@@ -149,11 +150,11 @@ export function VideoProjectCard({
export function VideoProjectCardSkeleton() {
return (
<div className="flex flex-col overflow-hidden rounded-xl ring-1 ring-white/5">
<div className="flex flex-col overflow-hidden rounded-[3px] ring-1 ring-white/15">
<div className="animate-shimmer aspect-video w-full bg-white/[0.04]" />
<div className="space-y-2 px-3 py-3">
<div className="animate-shimmer h-3.5 w-2/3 rounded bg-white/[0.06]" />
<div className="animate-shimmer h-3 w-1/3 rounded bg-white/[0.05]" />
<div className="animate-shimmer h-3.5 w-2/3 bg-white/[0.06]" />
<div className="animate-shimmer h-3 w-1/3 bg-white/[0.05]" />
</div>
</div>
);
@@ -164,9 +165,9 @@ export function NewProjectTile({ onClick }: { onClick: () => void }) {
<button
type="button"
onClick={onClick}
className="group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2.5 rounded-xl border border-dashed border-white/20 bg-white/[0.02] py-10 transition-colors hover:border-white/40 hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
className="group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2.5 border border-dashed border-white/20 bg-white/[0.02] py-10 transition-colors hover:border-white/40 hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-white/55 transition-colors group-hover:bg-primary/15 group-hover:text-primary">
<span className="flex h-9 w-9 items-center justify-center bg-white/5 text-white/55 transition-colors group-hover:bg-white/15 group-hover:text-white">
<PlusIcon />
</span>
<span className="text-sm font-medium text-white/70">New project</span>
@@ -174,10 +175,10 @@ export function NewProjectTile({ onClick }: { onClick: () => void }) {
);
}
function gradientForProject(id: string): string {
function tintForProject(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
return PLACEHOLDER_GRADIENTS[hash % PLACEHOLDER_GRADIENTS.length];
return PLACEHOLDER_TINTS[hash % PLACEHOLDER_TINTS.length];
}
@@ -117,9 +117,7 @@ export default function VideoEditorPage() {
return (
<VideoEditorProjectsLanding
projectStorage={projectStorage}
onNewProject={() =>
navigate(`/video-editor/${makeLocalProjectId()}`)
}
onNewProject={() => navigate(`/video-editor/${makeLocalProjectId()}`)}
onOpenProject={(token) => navigate(`/video-editor/${token}`)}
/>
);
@@ -215,7 +213,7 @@ function VideoEditorSession({
</p>
<button
onClick={() => navigate("/video-editor")}
className="rounded-lg border border-white/15 px-4 py-2 text-sm transition-colors hover:bg-white/10"
className="border border-white/15 px-4 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors hover:bg-white/10"
>
Back to projects
</button>
@@ -1,4 +1,13 @@
import { ArrowRightIcon, BoxIcon, CircleCheckIcon, DownloadIcon, ImageIcon, MonitorIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import {
ArrowRightIcon,
BoxIcon,
CircleCheckIcon,
DownloadIcon,
ImageIcon,
MonitorIcon,
VideoIcon,
WandSparklesIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
@@ -11,25 +20,17 @@ const Welcome = () => {
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
return (
<div className="relative min-h-full bg-[#101014] text-white">
<div className="relative min-h-full bg-ui-background text-white">
<Seo
title="Welcome - ArtCraft"
description="Welcome to ArtCraft. Get started with your subscription."
/>
{/* Background gradient — clipped to its own wrapper so the outer
page can grow naturally and let SidebarInset handle scrolling. */}
<div className="absolute inset-0 overflow-hidden flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-primary/40 via-purple-600/30 to-blue-500/20 opacity-30 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8">
{/* Success Header */}
<div className="text-center mb-12">
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/30 rounded-full px-6 py-3 mb-6">
<CircleCheckIcon
className="text-xl text-primary" />
<div className="inline-flex items-center gap-3 bg-white/5 border border-white/15 px-6 py-3 mb-6">
<CircleCheckIcon className="text-xl text-green-400" />
<span className="text-white font-medium">
Account created successfully!
</span>
@@ -56,12 +57,10 @@ const Welcome = () => {
{/* Create-in-browser CTA — desktop download is optional */}
<div className="max-w-3xl mx-auto mb-8">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
<div className="bg-[#101014] border border-white/15 p-8 md:p-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
<WandSparklesIcon
className="text-primary text-lg" />
<div className="w-10 h-10 bg-white/10 flex items-center justify-center">
<WandSparklesIcon className="text-white/70 text-lg" />
</div>
<h2 className="text-2xl font-medium text-white">
Start creating right here
@@ -84,18 +83,16 @@ const Welcome = () => {
<Link
key={item.to}
to={item.to}
className="group flex items-center gap-3 bg-[#252529] hover:bg-[#2D2D32] border border-white/[0.06] hover:border-white/[0.12] rounded-xl px-4 py-3 transition-all"
className="group flex items-center gap-3 rounded-[3px] bg-ui-controls hover:bg-white/10 border border-white/15 hover:border-white/30 px-4 py-3 transition-all"
>
<DynamicIcon
icon={item.icon}
className="text-primary text-base shrink-0"
className="text-white/70 text-base shrink-0"
/>
<span className="text-white/90 font-medium flex-1">
{item.label}
</span>
<ArrowRightIcon
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
<ArrowRightIcon className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
</Link>
))}
</div>
@@ -104,12 +101,10 @@ const Welcome = () => {
{/* Desktop app alternative */}
<div className="max-w-3xl mx-auto">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
<div className="bg-[#101014] border border-white/15 p-8 md:p-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
<MonitorIcon
className="text-primary text-lg" />
<div className="w-10 h-10 bg-white/10 flex items-center justify-center">
<MonitorIcon className="text-white/70 text-lg" />
</div>
<h2 className="text-2xl font-medium text-white">
Want the desktop app too?
@@ -130,9 +125,9 @@ const Welcome = () => {
].map((item) => (
<div
key={item.step}
className="bg-[#252529] p-4 rounded-xl flex items-center gap-4"
className="bg-ui-controls border border-white/15 p-4 flex items-center gap-4"
>
<div className="w-8 h-8 shrink-0 rounded-full bg-primary/60 flex items-center justify-center text-sm font-bold text-white">
<div className="w-8 h-8 shrink-0 bg-white/10 border border-white/15 flex items-center justify-center font-mono text-[11px] font-semibold text-white">
{item.step}
</div>
<div className="text-white/90 font-medium">{item.text}</div>
@@ -142,7 +137,7 @@ const Welcome = () => {
{/* Download Contingency */}
{!isMobile && (
<div className="pt-6 border-t border-white/10">
<div className="pt-6 border-t border-white/15">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<span className="text-white/50 text-sm">
Download didn't start?
@@ -151,9 +146,9 @@ const Welcome = () => {
<Button
as="link"
href={downloadUrl}
className="rounded-full bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold"
className="bg-white text-black hover:bg-white/80 px-6 py-2.5"
>
<DownloadIcon className="mr-2" />
<DownloadIcon className="mr-2" />
Download for {isMacOs ? "Mac" : "Windows"}
</Button>
<div className="flex gap-4 text-sm font-medium text-white/30">
@@ -161,14 +156,14 @@ const Welcome = () => {
href={DOWNLOAD_LINKS.WINDOWS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
<span className="w-1.5 h-1.5 bg-current"></span>
Windows
</a>
<a
href={DOWNLOAD_LINKS.MACOS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
<span className="w-1.5 h-1.5 bg-current"></span>
Mac
</a>
</div>
@@ -178,9 +173,9 @@ const Welcome = () => {
)}
{isMobile && (
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
<div className="bg-[#431407] border border-orange-900/50 p-6 text-orange-200 text-sm leading-relaxed">
<div className="flex items-center justify-center mb-3 text-orange-400">
<MonitorIcon className="text-2xl" />
<MonitorIcon className="text-2xl" />
</div>
ArtCraft is a powerful desktop experience. <br />
Please head to your computer to download and install.
+78 -90
View File
@@ -5,36 +5,37 @@
@layer base {
:root {
/* Sidebar tokens (consumed by tailwind colors in tailwind.config.js).
Stored as raw hex so we can reference them via theme() if needed. */
--sidebar-bg: #1a1a1a;
Brutalist system: near-black surfaces, white/15 hairlines, white
accent for selection/focus. */
--sidebar-bg: #1e1f22;
--sidebar-fg: rgba(255, 255, 255, 0.72);
--sidebar-border: rgba(64, 64, 64, 0.5);
--sidebar-accent: rgba(255, 255, 255, 0.06);
--sidebar-border: rgba(255, 255, 255, 0.15);
--sidebar-accent: rgba(255, 255, 255, 0.08);
--sidebar-accent-fg: #ffffff;
--sidebar-ring: rgba(45, 129, 255, 0.4);
--sidebar-ring: rgba(255, 255, 255, 0.4);
/* shadcn design tokens consumed by @storyteller/ui-video-editor.
Dark-theme palette (the editor only runs in dark mode today).
--primary matches the webapp's brand blue (#2d81ff). */
--background: 0 0% 9%;
--background: 240 5% 8%;
--foreground: 0 0% 98%;
--card: 0 0% 12%;
--card: 240 11% 7%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 12%;
--popover: 240 4% 5%;
--popover-foreground: 0 0% 98%;
--primary: 216 100% 59%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 24%;
--secondary: 240 4% 9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 18%;
--muted: 240 4% 9%;
--muted-foreground: 0 0% 64%;
--accent: 0 0% 16%;
--accent: 240 4% 9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 217 91% 60%;
--border: 0 0% 100% / 0.15;
--input: 0 0% 100% / 0.15;
--ring: 0 0% 100% / 0.6;
/* ── Motion tokens ──────────────────────────────────────────────────
Shared easing curves + durations so motion across the app feels like
@@ -71,9 +72,25 @@
}
}
/* Subtle 3px corners on interactive controls. The brutalist system keeps
large surfaces (panels, modals, tables), passive labels, and media tiles
square; anything pressable gets a soft corner for affordance. Explicit
rounding elsewhere uses the same rounded-[3px] value. Tailwind radius
utilities (rounded-full, rounded-none) still win — the utilities layer
is emitted after base. */
@layer base {
button,
select,
textarea,
input:not([type="radio"]):not([type="range"]),
[role="button"] {
border-radius: 3px;
}
}
* {
scrollbar-width: thin;
scrollbar-color: #363636 #171717;
scrollbar-color: #2c2c2f #0b0b0c;
}
*::-webkit-scrollbar {
@@ -81,13 +98,12 @@
}
*::-webkit-scrollbar-track {
background: #171717;
background: #0b0b0c;
}
*::-webkit-scrollbar-thumb {
background-color: #363636;
border-radius: 4px;
border: 2px solid #171717;
background-color: #2c2c2f;
border: 2px solid #0b0b0c;
}
.scrollbar-hidden {
@@ -100,6 +116,9 @@
}
@layer base {
/* Display face: Archivo — an industrial grotesque that matches the
hard-edged HUD system better than Bricolage's editorial quirk.
Weights still come from per-usage utilities. */
h1,
h2,
h3,
@@ -107,7 +126,7 @@
h5,
h6 {
font-family:
"Outfit",
"Archivo",
system-ui,
-apple-system,
BlinkMacSystemFont,
@@ -116,12 +135,19 @@
"Helvetica Neue",
Arial,
sans-serif;
letter-spacing: -0.02em;
letter-spacing: -0.03em;
}
/* Only display-scale headings run expanded on the wdth axis; compact
headings (card titles, section labels) stay at normal width so they
don't truncate in tight layouts. */
h1,
h2 {
font-stretch: 118%;
}
body {
font-family:
"Source Sans 3",
system-ui,
-apple-system,
BlinkMacSystemFont,
@@ -131,11 +157,21 @@
Arial,
sans-serif;
color: #ffffff;
background-color: #121212;
background-color: #0b0b0c;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Mono HUD micro-label, matching the marketing site's .hud-label. */
.hud-label {
font-family:
"Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.font-serif-italic {
font-family: "Instrument Serif", Georgia, "Times New Roman", serif;
font-style: italic;
@@ -144,6 +180,15 @@
}
}
/* Display utility carries the ax-display reference treatment (Archivo at
weight 620, 118% width), matching the marketing site. Plain (unlayered)
rule so it wins over font-weight utilities like font-semibold, which are
emitted at the top of the file. */
.font-display {
font-stretch: 118%;
font-weight: 620;
}
/* Shared component utilities (matching desktop app theme) */
.bg-ui-badge {
background-color: rgba(0, 0, 0, 0.4);
@@ -153,16 +198,11 @@
filter: invert(1);
}
/* Gradient panel surface used by pagescene + lib components. */
/* Panel surface used by pagescene + lib components. Brutalist system:
flat near-black, no gradient (class name kept for lib compatibility). */
.bg-ui-panel-gradient {
background:
radial-gradient(
60.68% 60.68% at 50% 0%,
rgba(255, 255, 255, 0.08) 0%,
rgba(0, 0, 0, 0) 70%
),
#1f1f1f;
background-color: #1f1f1f;
background: #101014;
background-color: #101014;
}
.prevent-select {
@@ -233,19 +273,18 @@
}
}
/* Brutalist system: former "glass" surfaces are now flat near-black panels
with white/15 hairline borders (class names kept for lib compatibility). */
@layer components {
.glass {
@apply relative border border-white/10 shadow-lg backdrop-blur-3xl transition-colors duration-300;
@apply bg-[#303032]/90 !important;
@apply relative border border-white/15 transition-colors duration-300 rounded-[3px] backdrop-blur-sm;
@apply bg-[#1E1F22]/95 !important;
}
}
.liquid-glass {
background: #232323c0;
border: 1px solid #ffffff14;
backdrop-filter: blur(18px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
box-shadow: 0 8px 32px -8px rgba(0, 0, 0, 0.6);
background: rgba(11, 11, 12, 0.92);
border: 1px solid rgba(255, 255, 255, 0.15);
}
/* Minimal thin scrollbar for prompt textareas */
@@ -264,7 +303,6 @@
.promptbox-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 9999px;
}
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
@@ -412,56 +450,6 @@
@apply bg-center;
}
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes rotate {
0% {
--angle: 0deg;
}
100% {
--angle: 360deg;
}
}
.glow-border-animated {
position: relative;
overflow: hidden;
}
.glow-border-animated::before {
content: "";
position: absolute;
inset: -3px;
background: conic-gradient(
from var(--angle),
transparent 0%,
transparent 80%,
rgba(147, 197, 253, 0.4) 85%,
rgba(191, 219, 254, 0.9) 90%,
rgba(255, 255, 255, 1) 92.5%,
rgba(191, 219, 254, 0.9) 95%,
rgba(147, 197, 253, 0.4) 100%,
transparent 100%
);
border-radius: inherit;
animation: rotate 2.2s linear infinite;
z-index: -1;
}
.glow-border-animated::after {
content: "";
position: absolute;
inset: 2px;
background: inherit;
border-radius: inherit;
z-index: -1;
}
/* Mobile bottom-sheet (SettingsDrawer). The shadcn Sheet relies on the
tailwindcss-animate plugin, which isn't enabled here, so we drive the
slide ourselves off Radix's data-state. Defining a closed-state animation
@@ -18,8 +18,26 @@ module.exports = {
theme: {
extend: {
fontFamily: {
mono: [
"Geist Mono",
"ui-monospace",
"SFMono-Regular",
"Menlo",
"Consolas",
"monospace",
],
display: [
"Outfit",
"Archivo",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"sans-serif",
],
sans: [
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
@@ -159,16 +177,20 @@ module.exports = {
ring: "var(--sidebar-ring)",
},
ui: {
// Aligned with artcraft app's theme-gray (frontend/apps/artcraft/app/src/styles/base.css)
background: "#242424",
panel: "#1F1F1F",
"panel-border": "#272727",
border: "#3f3f3f",
controls: "#3e3e41",
"controls-button": "#3E3E41",
"controls-border": "#3f3f3f",
divider: "#272727",
modal: "#1F1F1F",
// Brutalist system: near-black surfaces with white/15 hairline
// borders, aligned with artcraft-website + artcraft-website-next.
background: "#121316",
panel: "#0b0b0c",
"panel-border": "rgb(255 255 255 / 0.15)",
border: "rgb(255 255 255 / 0.15)",
// Raised control surface — a step above the #0b0b0c page/panel bg
// (matches the sidebar's #1e1f22) so buttons and inputs read as
// surfaces instead of blending into the background.
controls: "#1e1f22",
"controls-button": "#1e1f22",
"controls-border": "rgb(255 255 255 / 0.15)",
divider: "rgb(255 255 255 / 0.15)",
modal: "#0b0b0c",
},
"axis-x": "#D33242",
"axis-y": "#308752",
+6 -6
View File
@@ -13,8 +13,8 @@ the whole box with `–`, or expand/collapse individual sections.
## Behavior
- **Live**: physics/lighting/material values are read every frame, so slider
changes apply instantly. Formation values (anything that changes the ball
- **Live**: motion/look values are read every frame, so slider changes
apply instantly. Layout values (anything that changes the wall's panel
arrangement itself) trigger a debounced rebuild (~250 ms after the last
change).
- **Persistent**: tuned values are stored in `localStorage`
@@ -48,12 +48,12 @@ Then read values where they're used:
inside the loop — it's cheap and picks up changes instantly.
- **Build-time consumers** (values baked into geometry/sampling): read at
build time and subscribe to `useTunerStore` to trigger a debounced rebuild
when the group's values change (see the Formation wiring in
`hero-wordmark.tsx`).
when the group's values change (see the Wall layout wiring in
`hero-wall.tsx`).
The panel picks up new groups automatically — no panel changes needed.
Registered groups: Formation, Physics, Cursor & Light, Material, Halo (all
for the hero wordmark; see `src/components/landing/hero-tunables.ts`).
Registered groups: Wall layout, Wall motion, Wall look (all for the hero
render wall; see `src/components/landing/hero-tunables.ts`).
## Shipping tuned values
@@ -2,6 +2,18 @@ import type { NextConfig } from "next";
const nextConfig: NextConfig = {
reactStrictMode: true,
// Same-origin proxy for large media served from the live site. The hero
// rail draws these as WebGL video textures, which need CORS-clean
// sources — getartcraft.com serves its videos without
// Access-Control-Allow-Origin, so we route them through our own origin.
async rewrites() {
return [
{
source: "/ext-media/:path*",
destination: "https://getartcraft.com/:path*",
},
];
},
};
export default nextConfig;
+1 -9
View File
@@ -1114,7 +1114,6 @@
"resolved": "https://registry.npmjs.org/@react-three/fiber/-/fiber-9.6.1.tgz",
"integrity": "sha512-zF0rsKcVYpcJwbFEnv2HkHX9cvOEgsfQo/X8lwmR2dn13S4qEQJXir9fxf5js2LQFoXqxOY7MDkOkYx2uZ4gSg==",
"license": "MIT",
"peer": true,
"dependencies": {
"@babel/runtime": "^7.17.8",
"@types/webxr": "*",
@@ -1497,7 +1496,6 @@
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.18.tgz",
"integrity": "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w==",
"license": "MIT",
"peer": true,
"dependencies": {
"csstype": "^3.2.2"
}
@@ -1508,7 +1506,6 @@
"integrity": "sha512-Bsc+QHgp+P/F02XDzNCY9jnZNCUuLki36KT7VKrTXXLdHf+vHMNZnW1rVu5DNW/rCK+fya3DATySbLM4yhtKUw==",
"devOptional": true,
"license": "MIT",
"peer": true,
"peerDependencies": {
"@types/react": "^19.2.0"
}
@@ -1533,7 +1530,6 @@
"resolved": "https://registry.npmjs.org/@types/three/-/three-0.184.0.tgz",
"integrity": "sha512-4mY2tZAu0y0B0567w7013BBXSpsP0+Z48NJvmNo4Y/Pf76yCyz6Jw4P3tUVs10WuYNXXZ+wmHyGWpCek3amJxA==",
"license": "MIT",
"peer": true,
"dependencies": {
"@dimforge/rapier3d-compat": "~0.12.0",
"@tweenjs/tween.js": "~23.1.3",
@@ -2342,7 +2338,6 @@
"resolved": "https://registry.npmjs.org/postprocessing/-/postprocessing-6.39.4.tgz",
"integrity": "sha512-oAS/PjAbc/xT3OzjUrGsorJ4J064XwhVD2t0OwKLP/E8QwDMUJ0oOv6ZI1SYMtLchv4g0ySEx+JcLy92+48vlA==",
"license": "Zlib",
"peer": true,
"peerDependencies": {
"three": ">= 0.168.0 < 0.186.0"
}
@@ -2368,7 +2363,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-19.2.6.tgz",
"integrity": "sha512-sfWGGfavi0xr8Pg0sVsyHMAOziVYKgPLNrS7ig+ivMNb3wbCBw3KxtflsGBAwD3gYQlE/AEZsTLgToRrSCjb0Q==",
"license": "MIT",
"peer": true,
"engines": {
"node": ">=0.10.0"
}
@@ -2378,7 +2372,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.6.tgz",
"integrity": "sha512-0prMI+hvBbPjsWnxDLxlCGyM8PN6UuWjEUCYmZhO67xIV9Xasa/r/vDnq+Xyq4Lo27g8QSbO5YzARu0D1Sps3g==",
"license": "MIT",
"peer": true,
"dependencies": {
"scheduler": "^0.27.0"
},
@@ -2597,8 +2590,7 @@
"version": "0.184.0",
"resolved": "https://registry.npmjs.org/three/-/three-0.184.0.tgz",
"integrity": "sha512-wtTRjG92pM5eUg/KuUnHsqSAlPM296brTOcLgMRqEeylYTh/CdtvKUvCyyCQTzFuStieWxvZb8mVTMvdPyUpxg==",
"license": "MIT",
"peer": true
"license": "MIT"
},
"node_modules/three-mesh-bvh": {
"version": "0.8.3",
@@ -35,7 +35,7 @@
}
:root[data-theme="dark"] {
--bg: #0b0b0c;
--bg: #121316;
--bg-raised: #101014;
--bg-sunken: #060607;
--ink: #f2f1ee;
@@ -56,7 +56,7 @@
@media (prefers-color-scheme: dark) {
:root:not([data-theme="light"]) {
--bg: #0b0b0c;
--bg: #121316;
--bg-raised: #101014;
--bg-sunken: #060607;
--ink: #f2f1ee;
@@ -92,10 +92,11 @@
--color-invert-fg: var(--invert-fg);
--color-danger: var(--danger);
--font-display: var(--font-bricolage), system-ui, sans-serif;
--font-display: var(--font-archivo), system-ui, sans-serif;
--font-serif: var(--font-instrument), Georgia, serif;
--font-body: var(--font-inter), system-ui, sans-serif;
--font-mono: var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
--font-mono:
var(--font-geist-mono), ui-monospace, SFMono-Regular, Menlo, monospace;
/* No rounded corners anywhere in this design language. */
--radius-*: initial;
@@ -125,7 +126,9 @@ body {
/* Signature interaction: label blocks invert on hover/active. */
.invert-block {
transition: background-color 120ms linear, color 120ms linear;
transition:
background-color 120ms linear,
color 120ms linear;
}
.invert-block:hover,
.invert-block[data-active="true"] {
@@ -146,6 +149,17 @@ body {
}
}
/* Display type: Archivo at weight 620, slightly expanded on the width
axis (the ax-display reference treatment). Declared after the utility
layer so it also wins over per-usage font-weight utilities. The hero
wordmark opts back out of the stretch inline (canvas rasterization
can't apply font-stretch, so its static fallback must match the
sampled blocks). */
.font-display {
font-stretch: 118%;
font-weight: 620;
}
/* Mono HUD micro-label. */
.hud-label {
font-family: var(--font-mono);
@@ -1,5 +1,11 @@
import type { Metadata, Viewport } from "next";
import { Bricolage_Grotesque, Instrument_Serif, Inter, Geist_Mono } from "next/font/google";
import {
Archivo,
Archivo_Black,
Instrument_Serif,
Inter,
Geist_Mono,
} from "next/font/google";
import SiteNav from "@/components/site-nav";
import SiteFooter from "@/components/site-footer";
import MotionProvider from "@/components/motion-provider";
@@ -8,9 +14,21 @@ import "./globals.css";
const SITE_URL = "https://getartcraft.com";
const bricolage = Bricolage_Grotesque({
// Display face: Archivo with its width axis loaded, so headings can run
// slightly expanded (font-stretch) for the industrial-grotesque look.
const archivo = Archivo({
subsets: ["latin"],
variable: "--font-bricolage",
variable: "--font-archivo",
display: "swap",
axes: ["wdth"],
});
// Wordmark face: Archivo Black, the display family's poster-weight cut
// (a single-weight family of its own, not Archivo at 900).
const archivoBlack = Archivo_Black({
subsets: ["latin"],
weight: "400",
variable: "--font-archivo-black",
display: "swap",
});
@@ -91,7 +109,7 @@ export default function RootLayout({
<html
lang="en"
suppressHydrationWarning
className={`${bricolage.variable} ${instrumentSerif.variable} ${inter.variable} ${geistMono.variable}`}
className={`${archivo.variable} ${archivoBlack.variable} ${instrumentSerif.variable} ${inter.variable} ${geistMono.variable}`}
>
<head>
<script dangerouslySetInnerHTML={{ __html: THEME_SCRIPT }} />
@@ -1,62 +1,81 @@
// Every tweakable constant of the hero wordmark, registered with the dev
// Every tweakable constant of the hero render wall, registered with the dev
// tuner (see TUNER.md). Defaults here ARE the shipped values — tune live,
// copy the JSON from the panel, then update the defaults.
import { defineTunables } from "@/lib/tuner";
export const formationTuner = defineTunables("formation", "Formation", {
ballScale: { label: "Ball scale", min: 0.5, max: 2.5, step: 0.05, default: 1 },
sizeMin: { label: "Size min ×", min: 0.05, max: 0.6, step: 0.01, default: 0.18 },
sizeMax: { label: "Size max ×", min: 0.2, max: 1.4, step: 0.01, default: 0.66 },
sizeNoiseScale: { label: "Size noise px", min: 30, max: 400, step: 5, default: 130 },
sizeRoughen: { label: "Size roughen", min: 0, max: 1, step: 0.02, default: 0.4 },
gap: { label: "Gap px", min: 0, max: 6, step: 0.1, default: 0.6 },
depth: { label: "Depth ×", min: 0.2, max: 5, step: 0.1, default: 1.9 },
density: { label: "Fill density", min: 0.2, max: 3, step: 0.05, default: 1 },
edgeMargin: { label: "Edge margin", min: 0, max: 1, step: 0.05, default: 0.55 },
seed: { label: "Seed", min: 1, max: 9999, step: 1, default: 1013 },
// Wall structure — moving these rebuilds the panel layout (debounced).
export const wallLayoutTuner = defineTunables("wallLayout", "Wall layout", {
rowHeight: {
label: "Near row × vh",
min: 0.14,
max: 0.42,
step: 0.01,
default: 0.31,
},
rowScale: {
label: "Row shrink",
min: 0.5,
max: 1,
step: 0.01,
default: 0.78,
},
gap: { label: "Gap px", min: 8, max: 80, step: 1, default: 22 },
zStep: { label: "Row z step px", min: 0, max: 400, step: 5, default: 170 },
yNear: {
label: "Near row y × vh",
min: -0.5,
max: 0.5,
step: 0.01,
default: -0.17,
},
yMid: {
label: "Mid row y × vh",
min: -0.5,
max: 0.5,
step: 0.01,
default: 0.17,
},
});
export const physicsTuner = defineTunables("physics", "Physics", {
spring: { label: "Spring", min: 5, max: 140, step: 1, default: 46 },
damping: { label: "Damping", min: 1, max: 14, step: 0.1, default: 5.2 },
pushRadius: { label: "Push radius", min: 40, max: 450, step: 5, default: 180 },
pushStrength: { label: "Push strength", min: 1000, max: 80000, step: 500, default: 23000 },
swirl: { label: "Swirl", min: 0, max: 1.5, step: 0.05, default: 0.55 },
popZ: { label: "Pop toward viewer", min: 0, max: 2, step: 0.05, default: 0.6 },
bobAmp: { label: "Idle bob px", min: 0, max: 8, step: 0.1, default: 1.4 },
scatter: { label: "Intro scatter px", min: 0, max: 1400, step: 20, default: 480 },
export const wallMotionTuner = defineTunables("wallMotion", "Wall motion", {
yawDeg: { label: "Yaw °", min: -35, max: 35, step: 0.5, default: -11 },
pitchDeg: { label: "Pitch °", min: -12, max: 12, step: 0.25, default: 2.5 },
speed: { label: "Drift px/s", min: 0, max: 240, step: 2, default: 42 },
speedStep: {
label: "Row parallax",
min: 0.3,
max: 1,
step: 0.02,
default: 0.62,
},
dragMax: {
label: "Max throw px/s",
min: 400,
max: 6000,
step: 100,
default: 2400,
},
introBurst: { label: "Intro burst ×", min: 1, max: 40, step: 1, default: 16 },
parallax: {
label: "Pointer tilt rad",
min: 0,
max: 0.09,
step: 0.002,
default: 0.024,
},
});
export const lightTuner = defineTunables("light", "Cursor & Light", {
intensityDark: { label: "Cursor int (dark)", min: 0, max: 220000, step: 1000, default: 58000 },
intensityLight: { label: "Cursor int (light)", min: 0, max: 220000, step: 1000, default: 30000 },
idleDim: { label: "Idle dim", min: 0, max: 1, step: 0.05, default: 0.3 },
lightZ: { label: "Light z", min: 30, max: 500, step: 5, default: 130 },
lightRange: { label: "Light range", min: 100, max: 3000, step: 50, default: 900 },
keyIntensity: { label: "Back key (dark)", min: 0, max: 8, step: 0.1, default: 2.2 },
counterIntensity: { label: "Counter rim (dark)", min: 0, max: 4, step: 0.05, default: 0.7 },
frontFill: { label: "Front fill (dark)", min: 0, max: 1, step: 0.01, default: 0.06 },
ambientDark: { label: "Ambient (dark)", min: 0, max: 1, step: 0.01, default: 0.05 },
ambientLight: { label: "Ambient (light)", min: 0, max: 2, step: 0.05, default: 0.55 },
dirLight: { label: "Key (light)", min: 0, max: 3, step: 0.05, default: 0.75 },
});
export const materialTuner = defineTunables("material", "Material", {
roughnessDark: { label: "Roughness (dark)", min: 0, max: 1, step: 0.02, default: 0.42 },
metalnessDark: { label: "Metalness (dark)", min: 0, max: 1, step: 0.02, default: 0.5 },
clearcoat: { label: "Clearcoat (dark)", min: 0, max: 1, step: 0.02, default: 0.9 },
clearcoatRoughness: { label: "Coat rough (dark)", min: 0, max: 1, step: 0.02, default: 0.22 },
roughnessLight: { label: "Roughness (light)", min: 0, max: 1, step: 0.02, default: 0.3 },
metalnessLight: { label: "Metalness (light)", min: 0, max: 1, step: 0.02, default: 0.15 },
envLight: { label: "Env int (light)", min: 0, max: 2, step: 0.05, default: 0.55 },
accentShare: { label: "Accent share", min: 0, max: 0.3, step: 0.005, default: 0.05 },
});
export const haloTuner = defineTunables("halo", "Halo", {
size: { label: "Size px", min: 200, max: 1800, step: 20, default: 720 },
alphaDark: { label: "Alpha (dark)", min: 0, max: 1, step: 0.02, default: 0.5 },
alphaLight: { label: "Alpha (light)", min: 0, max: 0.6, step: 0.01, default: 0.14 },
z: { label: "Z offset", min: -400, max: 0, step: 5, default: -90 },
falloff: { label: "Falloff", min: 0.5, max: 6, step: 0.1, default: 2.6 },
export const wallLookTuner = defineTunables("wallLook", "Wall look", {
dimNear: { label: "Near row dim", min: 0, max: 1, step: 0.02, default: 0.86 },
dimMid: { label: "Mid row dim", min: 0, max: 1, step: 0.02, default: 0.7 },
washLight: { label: "Light wash", min: 0, max: 1, step: 0.02, default: 0.15 },
edgeFade: { label: "Edge fade", min: 0, max: 1, step: 0.02, default: 0.65 },
frameAlpha: {
label: "Frame alpha",
min: 0,
max: 1,
step: 0.02,
default: 0.9,
},
});
@@ -0,0 +1,388 @@
"use client";
import { useEffect, useMemo, useRef, useState } from "react";
import { useFrame, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { SEEDANCE_SHOWCASE } from "@/lib/landing-data";
import type { ThemeColors } from "@/lib/theme-colors";
import { useTunerStore } from "@/lib/tuner";
import {
wallLayoutTuner,
wallLookTuner,
wallMotionTuner,
} from "./hero-tunables";
// The render wall: two film-strip rows of showcase clips forming one
// yawed, pitched wall behind the wordmark. Rows sit at stepped depths and
// drift sideways at speeds proportional to their depth — real parallax,
// like passing a wall of screens — and each row loops seamlessly (panels
// wrap around offscreen). Near row is full strength; deeper rows dim into
// the background so the type always owns the composition, and panels
// vignette out toward the screen edges.
//
// A drag anywhere throws the wall with momentum (the hero container feeds
// drag state in via the WallDrag ref, since the canvas itself is
// pointer-events: none); the pointer also tilts the whole wall a few
// degrees. Unattended it drifts at a slow walking pace.
//
// The scene is fitted 1 world unit == 1 CSS px at z=0, so layout below is
// in pixels. Each clip decodes once; panels showing the same clip share
// one VideoTexture.
// Drag state written by the hero container's pointer handlers and consumed
// here every frame.
export type WallDrag = {
dragging: boolean;
dx: number; // accumulated drag pixels since the wall last consumed them
vel: number; // smoothed drag velocity, px/s
};
export function createWallDrag(): WallDrag {
return { dragging: false, dx: 0, vel: 0 };
}
const ROW_COUNT = 2;
const FOV = 30; // must match FittedCamera in hero-wordmark.tsx
type WallPanel = {
clip: number;
w: number;
h: number;
x0: number; // resting position of the panel center along its row
y: number;
z: number;
row: number;
length: number; // total loop length of the row this panel rides
};
function smoothstep(edge0: number, edge1: number, x: number): number {
const t = Math.max(0, Math.min(1, (x - edge0) / (edge1 - edge0)));
return t * t * (3 - 2 * t);
}
export default function HeroWall({
pointer,
drag,
colors,
}: {
pointer: React.RefObject<{ x: number; y: number; active: boolean }>;
drag: React.RefObject<WallDrag>;
colors: ThemeColors;
}) {
const size = useThree((s) => s.size);
const rigRef = useRef<THREE.Group>(null);
const groupRefs = useRef<(THREE.Group | null)[]>([]);
const state = useRef({
offset: 0,
speed: 0,
wasDragging: false,
started: false,
});
const tilt = useRef({ x: 0, y: 0 });
const dark = useMemo(() => {
const c = new THREE.Color(colors.bg);
return c.r + c.g + c.b < 1.5;
}, [colors.bg]);
// One <video> and one shared texture per clip.
const videos = useMemo(
() =>
SEEDANCE_SHOWCASE.map((clip) => {
const v = document.createElement("video");
v.src = clip.src;
v.muted = true;
v.loop = true;
v.playsInline = true;
v.crossOrigin = "anonymous";
v.preload = "auto";
return v;
}),
[],
);
const textures = useMemo(
() =>
videos.map((v, i) => {
const t = new THREE.VideoTexture(v);
t.colorSpace = THREE.SRGBColorSpace;
// Cover-fit the source into the panel plane (GL has no object-cover):
// crop the longer axis via repeat/offset once dimensions are known.
const fit = () => {
const va = v.videoWidth / v.videoHeight || 16 / 9;
const pa = SEEDANCE_SHOWCASE[i].aspect;
if (va > pa) {
t.repeat.set(pa / va, 1);
t.offset.set((1 - pa / va) / 2, 0);
} else {
t.repeat.set(1, va / pa);
t.offset.set(0, (1 - va / pa) / 2);
}
};
if (v.readyState >= 1) fit();
else v.addEventListener("loadedmetadata", fit, { once: true });
return t;
}),
[videos],
);
// Video lifecycle is keyed to the videos/textures alone. Re-arm sources
// on every run: React strict mode (and any remount) runs the cleanup,
// which unloads the shared <video> elements the memo still holds.
useEffect(() => {
videos.forEach((v, i) => {
if (!v.getAttribute("src")) {
v.src = SEEDANCE_SHOWCASE[i].src;
v.load();
}
v.play().catch(() => {});
});
return () => {
textures.forEach((t) => t.dispose());
videos.forEach((v) => {
v.pause();
v.removeAttribute("src");
v.load();
});
};
}, [videos, textures]);
// Layout tunables change the wall's structure — debounce a rebuild.
const [layoutVersion, setLayoutVersion] = useState(0);
useEffect(() => {
let last = JSON.stringify(wallLayoutTuner.read());
let timer: ReturnType<typeof setTimeout> | undefined;
const unsubscribe = useTunerStore.subscribe(() => {
const now = JSON.stringify(wallLayoutTuner.read());
if (now === last) return;
last = now;
clearTimeout(timer);
timer = setTimeout(() => setLayoutVersion((v) => v + 1), 250);
});
return () => {
clearTimeout(timer);
unsubscribe();
};
}, []);
// Wall structure: per row, panels are laid end to end with a uniform gap
// until they cover the yawed viewport plus one panel of margin, so the
// wrap-around teleport always happens offscreen. Rows shrink and recede
// with depth; each row draws from its own disjoint slice of the clip
// pool, so no clip ever appears in more than one row.
const layout = useMemo(() => {
const t = wallLayoutTuner.read();
const nearH = Math.min(320, Math.max(110, size.height * t.rowHeight));
const yFracs = [t.yNear, t.yMid];
const clipCount = SEEDANCE_SHOWCASE.length;
const perRow = Math.floor(clipCount / ROW_COUNT);
const extra = clipCount % ROW_COUNT;
const panels: WallPanel[] = [];
for (let i = 0; i < ROW_COUNT; i++) {
// Remainder clips go to the deeper rows: their panels are smaller, so
// they need more of them and repeat soonest without the bigger slice.
const extraBefore = Math.max(0, i - (ROW_COUNT - extra));
const rowStart = i * perRow + extraBefore;
const rowClips = Math.max(1, perRow + (i >= ROW_COUNT - extra ? 1 : 0));
const h = nearH * Math.pow(t.rowScale, i);
const span = size.width * 1.5 + h * 2.6;
let x = 0;
let k = 0;
const rowPanels: WallPanel[] = [];
while (x < span) {
const clip = (rowStart + (k % rowClips)) % clipCount;
const w = h * SEEDANCE_SHOWCASE[clip].aspect;
rowPanels.push({
clip,
w,
h,
x0: x + w / 2,
y: size.height * yFracs[i],
z: -t.zStep * i,
row: i,
length: 0,
});
x += w + t.gap;
k++;
}
// The row is a loop, so the seam (last panel wrapping around to meet
// the first) is an adjacency too. When the cycle length doesn't divide
// the panel count, the seam can pair a clip with itself — bump the
// last panel one step along the slice, which is distinct from both
// neighbors whenever the slice holds 3+ clips.
const last = rowPanels[rowPanels.length - 1];
if (rowPanels.length > 1 && rowClips >= 3 && last.clip === rowPanels[0].clip) {
const left = last.x0 - last.w / 2;
last.clip = rowStart + ((last.clip - rowStart + 1) % rowClips);
last.w = last.h * SEEDANCE_SHOWCASE[last.clip].aspect;
last.x0 = left + last.w / 2;
x = left + last.w + t.gap;
}
for (const p of rowPanels) p.length = x;
panels.push(...rowPanels);
}
const frames = panels.map(
(p) => new THREE.EdgesGeometry(new THREE.PlaneGeometry(p.w, p.h)),
);
return { panels, frames };
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [size.width, size.height, layoutVersion]);
useEffect(() => () => layout.frames.forEach((f) => f.dispose()), [layout]);
// Per-panel materials (panels share clip textures but dim and vignette
// independently, so each needs its own color/opacity).
const materials = useMemo(
() =>
layout.panels.map(
(p) =>
new THREE.MeshBasicMaterial({
map: textures[p.clip],
transparent: true,
toneMapped: false,
}),
),
[textures, layout],
);
const frameMaterials = useMemo(
() =>
layout.panels.map(
() => new THREE.LineBasicMaterial({ transparent: true }),
),
[layout],
);
// Frame color follows the theme imperatively — recreating the materials
// on a color change would churn the panel meshes for nothing.
useEffect(() => {
frameMaterials.forEach((m) => m.color.set(colors.lineStrong));
}, [frameMaterials, colors.lineStrong]);
useEffect(() => () => materials.forEach((m) => m.dispose()), [materials]);
useEffect(
() => () => frameMaterials.forEach((m) => m.dispose()),
[frameMaterials],
);
// Per-panel video readiness, eased: until a clip has decodable frames its
// panels stay as empty hairline skeletons, then the footage fades in —
// no black rectangles and no pop-in on slow connections.
const videoAlpha = useMemo(
() => new Float32Array(layout.panels.length),
[layout],
);
useFrame((st3, delta) => {
const dt = Math.min(delta, 0.05);
const st = state.current;
const d = drag.current;
const mt = wallMotionTuner.read();
const lk = wallLookTuner.read();
// Mount: the wall sweeps in fast and eases down to its walking pace.
if (!st.started) {
st.started = true;
st.speed = mt.speed * mt.introBurst;
}
// Offset advances in near-row pixels; deeper rows consume it scaled by
// their parallax factor, so a drag maps 1:1 to the near row's motion.
if (d?.dragging) {
st.offset -= d.dx;
d.dx = 0;
st.wasDragging = true;
} else {
if (st.wasDragging && d) {
st.wasDragging = false;
st.speed = Math.max(-mt.dragMax, Math.min(mt.dragMax, d.vel));
}
st.offset += st.speed * dt;
st.speed += (mt.speed - st.speed) * (1 - Math.exp(-1.1 * dt));
}
// Pointer tilt, critically damped; unattended, a slow Lissajous drift
// keeps the perspective alive.
const p = pointer.current ?? { x: 0, y: 0, active: false };
const t = st3.clock.elapsedTime;
const nx = p.active
? p.x / (size.width / 2)
: Math.sin(t * 0.13) * 0.35;
const ny = p.active
? p.y / (size.height / 2)
: Math.cos(t * 0.09) * 0.3;
const k = 1 - Math.exp(-4 * dt);
tilt.current.x += (nx - tilt.current.x) * k;
tilt.current.y += (ny - tilt.current.y) * k;
const yaw =
THREE.MathUtils.degToRad(mt.yawDeg) + tilt.current.x * mt.parallax;
const pitch =
THREE.MathUtils.degToRad(mt.pitchDeg) - tilt.current.y * mt.parallax * 0.6;
rigRef.current?.rotation.set(pitch, yaw, 0);
const intro = smoothstep(0, 1, Math.min(1, t / 1.4));
const dist = size.height / 2 / Math.tan(THREE.MathUtils.degToRad(FOV / 2));
const cosY = Math.cos(yaw);
const sinY = Math.sin(yaw);
const dims = [lk.dimNear, lk.dimMid];
for (let i = 0; i < layout.panels.length; i++) {
const g = groupRefs.current[i];
if (!g) continue;
const c = layout.panels[i];
// Odd rows ride the shared offset mirrored, so the top strip slides
// against the near one (and a drag counter-scrolls them).
const f = Math.pow(mt.speedStep, c.row) * (c.row % 2 ? -1 : 1);
let lx = (c.x0 - st.offset * f) % c.length;
if (lx < 0) lx += c.length;
lx -= c.length / 2;
g.position.set(lx, c.y, c.z);
// Screen-space vignette: project the panel center through the yaw and
// fade panels out toward (and past) the viewport edges, so the wall
// dissolves into the background instead of ending in a hard cut.
const wx = cosY * lx + sinY * c.z;
const wz = -sinY * lx + cosY * c.z;
const projX = wx * (dist / (dist - wz));
const edge = Math.abs(projX) / (size.width / 2);
const vig = 1 - lk.edgeFade * smoothstep(0.72, 1.3, edge);
const ready = videos[c.clip].readyState >= 2 ? 1 : 0;
videoAlpha[i] += (ready - videoAlpha[i]) * (1 - Math.exp(-3 * dt));
const va = videoAlpha[i];
// Depth dimming: dark theme crushes deep rows toward black (the bg);
// light theme washes them toward the paper via opacity instead.
const dim = dims[c.row];
const mat = materials[i];
if (dark) {
mat.color.setScalar(dim * vig * va);
mat.opacity = intro;
} else {
// Light theme recedes via opacity toward the paper — but only for
// depth: the near row keeps nearly full ink (sqrt softens its dim)
// and the wash applies per-row, else everything goes pastel.
mat.color.setScalar(1);
mat.opacity =
intro * Math.sqrt(dim) * vig * va * (1 - lk.washLight * c.row);
}
frameMaterials[i].opacity = lk.frameAlpha * vig * intro;
}
});
return (
<group ref={rigRef}>
{layout.panels.map((c, i) => (
<group
key={i}
ref={(el) => {
groupRefs.current[i] = el;
}}
position={[0, c.y, c.z]}
>
<mesh material={materials[i]}>
<planeGeometry args={[c.w, c.h]} />
</mesh>
{/* Hairline frame, matching the site's bordered viewport idiom. */}
<lineSegments geometry={layout.frames[i]} material={frameMaterials[i]} />
</group>
))}
</group>
);
}
@@ -1,33 +1,10 @@
"use client";
import {
Component,
useEffect,
useMemo,
useRef,
useState,
type ReactNode,
} from "react";
import { Canvas, useFrame, useThree } from "@react-three/fiber";
import { Component, useEffect, useRef, useState, type ReactNode } from "react";
import { Canvas, useThree } from "@react-three/fiber";
import * as THREE from "three";
import { RoomEnvironment } from "three/examples/jsm/environments/RoomEnvironment.js";
import { watchThemeColors, type ThemeColors } from "@/lib/theme-colors";
import { useTunerStore } from "@/lib/tuner";
import {
sampleWordmark,
WordmarkSim,
type SampledWordmark,
} from "./wordmark-sim";
import {
formationTuner,
physicsTuner,
lightTuner,
materialTuner,
haloTuner,
} from "./hero-tunables";
const WORDMARK = "artcraft";
const FONT_WEIGHT = 700;
import HeroWall, { createWallDrag } from "./hero-wall";
type PointerState = {
x: number;
@@ -35,17 +12,20 @@ type PointerState = {
active: boolean;
};
// The hero wordmark: "artcraft" built from small 3D balls. The cursor is both
// a point light and a push force — the balls part around it, pop toward the
// viewer, and rejoin on their springs. On mount they assemble from a
// scattered cloud. Server HTML, reduced-motion visitors, and WebGL failures
// all get the static display-type wordmark instead.
// The hero: the wordmark set in the site's display type, floating over a
// perspective render wall — two film-strip rows of Seedance takes forming
// one yawed wall behind the word, drifting sideways at parallax speeds.
// The type is real DOM text (crisp at any size, selectable by crawlers);
// the wall is the WebGL layer behind it. Dragging anywhere throws the wall
// with momentum; the pointer tilts it a few degrees. Server HTML,
// reduced-motion visitors, and WebGL failures all get the plain wordmark
// on the page background.
export default function HeroWordmark() {
const [ready, setReady] = useState(false);
const [colors, setColors] = useState<ThemeColors | null>(null);
const [sample, setSample] = useState<SampledWordmark | null>(null);
const containerRef = useRef<HTMLDivElement>(null);
const pointerRef = useRef<PointerState>({ x: 0, y: 0, active: false });
const wallDragRef = useRef(createWallDrag());
// Gate: motion allowed and the tab actually foregrounded (a canvas born in
// a hidden tab can come up blank).
@@ -68,134 +48,137 @@ export default function HeroWordmark() {
return watchThemeColors(setColors);
}, [ready]);
// Rasterize + sample once the display font is really loaded; re-sample when
// the container width changes meaningfully (not on mobile URL-bar jitter)
// or when a Formation tunable moves in the dev tuner.
useEffect(() => {
if (!ready) return;
const container = containerRef.current;
if (!container) return;
let cancelled = false;
let lastWidth = 0;
let timer: ReturnType<typeof setTimeout> | undefined;
const build = async (force = false) => {
const width = container.clientWidth;
if (!width || (!force && width === lastWidth)) return;
lastWidth = width;
const family = getComputedStyle(container).fontFamily;
try {
await document.fonts.load(`${FONT_WEIGHT} 100px ${family}`);
} catch {
// Fall through — canvas will use the fallback font, still legible.
}
if (cancelled) return;
const { ballScale, ...formation } = formationTuner.read();
const spacing = Math.min(18, Math.max(11, width / 62)) * ballScale;
setSample(
sampleWordmark({
text: WORDMARK,
fontFamily: family,
fontWeight: FONT_WEIGHT,
targetWidth: Math.min(width * 0.88, 1280),
spacing,
formation,
}),
);
};
build();
const ro = new ResizeObserver(() => {
clearTimeout(timer);
timer = setTimeout(build, 350);
});
ro.observe(container);
// Formation tunables change the arrangement itself — debounce a rebuild.
let lastFormation = JSON.stringify(formationTuner.read());
const unsubscribe = useTunerStore.subscribe(() => {
const now = JSON.stringify(formationTuner.read());
if (now === lastFormation) return;
lastFormation = now;
clearTimeout(timer);
timer = setTimeout(() => build(true), 250);
});
return () => {
cancelled = true;
clearTimeout(timer);
ro.disconnect();
unsubscribe();
};
}, [ready]);
// Pointer in text-plane coordinates (origin at container center, y up).
// Pointer in scene coordinates (origin at container center, y up), plus
// drag bookkeeping for the wall: pressing anywhere grabs the wall and
// throws it, while pointer movement tilts the whole perspective.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const dragLast = { x: 0, t: 0 };
const onMove = (e: PointerEvent) => {
const rect = container.getBoundingClientRect();
pointerRef.current.x = e.clientX - rect.left - rect.width / 2;
pointerRef.current.y = -(e.clientY - rect.top - rect.height / 2);
pointerRef.current.active = true;
const d = wallDragRef.current;
if (d.dragging) {
const now = performance.now();
const dx = e.clientX - dragLast.x;
const dt = Math.max(8, now - dragLast.t) / 1000;
d.dx += dx;
d.vel = d.vel * 0.75 + (-dx / dt) * 0.25;
dragLast.x = e.clientX;
dragLast.t = now;
}
};
const onDown = (e: PointerEvent) => {
onMove(e);
const d = wallDragRef.current;
d.dragging = true;
d.dx = 0;
d.vel = 0;
dragLast.x = e.clientX;
dragLast.t = performance.now();
container.setPointerCapture(e.pointerId);
};
const onUp = () => {
wallDragRef.current.dragging = false;
};
const onLeave = () => {
pointerRef.current.active = false;
wallDragRef.current.dragging = false;
};
container.addEventListener("pointermove", onMove);
container.addEventListener("pointerdown", onMove);
container.addEventListener("pointerdown", onDown);
container.addEventListener("pointerup", onUp);
container.addEventListener("pointerleave", onLeave);
container.addEventListener("pointercancel", onLeave);
return () => {
container.removeEventListener("pointermove", onMove);
container.removeEventListener("pointerdown", onMove);
container.removeEventListener("pointerdown", onDown);
container.removeEventListener("pointerup", onUp);
container.removeEventListener("pointerleave", onLeave);
container.removeEventListener("pointercancel", onLeave);
};
}, []);
const active = ready && colors !== null && sample !== null;
const active = ready && colors !== null;
return (
<div
ref={containerRef}
className="font-display relative h-[52vh] min-h-[340px] w-full touch-pan-y select-none md:h-[62vh]"
className="relative h-[64svh] min-h-110 w-full touch-pan-y select-none"
>
{/* Static wordmark: the server-rendered face of the hero, replaced by
the ball field once it's live. */}
<p
aria-hidden={active || undefined}
className={`absolute inset-0 flex items-center justify-center font-semibold lowercase tracking-[-0.05em] text-ink-strong ${
active ? "invisible" : ""
}`}
style={{ fontSize: "clamp(4rem, 13.5vw, 13rem)" }}
>
{WORDMARK}
</p>
{ready && colors && sample && (
{active && (
<CanvasBoundary>
<Canvas
aria-hidden
tabIndex={-1}
dpr={[1, 2]}
gl={{ antialias: true, alpha: true, powerPreference: "high-performance" }}
gl={{
antialias: true,
alpha: true,
powerPreference: "high-performance",
}}
style={{ position: "absolute", inset: 0, pointerEvents: "none" }}
>
<FittedCamera />
<BallField sample={sample} colors={colors} pointer={pointerRef} />
<HeroWall pointer={pointerRef} drag={wallDragRef} colors={colors} />
</Canvas>
</CanvasBoundary>
)}
{/* The wordmark, justified flush to the hero width (the big-type
poster treatment): SVG text with textLength stretches the word
edge-to-edge at any viewport, so the height rides the width via
the viewBox aspect. Still real selectable/crawlable text. The
soft drop-shadow is a legibility halo lifting the type off the
busy footage, not decoration (vw units so it scales with the
word). */}
<div className="pointer-events-none absolute inset-0 z-10 flex items-center justify-center px-4 text-ink-strong md:px-6">
<svg
className="w-[62%]"
viewBox="0 0 720 74"
style={{
overflow: "visible",
filter:
"drop-shadow(0 0 0.3vw color-mix(in srgb, var(--bg) 60%, transparent)) drop-shadow(0 0.2vw 1.2vw color-mix(in srgb, var(--bg) 45%, transparent))",
}}
>
<text
x="360"
y="72"
textAnchor="middle"
textLength="780"
lengthAdjust="spacingAndGlyphs"
fill="currentColor"
style={{
fontFamily:
"var(--font-archivo-black), var(--font-archivo), system-ui, sans-serif",
fontSize: "100px",
}}
>
ARTCRAFT
</text>
</svg>
</div>
{active && (
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-1.5 z-10 flex items-center justify-between px-6 md:px-10"
>
<p className="hud-label text-faint">Rendered with Seedance 2.5</p>
<p className="hud-label hidden text-faint sm:block">drag to scroll</p>
</div>
)}
</div>
);
}
// Perspective camera fitted so 1 world unit == 1 CSS pixel on the z=0 plane:
// raster coordinates, sim coordinates, and pointer coordinates all agree.
// wall layout and pointer coordinates all agree. FOV must match hero-wall's
// vignette projection.
function FittedCamera() {
const camera = useThree((s) => s.camera) as THREE.PerspectiveCamera;
const size = useThree((s) => s.size);
@@ -204,278 +187,13 @@ function FittedCamera() {
const dist = size.height / 2 / Math.tan(THREE.MathUtils.degToRad(fov / 2));
camera.fov = fov;
camera.position.set(0, 0, dist);
camera.near = Math.max(1, dist - 900);
camera.far = dist + 900;
camera.near = Math.max(1, dist - 1200);
camera.far = dist + 1200;
camera.updateProjectionMatrix();
}, [camera, size.height]);
return null;
}
// Studio reflections without any network fetch: three's procedural room,
// prefiltered once per WebGL context. This is what gives the dark "car
// paint" balls something to mirror.
function StudioEnvironment() {
const gl = useThree((s) => s.gl);
const scene = useThree((s) => s.scene);
useEffect(() => {
const pmrem = new THREE.PMREMGenerator(gl);
const env = pmrem.fromScene(new RoomEnvironment(), 0.04).texture;
scene.environment = env;
return () => {
scene.environment = null;
env.dispose();
pmrem.dispose();
};
}, [gl, scene]);
return null;
}
const HALO_VERTEX = /* glsl */ `
varying vec2 vUv;
void main() {
vUv = uv;
gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
}
`;
const HALO_FRAGMENT = /* glsl */ `
varying vec2 vUv;
uniform vec3 uColor;
uniform float uAlpha;
uniform float uFalloff;
void main() {
float d = distance(vUv, vec2(0.5)) * 2.0;
float a = pow(clamp(1.0 - d, 0.0, 1.0), uFalloff);
gl_FragColor = vec4(uColor, a * uAlpha);
}
`;
function BallField({
sample,
colors,
pointer,
}: {
sample: SampledWordmark;
colors: ThemeColors;
pointer: React.RefObject<PointerState>;
}) {
const meshRef = useRef<THREE.InstancedMesh>(null);
const lightRef = useRef<THREE.PointLight>(null);
const haloRef = useRef<THREE.Mesh>(null);
const ambientRef = useRef<THREE.AmbientLight>(null);
const keyRef = useRef<THREE.DirectionalLight>(null);
const counterRef = useRef<THREE.DirectionalLight>(null);
const fillRef = useRef<THREE.DirectionalLight>(null);
const lightPos = useRef(new THREE.Vector3(0, 120, 0));
const sim = useMemo(
() => new WordmarkSim(sample, physicsTuner.read().scatter),
[sample],
);
const dummy = useMemo(() => new THREE.Object3D(), []);
// Reactive so recoloring reruns when the accent share slider moves.
const accentShare = useTunerStore(
(s) => s.values["material.accentShare"] ?? 0.05,
);
const dark = useMemo(() => {
const c = new THREE.Color(colors.bg);
return c.r + c.g + c.b < 1.5;
}, [colors.bg]);
// The halo sits behind the text plane, so the balls eclipse it: bodies go
// to silhouette against the glow while their rims catch the back light.
const haloMaterial = useMemo(
() =>
new THREE.ShaderMaterial({
vertexShader: HALO_VERTEX,
fragmentShader: HALO_FRAGMENT,
uniforms: {
uColor: {
value: new THREE.Color(dark ? "#a8c4ff" : colors.accent),
},
uAlpha: { value: dark ? 0.5 : 0.14 },
uFalloff: { value: 2.6 },
},
transparent: true,
depthWrite: false,
blending: dark ? THREE.AdditiveBlending : THREE.NormalBlending,
}),
[dark, colors.accent],
);
useEffect(() => () => haloMaterial.dispose(), [haloMaterial]);
// Per-ball colors. Light theme: ink with slight tonal variety. Dark theme:
// near-black lacquer, so shape comes from reflections and the back rim
// instead of albedo. Both keep sparse brand-accent balls.
useEffect(() => {
const mesh = meshRef.current;
if (!mesh) return;
const bg = new THREE.Color(colors.bg);
const ink = new THREE.Color(colors.ink);
const accent = new THREE.Color(colors.accent);
const base = dark
? bg.clone().lerp(ink, 0.10)
: ink.clone();
const c = new THREE.Color();
const rand = seeded(4242);
for (let i = 0; i < sim.n; i++) {
if (rand() < accentShare) {
c.copy(accent);
if (dark) c.multiplyScalar(0.8);
} else {
c.copy(base).lerp(ink, rand() * (dark ? 0.08 : 0.16));
}
mesh.setColorAt(i, c);
}
if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true;
}, [colors, sim, dark, accentShare]);
useFrame((state, delta) => {
const mesh = meshRef.current;
if (!mesh) return;
const p = pointer.current;
// Live tuner values — read every frame so slider changes apply instantly.
const phys = physicsTuner.read();
const lt = lightTuner.read();
const mt = materialTuner.read();
const ht = haloTuner.read();
sim.step(delta, p.x, p.y, p.active, phys);
for (let i = 0; i < sim.n; i++) {
dummy.position.set(sim.x[i], sim.y[i], sim.z[i]);
dummy.scale.setScalar(sim.r[i]);
dummy.updateMatrix();
mesh.setMatrixAt(i, dummy.matrix);
}
mesh.instanceMatrix.needsUpdate = true;
// The cursor light (and its halo) chase the pointer; unattended they
// wander a slow Lissajous path so the relief never goes flat.
const t = state.clock.elapsedTime;
const targetX = p.active ? p.x : Math.sin(t * 0.22) * 320;
const targetY = p.active ? p.y : Math.cos(t * 0.17) * 130;
const k = 1 - Math.exp(-6 * delta);
lightPos.current.x += (targetX - lightPos.current.x) * k;
lightPos.current.y += (targetY - lightPos.current.y) * k;
lightRef.current?.position.set(
lightPos.current.x,
lightPos.current.y,
lt.lightZ,
);
haloRef.current?.position.set(
lightPos.current.x,
lightPos.current.y,
ht.z,
);
// At rest the word sits in near-eclipse; approaching with the cursor
// brings the light up to full.
const light = lightRef.current;
if (light) {
light.distance = lt.lightRange;
const full = dark ? lt.intensityDark : lt.intensityLight;
const target = p.active ? full : full * lt.idleDim;
light.intensity += (target - light.intensity) * k;
}
// Rig + material + halo bindings.
if (ambientRef.current) {
ambientRef.current.intensity = dark ? lt.ambientDark : lt.ambientLight;
}
if (keyRef.current) {
keyRef.current.intensity = dark ? lt.keyIntensity : lt.dirLight;
}
if (counterRef.current) counterRef.current.intensity = lt.counterIntensity;
if (fillRef.current) fillRef.current.intensity = lt.frontFill;
const mat = mesh.material as THREE.MeshPhysicalMaterial &
THREE.MeshStandardMaterial;
if (dark) {
mat.roughness = mt.roughnessDark;
mat.metalness = mt.metalnessDark;
mat.clearcoat = mt.clearcoat;
mat.clearcoatRoughness = mt.clearcoatRoughness;
} else {
mat.roughness = mt.roughnessLight;
mat.metalness = mt.metalnessLight;
mat.envMapIntensity = mt.envLight;
}
haloMaterial.uniforms.uAlpha.value = dark ? ht.alphaDark : ht.alphaLight;
haloMaterial.uniforms.uFalloff.value = ht.falloff;
haloRef.current?.scale.setScalar(ht.size / 720);
});
return (
<>
{/* No environment in dark mode: three applies scene.environment at
effectively full strength here regardless of envMapIntensity, and
any env flood washes the eclipse out. The rim lights are the only
broad sources; the cursor light does the revealing. */}
{!dark && <StudioEnvironment />}
{dark ? (
// Eclipse rig: almost no front light. A cool key from behind-above
// rims the top edges, a faint counter-rim catches the lower-right,
// and the cursor light does the actual revealing.
<>
<ambientLight ref={ambientRef} intensity={0.05} />
<directionalLight ref={keyRef} position={[-160, 340, -460]} intensity={2.2} color="#e9f1ff" />
<directionalLight ref={counterRef} position={[430, -180, -380]} intensity={0.7} color="#7fa8e8" />
<directionalLight ref={fillRef} position={[0, -60, 520]} intensity={0.06} />
</>
) : (
<>
<ambientLight ref={ambientRef} intensity={0.55} />
<directionalLight ref={keyRef} position={[-260, 340, 420]} intensity={0.75} />
<directionalLight position={[180, 260, -420]} intensity={0.5} color="#eef3ff" />
</>
)}
{/* Cursor light: physical inverse-square falloff, so nearby balls are
genuinely brighter than distant ones. Intensity is candela-scale
because the scene is measured in pixels. */}
<pointLight
ref={lightRef}
position={[0, 120, 130]}
intensity={dark ? 42000 : 30000}
decay={2}
distance={900}
color={dark ? "#d6e4ff" : "#ffffff"}
/>
<mesh ref={haloRef} position={[0, 120, -90]} material={haloMaterial}>
<planeGeometry args={[720, 720]} />
</mesh>
<instancedMesh
ref={meshRef}
args={[undefined, undefined, sim.n]}
frustumCulled={false}
>
<sphereGeometry args={[1, 24, 16]} />
{dark ? (
<meshPhysicalMaterial
roughness={0.42}
metalness={0.5}
clearcoat={0.9}
clearcoatRoughness={0.22}
envMapIntensity={0.05}
/>
) : (
<meshStandardMaterial
roughness={0.3}
metalness={0.15}
envMapIntensity={0.55}
/>
)}
</instancedMesh>
</>
);
}
class CanvasBoundary extends Component<
{ children: ReactNode },
{ failed: boolean }
@@ -490,14 +208,3 @@ class CanvasBoundary extends Component<
return this.state.failed ? null : this.props.children;
}
}
function seeded(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
@@ -9,15 +9,16 @@ import HeroViewport from "./hero-viewport";
export default function Hero() {
return (
<section className="relative">
{/* Full-bleed ball-field wordmark — the brand as an object you can
shove apart and watch reassemble. */}
{/* Full-bleed hero — crisp display-type wordmark over the render
wall: rows of Seedance takes forming one perspective wall that
drifts behind the word and answers the cursor. */}
<HeroWordmark />
<div className="relative mx-auto max-w-[1280px] border-x border-line">
<div className="flex items-center justify-between gap-4 border-y border-line px-6 py-3 md:px-10">
<p className="hud-label text-muted">Open-source AI studio</p>
<p className="hud-label text-accent-ink">
Now with Seedance 2.0, Nano Banana 2 &amp; more
Now with Seedance 2.5, Nano Banana 2 &amp; more
</p>
</div>
@@ -1,437 +0,0 @@
// Wordmark ball field: the brand name rasterized to a mask, then filled with
// small 3D balls by multi-pass dart throwing — big balls land first, smaller
// ones settle into the gaps, like a pile that collided into the letter shape
// (a faked impression, not a physics solve; it must run on laptops/phones).
// The balls separate under the cursor's push and rejoin on their home
// springs; a 3D collision pass keeps them from intersecting.
//
// Units are CSS pixels at the text's z=0 plane — the camera is fitted so one
// world unit projects to one pixel there, which keeps pointer math trivial.
export type Ball = {
x: number;
y: number;
z: number;
r: number;
};
export type SampledWordmark = {
balls: Ball[];
width: number;
height: number;
};
// All knobs of the arrangement — registered with the dev tuner in
// hero-tunables.ts. Size factors are multiples of `spacing`.
export type FormationParams = {
sizeMin: number;
sizeMax: number;
sizeNoiseScale: number; // px wavelength of the size clusters
sizeRoughen: number; // 0..1 per-ball white-noise contribution
gap: number; // px clearance between resting balls
depth: number; // slab thickness, × spacing
density: number; // dart-attempt multiplier
edgeMargin: number; // 0..1 — how far inside the stroke big balls stay
seed: number;
};
// ————————————————————————————— sampling —————————————————————————————
export function sampleWordmark(opts: {
text: string;
fontFamily: string;
fontWeight: number;
targetWidth: number;
/** Base unit in px — ball sizes and slab depth scale from this. */
spacing: number;
formation: FormationParams;
}): SampledWordmark {
const { text, fontFamily, fontWeight, targetWidth, spacing, formation } =
opts;
// Each glyph is measured and drawn individually with a small manual gap:
// no shaping ever runs across letters, so the font can't fuse tight pairs
// ("ft") into a ligature that would blob together once quantized to balls.
const canvas = document.createElement("canvas");
const ctx = canvas.getContext("2d", { willReadFrequently: true })!;
const glyphs = [...text];
const GAP_EM = 0.05;
ctx.font = `${fontWeight} 100px ${fontFamily}`;
const width100 = glyphs.reduce((w, g) => w + ctx.measureText(g).width, 0)
+ 100 * GAP_EM * (glyphs.length - 1);
const fontPx = (100 * targetWidth) / Math.max(1, width100);
const gapPx = fontPx * GAP_EM;
ctx.font = `${fontWeight} ${fontPx}px ${fontFamily}`;
let ascent = 0;
let descent = 0;
for (const g of glyphs) {
const m = ctx.measureText(g);
ascent = Math.max(ascent, m.actualBoundingBoxAscent);
descent = Math.max(descent, m.actualBoundingBoxDescent);
}
const pad = spacing;
const width = Math.ceil(targetWidth + pad * 2);
const height = Math.ceil(ascent + descent + pad * 2);
canvas.width = width;
canvas.height = height;
ctx.font = `${fontWeight} ${fontPx}px ${fontFamily}`;
ctx.fillStyle = "#fff";
let penX = pad;
for (const g of glyphs) {
ctx.fillText(g, penX, pad + ascent);
penX += ctx.measureText(g).width + gapPx;
}
const alpha = ctx.getImageData(0, 0, width, height).data;
const covered = (x: number, y: number) => {
if (x < 0 || y < 0 || x >= width || y >= height) return false;
return alpha[(y * width + x) * 4 + 3] > 127;
};
// Fast "random point inside the ink" sampling: collect covered pixels on a
// coarse stride once, then darts pick from that list with subpixel jitter.
const inkCells: number[] = [];
for (let y = 0; y < height; y += 2) {
for (let x = 0; x < width; x += 2) {
if (covered(x, y)) inkCells.push(y * width + x);
}
}
if (!inkCells.length) return { balls: [], width, height };
const rand = mulberry32(formation.seed);
const sizeNoise = makeValueNoise(formation.seed + 41);
const rMin = spacing * formation.sizeMin;
const rMax = spacing * formation.sizeMax;
const halfDepth = (spacing * formation.depth) / 2;
// Organic sizing: smooth spatial noise drifts the size in clusters across
// the word; white noise roughens it per ball.
const desiredRadius = (x: number, y: number) => {
const n = Math.pow(
sizeNoise(x / formation.sizeNoiseScale, y / formation.sizeNoiseScale),
1.15,
);
const roughened =
n * (1 - formation.sizeRoughen) + rand() * formation.sizeRoughen;
return rMin + (rMax - rMin) * roughened;
};
// A ball only counts as "inside the letter" if its center plus four cross
// points at ~edgeMargin·r are on ink — big balls stay inside the strokes,
// small ones may nestle right up to the edge, keeping letterforms crisp.
const insideStroke = (x: number, y: number, r: number) => {
const m = r * formation.edgeMargin;
return (
covered(Math.round(x), Math.round(y)) &&
covered(Math.round(x + m), Math.round(y)) &&
covered(Math.round(x - m), Math.round(y)) &&
covered(Math.round(x), Math.round(y + m)) &&
covered(Math.round(x), Math.round(y - m))
);
};
// Dart throwing with a 3D hash for overlap rejection. Three passes with a
// descending size floor: the big balls claim their spots first and the
// smaller ones settle into the leftover gaps — the arrangement reads like
// a settled pile instead of a grid.
const balls: Ball[] = [];
const cell = rMax * 2 + formation.gap;
const grid = new Map<number, number[]>();
const key = (x: number, y: number, z: number) =>
(Math.floor(x / cell) * 73856093) ^
(Math.floor(y / cell) * 19349663) ^
(Math.floor(z / cell) * 83492791);
const fits = (x: number, y: number, z: number, r: number) => {
for (let gz = -1; gz <= 1; gz++) {
for (let gy = -1; gy <= 1; gy++) {
for (let gx = -1; gx <= 1; gx++) {
const bucket = grid.get(
key(x + gx * cell, y + gy * cell, z + gz * cell),
);
if (!bucket) continue;
for (const j of bucket) {
const o = balls[j];
const dx = o.x - x;
const dy = o.y - y;
const dz = o.z - z;
const min = o.r + r + formation.gap;
if (dx * dx + dy * dy + dz * dz < min * min) return false;
}
}
}
}
return true;
};
const passes = [0.7, 0.35, 0]; // descending floor on the size range
const attemptsPerPass = Math.floor(
inkCells.length * 0.9 * formation.density,
);
for (const floor of passes) {
for (let a = 0; a < attemptsPerPass; a++) {
const c = inkCells[(rand() * inkCells.length) | 0];
const x = (c % width) + rand() * 2 - 1;
const y = Math.floor(c / width) + rand() * 2 - 1;
const z = (rand() * 2 - 1) * halfDepth;
let r = desiredRadius(x, y);
const passMin = rMin + (rMax - rMin) * floor;
if (r < passMin) r = passMin + (r - rMin) * 0.2;
if (!insideStroke(x, y, r)) continue;
if (!fits(x, y, z, r)) continue;
balls.push({ x, y, z, r });
const k = key(x, y, z);
const bucket = grid.get(k);
if (bucket) bucket.push(balls.length - 1);
else grid.set(k, [balls.length - 1]);
}
}
return { balls, width, height };
}
// Deterministic bilinear value noise, 0..1 — smooth variation across space.
function makeValueNoise(seed: number): (x: number, y: number) => number {
const hash = (ix: number, iy: number) => {
let h = ix * 374761393 + iy * 668265263 + seed * 2246822519;
h = (h ^ (h >>> 13)) * 1274126177;
return ((h ^ (h >>> 16)) >>> 0) / 4294967295;
};
const smooth = (t: number) => t * t * (3 - 2 * t);
return (x, y) => {
const ix = Math.floor(x);
const iy = Math.floor(y);
const fx = smooth(x - ix);
const fy = smooth(y - iy);
const a = hash(ix, iy);
const b = hash(ix + 1, iy);
const c = hash(ix, iy + 1);
const d = hash(ix + 1, iy + 1);
return a + (b - a) * fx + (c - a) * fy + (a - b - c + d) * fx * fy;
};
}
function mulberry32(seed: number): () => number {
let a = seed >>> 0;
return () => {
a |= 0;
a = (a + 0x6d2b79f5) | 0;
let t = Math.imul(a ^ (a >>> 15), 1 | a);
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t;
return ((t ^ (t >>> 14)) >>> 0) / 4294967296;
};
}
// ————————————————————————————— simulation —————————————————————————————
export type SimParams = {
spring: number; // pull toward home, 1/s²
damping: number; // exponential velocity decay, 1/s
pushRadius: number; // cursor influence radius, px
pushStrength: number; // px/s² at cursor center
swirl: number; // tangential (xy) fraction of the push
popZ: number; // fraction of the push biased toward the viewer
bobAmp: number; // idle breathing amplitude, px
};
export const DEFAULT_PARAMS: SimParams = {
spring: 46,
damping: 5.2,
pushRadius: 180,
pushStrength: 23000,
swirl: 0.55,
popZ: 0.6,
bobAmp: 1.4,
};
export class WordmarkSim {
readonly n: number;
readonly r: Float32Array;
readonly x: Float32Array;
readonly y: Float32Array;
readonly z: Float32Array;
private readonly hx: Float32Array;
private readonly hy: Float32Array;
private readonly hz: Float32Array;
private readonly vx: Float32Array;
private readonly vy: Float32Array;
private readonly vz: Float32Array;
private readonly phase: Float32Array;
private readonly cell: number;
private readonly grid = new Map<number, number[]>();
private time = 0;
// Balls are handed over in raster coordinates; homes are re-centered so the
// wordmark sits centered on the origin. Live positions start scattered in a
// loose shell so the mount plays an "assemble" — the balls fly in and join.
constructor(sample: SampledWordmark, scatter = 480) {
const { balls, width, height } = sample;
this.n = balls.length;
this.r = new Float32Array(this.n);
this.x = new Float32Array(this.n);
this.y = new Float32Array(this.n);
this.z = new Float32Array(this.n);
this.hx = new Float32Array(this.n);
this.hy = new Float32Array(this.n);
this.hz = new Float32Array(this.n);
this.vx = new Float32Array(this.n);
this.vy = new Float32Array(this.n);
this.vz = new Float32Array(this.n);
this.phase = new Float32Array(this.n);
let rMax = 1;
const rand = mulberry32(7177);
for (let i = 0; i < this.n; i++) {
const b = balls[i];
this.hx[i] = b.x - width / 2;
this.hy[i] = -(b.y - height / 2);
this.hz[i] = b.z;
const dir = randomUnit(rand);
const dist = scatter * (0.55 + rand() * 0.9);
this.x[i] = this.hx[i] + dir[0] * dist;
this.y[i] = this.hy[i] + dir[1] * dist * 0.7;
this.z[i] = this.hz[i] + dir[2] * dist * 0.55;
this.r[i] = b.r;
if (b.r > rMax) rMax = b.r;
this.phase[i] = (i * 2.399963) % (Math.PI * 2);
}
this.cell = rMax * 2 + 2;
}
step(
dt: number,
pointerX: number,
pointerY: number,
pointerActive: boolean,
params: SimParams = DEFAULT_PARAMS,
) {
const clamped = Math.min(dt, 1 / 30);
this.time += clamped;
const { spring, damping, pushRadius, pushStrength, swirl, popZ, bobAmp } =
params;
const decay = Math.exp(-damping * clamped);
const t = this.time;
for (let i = 0; i < this.n; i++) {
const bob = bobAmp;
const homeX = this.hx[i] + Math.sin(t * 0.9 + this.phase[i]) * bob;
const homeY = this.hy[i] + Math.cos(t * 0.7 + this.phase[i] * 1.7) * bob;
const homeZ = this.hz[i] + Math.sin(t * 0.8 + this.phase[i] * 2.3) * bob;
let fx = (homeX - this.x[i]) * spring;
let fy = (homeY - this.y[i]) * spring;
let fz = (homeZ - this.z[i]) * spring;
if (pointerActive) {
// The cursor lives on the z=0 plane; distance is measured in 3D so
// front-layer balls feel it sooner than back-layer ones.
const dx = this.x[i] - pointerX;
const dy = this.y[i] - pointerY;
const dz = this.z[i];
const dist = Math.sqrt(dx * dx + dy * dy + dz * dz);
if (dist < pushRadius) {
const f = 1 - dist / pushRadius;
const inv = dist > 1e-4 ? 1 / dist : 0;
const nx = dx * inv;
const ny = dy * inv;
const nz = dz * inv;
const s = f * f * pushStrength;
fx += (nx + -ny * swirl) * s;
fy += (ny + nx * swirl) * s;
// Radial z plus a toward-the-viewer bias: the crowd parts *and*
// pops out of the page instead of only sliding sideways.
fz += (nz + popZ) * s * 0.55;
}
}
this.vx[i] = this.vx[i] * decay + fx * clamped;
this.vy[i] = this.vy[i] * decay + fy * clamped;
this.vz[i] = this.vz[i] * decay + fz * clamped;
this.x[i] += this.vx[i] * clamped;
this.y[i] += this.vy[i] * clamped;
this.z[i] += this.vz[i] * clamped;
}
this.resolveCollisions();
}
// One positional-relaxation pass over a 3D spatial hash. The rest pose is
// already gap-free by construction; this keeps shoved balls from tunneling
// through neighbors, which is what makes them read as solid.
private resolveCollisions() {
const { grid, cell } = this;
grid.clear();
const key = (x: number, y: number, z: number) =>
(Math.floor(x / cell) * 73856093) ^
(Math.floor(y / cell) * 19349663) ^
(Math.floor(z / cell) * 83492791);
for (let i = 0; i < this.n; i++) {
const k = key(this.x[i], this.y[i], this.z[i]);
const bucket = grid.get(k);
if (bucket) bucket.push(i);
else grid.set(k, [i]);
}
for (let i = 0; i < this.n; i++) {
for (let gz = -1; gz <= 1; gz++) {
for (let gy = -1; gy <= 1; gy++) {
for (let gx = -1; gx <= 1; gx++) {
const bucket = grid.get(
key(
this.x[i] + gx * cell,
this.y[i] + gy * cell,
this.z[i] + gz * cell,
),
);
if (!bucket) continue;
for (const j of bucket) {
if (j <= i) continue;
const dx = this.x[j] - this.x[i];
const dy = this.y[j] - this.y[i];
const dz = this.z[j] - this.z[i];
const minDist = this.r[i] + this.r[j] + 0.4;
const d2 = dx * dx + dy * dy + dz * dz;
if (d2 >= minDist * minDist || d2 < 1e-6) continue;
const d = Math.sqrt(d2);
const push = (minDist - d) / d;
const px = dx * push * 0.5;
const py = dy * push * 0.5;
const pz = dz * push * 0.5;
this.x[i] -= px;
this.y[i] -= py;
this.z[i] -= pz;
this.x[j] += px;
this.y[j] += py;
this.z[j] += pz;
const nx = dx / d;
const ny = dy / d;
const nz = dz / d;
const rel =
(this.vx[j] - this.vx[i]) * nx +
(this.vy[j] - this.vy[i]) * ny +
(this.vz[j] - this.vz[i]) * nz;
if (rel < 0) {
const imp = rel * 0.5;
this.vx[i] += nx * imp;
this.vy[i] += ny * imp;
this.vz[i] += nz * imp;
this.vx[j] -= nx * imp;
this.vy[j] -= ny * imp;
this.vz[j] -= nz * imp;
}
}
}
}
}
}
}
}
function randomUnit(rand: () => number): [number, number, number] {
const theta = rand() * Math.PI * 2;
const cosPhi = rand() * 2 - 1;
const sinPhi = Math.sqrt(1 - cosPhi * cosPhi);
return [Math.cos(theta) * sinPhi, Math.sin(theta) * sinPhi, cosPhi];
}
@@ -25,7 +25,7 @@ type ButtonVariant =
type ButtonSize = "sm" | "md" | "lg";
const BASE_CLASSES =
"inline-flex w-fit items-center justify-center gap-2 rounded-none font-mono font-bold uppercase tracking-[0.15em] whitespace-nowrap transition-colors duration-150";
"inline-flex w-fit items-center justify-center gap-2 rounded-none font-mono font-bold uppercase tracking-[0.12em] whitespace-nowrap transition-colors duration-150";
const VARIANT_CLASSES: Record<ButtonVariant, string> = {
primary: "bg-invert-bg text-invert-fg transition-opacity hover:opacity-80",
@@ -43,7 +43,8 @@ const SIZE_CLASSES: Record<ButtonSize, string> = {
};
export interface ButtonProps
extends ButtonHTMLAttributes<HTMLButtonElement>,
extends
ButtonHTMLAttributes<HTMLButtonElement>,
Pick<AnchorHTMLAttributes<HTMLAnchorElement>, "target" | "rel"> {
variant?: ButtonVariant;
size?: ButtonSize;
@@ -86,10 +87,17 @@ export function Button({
);
if (href) {
const anchorProps = rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
const anchorProps =
rest as unknown as AnchorHTMLAttributes<HTMLAnchorElement>;
if (external || /^(https?:\/\/|mailto:)/.test(href)) {
return (
<a href={href} target={target} rel={rel} className={classes} {...anchorProps}>
<a
href={href}
target={target}
rel={rel}
className={classes}
{...anchorProps}
>
{content}
</a>
);
@@ -72,6 +72,30 @@ export const FEATURES: Feature[] = [
export const HERO_VIDEO_URL =
"https://pub-f7441936e5804042a1ea2bdc92e4dc71.r2.dev/website-commercial-2026.05.mp4";
// Clips for the hero's render wall. `aspect` is width/height of the panel
// plane (the texture is cover-fitted, so any source aspect works).
// Served through the /ext-media same-origin proxy (see next.config.ts) —
// WebGL video textures require CORS-clean sources. PLACEHOLDERS: these
// currently reuse the feature footage — swap each path for an exported
// Seedance 2.5 clip when the renders are ready; the wall adapts to any
// count and mix of aspects.
export type SeedanceClip = {
src: string;
aspect: number;
};
export const SEEDANCE_SHOWCASE: SeedanceClip[] = [
{ src: "/videos/1.mp4", aspect: 16 / 9 },
{ src: "/videos/2.mp4", aspect: 16 / 9 },
{ src: "/videos/3.mp4", aspect: 16 / 9 },
{ src: "/videos/4.mp4", aspect: 16 / 9 },
{ src: "/videos/5.mp4", aspect: 16 / 9 },
{ src: "/videos/6.mp4", aspect: 16 / 9 },
{ src: "/videos/7.mp4", aspect: 16 / 9 },
{ src: "/videos/8.mp4", aspect: 16 / 9 },
{ src: "/videos/9.mp4", aspect: 16 / 9 },
];
export const MADE_WITH_YOUTUBE_IDS = [
"HDdsKJl92H4",
"oqoCWdOwr2U",
@@ -79,7 +103,7 @@ export const MADE_WITH_YOUTUBE_IDS = [
];
export const TICKER_ITEMS = [
"Seedance 2.0",
"Seedance 2.5",
"Nano Banana 2",
"Image to Location",
"3D Compositing",
Loaded 100 of 156 files, more files were not shown because too many files have changed in this diff. Show more