Webapp stylings (#1916)

This commit is contained in:
BFlat
2026-09-01 10:25:21 +07:00
committed by GitHub
parent d2ad5820e0
commit 60bcfffc8d
156 changed files with 2512 additions and 2753 deletions
@@ -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",

Some files were not shown because too many files have changed in this diff Show More