Match desktop app styling to the webapp (#1963)

This commit is contained in:
BFlat
2026-09-28 12:29:54 +07:00
committed by GitHub
parent 1b27c31531
commit 90c7a8a118
184 changed files with 3150 additions and 2311 deletions
+1 -1
View File
@@ -31,7 +31,7 @@
<link rel="icon" type="image/x-icon" href="/favicon.ico" /> <link rel="icon" type="image/x-icon" href="/favicon.ico" />
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <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&display=swap" <link 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" /> rel="stylesheet" />
<!-- <link rel="stylesheet" href="/src/styles.css" /> --> <!-- <link rel="stylesheet" href="/src/styles.css" /> -->
</head> </head>
@@ -191,11 +191,11 @@ export function GlobalFileDropHandler() {
return ( return (
<> <>
{isDragging && modalType === null && ( {isDragging && modalType === null && (
<div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/40"> <div className="pointer-events-none fixed inset-0 z-[9999] flex items-center justify-center bg-black/70">
<div className="flex flex-col items-center gap-3 rounded-2xl border-2 border-dashed border-white/60 bg-black/30 px-16 py-12 text-white backdrop-blur-sm"> <div className="flex flex-col items-center gap-3 border border-dashed border-white/60 bg-ui-panel px-16 py-12 text-white">
<Maximize2Icon className="text-4xl opacity-80" /> <Maximize2Icon className="text-3xl opacity-60" />
<div className="text-xl font-semibold">Drop to Upload</div> <div className="font-display text-2xl tracking-tight">Drop to Upload</div>
<div className="text-sm opacity-60">GLB, PNG, JPG, JPEG, SPZ</div> <div className="hud-label text-white/50">GLB, PNG, JPG, JPEG, SPZ</div>
</div> </div>
</div> </div>
)} )}
@@ -37,6 +37,7 @@ import {
useCostBreakdownModalStore, useCostBreakdownModalStore,
} from "@storyteller/ui-pricing-modal"; } from "@storyteller/ui-pricing-modal";
import { HelpMenuButton } from "@storyteller/ui-help-menu"; import { HelpMenuButton } from "@storyteller/ui-help-menu";
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
type Mode = "image" | "text"; type Mode = "image" | "text";
type Variant = "object" | "world"; type Variant = "object" | "world";
@@ -550,9 +551,9 @@ export const ImageTo3DExperience = ({
role="button" role="button"
tabIndex={0} tabIndex={0}
className={twMerge( className={twMerge(
"flex flex-col items-center justify-center rounded-2xl border-[3px] border-dashed border-primary/40 bg-primary/5 text-center text-xs transition-all hover:border-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40", "flex flex-col items-center justify-center border border-dashed border-white/20 bg-white/[0.015] text-center text-xs transition-all hover:border-white/40 hover:bg-white/5 focus:border-white/60 focus:outline-none",
hasResults ? "aspect-square w-24" : "aspect-square w-48", hasResults ? "aspect-square w-24" : "aspect-square w-48",
dragActive && "border-primary bg-primary/10", dragActive && "border-white/60 bg-white/5",
)} )}
onDragEnter={(event) => { onDragEnter={(event) => {
event.preventDefault(); event.preventDefault();
@@ -582,11 +583,11 @@ export const ImageTo3DExperience = ({
<PlusIcon <PlusIcon
className={twMerge( className={twMerge(
"text-base-fg opacity-90 drop-shadow", "text-base-fg opacity-80",
hasResults ? "text-2xl" : "text-4xl", hasResults ? "text-2xl" : "text-4xl",
)} /> )} />
{!hasResults && ( {!hasResults && (
<span className="mt-3 text-[15px] font-medium text-base-fg opacity-60"> <span className="mt-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Add Image Add Image
</span> </span>
)} )}
@@ -599,7 +600,7 @@ export const ImageTo3DExperience = ({
{uploadedPreview ? ( {uploadedPreview ? (
<div <div
className={twMerge( className={twMerge(
"group relative cursor-pointer overflow-hidden rounded-2xl border-[3px] border-primary/40 bg-black/30 transition-all", "group relative cursor-pointer overflow-hidden border border-white/30 bg-black/30 transition-all hover:border-white/60",
hasResults ? "aspect-square w-24" : "aspect-square w-48", hasResults ? "aspect-square w-24" : "aspect-square w-48",
)} )}
onClick={() => !isUploading && fileInputRef.current?.click()} onClick={() => !isUploading && fileInputRef.current?.click()}
@@ -614,13 +615,13 @@ export const ImageTo3DExperience = ({
/> />
{isUploading && ( {isUploading && (
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-[3px] border-white/30 border-t-primary" /> <div className="h-8 w-8 animate-spin rounded-full border-[3px] border-white/30 border-t-white" />
</div> </div>
)} )}
{!isUploading && ( {!isUploading && (
<button <button
type="button" type="button"
className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-black/60 text-white opacity-0 transition-opacity group-hover:opacity-100" className="absolute right-2 top-2 flex h-6 w-6 items-center justify-center rounded-[3px] bg-black/60 text-white opacity-0 transition-all hover:bg-black group-hover:opacity-100"
onClick={(event) => { onClick={(event) => {
event.stopPropagation(); event.stopPropagation();
setUploadedPreview(null); setUploadedPreview(null);
@@ -646,7 +647,7 @@ export const ImageTo3DExperience = ({
ref={textareaRef} ref={textareaRef}
id={promptInputId} id={promptInputId}
rows={1} rows={1}
className="text-md max-h-[5.5em] w-full resize-none overflow-y-auto rounded bg-transparent pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none" className="text-md max-h-[5.5em] w-full resize-none overflow-y-auto rounded-[3px] bg-transparent pr-2 pt-1 text-base-fg placeholder-base-fg/60 focus:outline-none"
value={prompt} value={prompt}
placeholder="Describe any object you want to generate from scratch..." placeholder="Describe any object you want to generate from scratch..."
onChange={(event) => setPrompt(event.target.value)} onChange={(event) => setPrompt(event.target.value)}
@@ -692,8 +693,8 @@ export const ImageTo3DExperience = ({
role="button" role="button"
tabIndex={0} tabIndex={0}
className={twMerge( className={twMerge(
"flex h-32 w-32 flex-col items-center justify-center rounded-xl border-2 border-dashed border-primary/40 bg-primary/5 transition-all hover:border-primary hover:bg-primary/10 focus:outline-none focus:ring-2 focus:ring-primary/40", "flex h-32 w-32 flex-col items-center justify-center border border-dashed border-white/20 bg-white/[0.015] transition-all hover:border-white/40 hover:bg-white/5 focus:border-white/60 focus:outline-none",
dragActive && "border-primary bg-primary/10", dragActive && "border-white/60 bg-white/5",
)} )}
onDragEnter={(e) => { onDragEnter={(e) => {
e.preventDefault(); e.preventDefault();
@@ -731,8 +732,8 @@ export const ImageTo3DExperience = ({
> >
<PlusIcon <PlusIcon
className="text-3xl text-base-fg opacity-90 drop-shadow" /> className="text-3xl text-base-fg opacity-80" />
<span className="mt-2 text-sm font-medium text-base-fg/50"> <span className="mt-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Add Images Add Images
</span> </span>
</div> </div>
@@ -741,7 +742,7 @@ export const ImageTo3DExperience = ({
<textarea <textarea
ref={worldTextareaRef} ref={worldTextareaRef}
rows={2} rows={2}
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 outline-none ring-2 ring-transparent transition-all focus:ring-primary/80" className="w-full resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
style={{ maxHeight: "5em" }} style={{ maxHeight: "5em" }}
value={worldPrompt} value={worldPrompt}
placeholder="Describe your 3D world (optional)..." placeholder="Describe your 3D world (optional)..."
@@ -755,7 +756,7 @@ export const ImageTo3DExperience = ({
<div <div
key={img.id} key={img.id}
className={twMerge( className={twMerge(
"glass group relative aspect-square overflow-hidden rounded-lg border-2 border-white/30 transition-all", "group relative aspect-square overflow-hidden rounded-[3px] border border-white/30 bg-black/30 transition-all",
hasResults ? "w-10" : "w-auto", hasResults ? "w-10" : "w-auto",
img.isUploading img.isUploading
? "cursor-default" ? "cursor-default"
@@ -773,13 +774,13 @@ export const ImageTo3DExperience = ({
/> />
{img.isUploading && ( {img.isUploading && (
<div className="absolute inset-0 flex items-center justify-center bg-black/20"> <div className="absolute inset-0 flex items-center justify-center bg-black/20">
<div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-primary" /> <div className="h-4 w-4 animate-spin rounded-full border-2 border-white/30 border-t-white" />
</div> </div>
)} )}
{!img.isUploading && ( {!img.isUploading && (
<button <button
type="button" type="button"
className="absolute right-[2px] top-[2px] flex h-4 w-4 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white opacity-0 backdrop-blur-md transition-colors hover:bg-red/70 group-hover:opacity-100" className="absolute right-[2px] top-[2px] flex h-4 w-4 cursor-pointer items-center justify-center rounded-[3px] bg-black/60 text-white opacity-0 transition-colors hover:bg-red/70 group-hover:opacity-100"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
removeWorldImage(img.id); removeWorldImage(img.id);
@@ -821,7 +822,7 @@ export const ImageTo3DExperience = ({
<Button <Button
variant="action" variant="action"
className={twMerge( className={twMerge(
"aspect-square overflow-hidden rounded-lg border-2 border-dashed border-black/5 bg-ui-controls/40 transition-all hover:bg-ui-controls/60 dark:border-white/25", "aspect-square overflow-hidden rounded-[3px] border border-dashed border-black/15 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10 dark:border-white/25",
hasResults ? "w-10" : "w-14", hasResults ? "w-10" : "w-14",
)} )}
onClick={() => worldFileInputRef.current?.click()} onClick={() => worldFileInputRef.current?.click()}
@@ -837,7 +838,7 @@ export const ImageTo3DExperience = ({
); );
const imageCountRow = ( const imageCountRow = (
<div className="flex items-center justify-center gap-2 text-xs text-base-fg/40"> <div className="flex items-center justify-center gap-2 font-mono text-[11px] tabular-nums text-base-fg/40">
<span> <span>
{worldImages.length}/{MAX_WORLD_IMAGES} images {worldImages.length}/{MAX_WORLD_IMAGES} images
</span> </span>
@@ -866,7 +867,7 @@ export const ImageTo3DExperience = ({
<textarea <textarea
ref={worldTextareaRef} ref={worldTextareaRef}
rows={1} rows={1}
className="flex-1 resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2 text-sm text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60" className="flex-1 resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
value={worldPrompt} value={worldPrompt}
placeholder="Describe world (optional)..." placeholder="Describe world (optional)..."
onChange={(e) => setWorldPrompt(e.target.value)} onChange={(e) => setWorldPrompt(e.target.value)}
@@ -888,7 +889,7 @@ export const ImageTo3DExperience = ({
<textarea <textarea
ref={worldTextareaRef} ref={worldTextareaRef}
rows={2} rows={2}
className="w-full resize-none overflow-y-auto rounded-lg bg-white/5 px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 focus:outline-none focus:ring-2 focus:ring-primary/60" className="w-full resize-none overflow-y-auto rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2.5 text-base text-base-fg placeholder-base-fg/60 transition-colors focus:border-white/60 focus:outline-none"
style={{ maxHeight: "5em" }} style={{ maxHeight: "5em" }}
value={worldPrompt} value={worldPrompt}
placeholder="Describe your 3D world (optional)..." placeholder="Describe your 3D world (optional)..."
@@ -908,7 +909,7 @@ export const ImageTo3DExperience = ({
results.find((r) => r.id === selectedResultId) || results[0]; results.find((r) => r.id === selectedResultId) || results[0];
return ( return (
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full bg-ui-panel text-base-fg"> <div className="flex h-[calc(100vh-56px)] w-full bg-ui-background text-base-fg">
{backgroundImage && !hasResults && ( {backgroundImage && !hasResults && (
<> <>
<div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_49%,_rgb(var(--st-controls-rgb)_/_var(--st-gallery-vignette-alpha))_100%)]" /> <div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_49%,_rgb(var(--st-controls-rgb)_/_var(--st-gallery-vignette-alpha))_100%)]" />
@@ -929,9 +930,12 @@ export const ImageTo3DExperience = ({
<div className="relative z-10 h-full w-full p-8"> <div className="relative z-10 h-full w-full p-8">
{!hasResults && ( {!hasResults && (
<div className="pointer-events-none absolute left-0 top-[calc(50%-280px)] w-full text-center"> <div className="pointer-events-none absolute left-0 top-[calc(50%-280px)] flex w-full justify-center">
<h1 className="mb-3 text-7xl font-bold tracking-tight">{title}</h1> <CreateEmptyState
<p className="text-xl text-base-fg/70">{subtitle}</p> title={title}
subtitle={subtitle}
className="py-8 sm:py-8"
/>
</div> </div>
)} )}
@@ -945,7 +949,7 @@ export const ImageTo3DExperience = ({
style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }} style={{ height: `calc(100vh - ${bottomOffsetPx + 80}px)` }}
> >
{/* Left: Viewer */} {/* Left: Viewer */}
<div className="glass relative h-full overflow-hidden rounded-xl border border-ui-panel-border"> <div className="relative h-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<Viewer3D <Viewer3D
key={activeResult?.id} key={activeResult?.id}
modelUrl={activeResult?.modelUrl} modelUrl={activeResult?.modelUrl}
@@ -1003,13 +1007,15 @@ export const ImageTo3DExperience = ({
</div> </div>
{/* Right: History List */} {/* Right: History List */}
<div className="glass flex h-full flex-col overflow-hidden rounded-xl border border-ui-panel-border"> <div className="flex h-full flex-col overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="flex items-center justify-between p-4"> <div className="flex items-center justify-between border-b border-ui-panel-border px-4 py-3">
<h3 className="font-semibold text-base-fg/80">History</h3> <h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
History
</h3>
{results.length > 0 && ( {results.length > 0 && (
<button <button
onClick={resetResults} onClick={resetResults}
className="rounded-md bg-red/20 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/30" className="rounded-[3px] border border-red/40 bg-red/10 px-3 py-1 text-xs text-white/70 transition-colors hover:bg-red/20"
> >
Clear Session Clear Session
</button> </button>
@@ -1025,13 +1031,13 @@ export const ImageTo3DExperience = ({
key={result.id} key={result.id}
onClick={() => setSelectedResultId(result.id)} onClick={() => setSelectedResultId(result.id)}
className={twMerge( className={twMerge(
"group flex w-full items-center gap-3 rounded-xl border p-2 text-left transition-all hover:bg-ui-controls/40", "group flex w-full items-center gap-3 rounded-none border p-2 text-left transition-all hover:bg-white/5",
isSelected isSelected
? "border-primary/50 bg-primary/10" ? "border-white bg-white/10"
: "border-transparent bg-ui-controls/20", : "border-white/10 bg-transparent hover:border-white/30",
)} )}
> >
<div className="relative aspect-square h-14 w-14 shrink-0 overflow-hidden rounded-lg bg-black/30 ring-1 ring-white/5"> <div className="relative aspect-square h-14 w-14 shrink-0 overflow-hidden border border-white/10 bg-black/30">
{result.previewUrl ? ( {result.previewUrl ? (
<img <img
src={result.previewUrl} src={result.previewUrl}
@@ -1050,7 +1056,7 @@ export const ImageTo3DExperience = ({
)} )}
{isPending && ( {isPending && (
<div className="absolute inset-0 flex items-center justify-center bg-black/30"> <div className="absolute inset-0 flex items-center justify-center bg-black/30">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-primary" /> <div className="h-5 w-5 animate-spin rounded-full border-2 border-white/20 border-t-white" />
</div> </div>
)} )}
</div> </div>
@@ -1064,7 +1070,7 @@ export const ImageTo3DExperience = ({
Generating… Generating…
</span> </span>
) : ( ) : (
<span className="text-base-fg/45"> <span className="font-mono tabular-nums text-base-fg/45">
{formatTime(result.timestamp)} {formatTime(result.timestamp)}
</span> </span>
)} )}
@@ -1094,7 +1100,7 @@ export const ImageTo3DExperience = ({
<div ref={promptContentRef}> <div ref={promptContentRef}>
<div <div
className={twMerge( className={twMerge(
"glass w-full rounded-xl shadow-2xl ring-1 ring-white/10", "glass w-full",
hasResults ? "p-3" : "p-5", hasResults ? "p-3" : "p-5",
)} )}
> >
@@ -1139,7 +1145,7 @@ export const ImageTo3DExperience = ({
activeTab={activeMode} activeTab={activeMode}
onTabChange={(tabId) => setActiveMode(tabId as Mode)} onTabChange={(tabId) => setActiveMode(tabId as Mode)}
className="w-fit" className="w-fit"
indicatorClassName="bg-primary/25" indicatorClassName="bg-white/15"
/> />
</div> </div>
)} )}
@@ -1196,12 +1202,12 @@ export const ImageTo3DExperience = ({
{previewImage && ( {previewImage && (
<div <div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm" className="fixed inset-0 z-50 flex items-center justify-center bg-black/90"
onClick={() => setPreviewImage(null)} onClick={() => setPreviewImage(null)}
> >
<button <button
type="button" type="button"
className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-full bg-black/60 text-white transition-colors hover:bg-black/80" className="absolute right-4 top-4 flex h-8 w-8 items-center justify-center rounded-[3px] border border-white/15 bg-black/60 text-white transition-colors hover:border-white/30 hover:bg-black"
onClick={() => setPreviewImage(null)} onClick={() => setPreviewImage(null)}
> >
<XIcon className="h-4 w-4" /> <XIcon className="h-4 w-4" />
@@ -1209,7 +1215,7 @@ export const ImageTo3DExperience = ({
<img <img
src={previewImage} src={previewImage}
alt="Preview" alt="Preview"
className="max-h-[80vh] max-w-[80vw] rounded-xl object-contain shadow-2xl" className="max-h-[80vh] max-w-[80vw] border border-white/15 object-contain"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
/> />
</div> </div>
@@ -0,0 +1,56 @@
import { type ReactNode } from "react";
import { twMerge } from "tailwind-merge";
const CORNERS = [
"top-0 left-0",
"top-0 right-0",
"bottom-0 left-0",
"bottom-0 right-0",
];
interface CreateEmptyStateProps {
title: string;
subtitle: string;
children?: ReactNode;
compact?: boolean;
className?: string;
}
/** A quiet editorial section with a fine border and corner registration marks. */
export function CreateEmptyState({
title,
subtitle,
children,
compact = false,
className,
}: CreateEmptyStateProps) {
return (
<section
className={twMerge(
"create-empty-section relative w-full max-w-3xl px-6 text-left sm:px-10",
compact ? "py-8" : "py-10 sm:py-14",
className,
)}
>
<h1
className={twMerge(
"text-balance font-display leading-[1.05] tracking-[-0.035em] text-ui-ink",
compact ? "text-2xl" : "text-3xl md:text-5xl",
)}
>
{title}
</h1>
<p className="mt-4 max-w-xl text-pretty text-sm leading-relaxed text-white/60 sm:text-base">
{subtitle}
</p>
{children && <div className="mt-6">{children}</div>}
{CORNERS.map((corner) => (
<span
key={corner}
aria-hidden="true"
className={`frame-corner-mark ${corner}`}
/>
))}
</section>
);
}
@@ -1,15 +1,16 @@
import { useEffect, useState, type ReactNode } from "react"; import { useEffect, useState, type ReactNode } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { CreateEmptyState } from "./CreateEmptyState";
// Shared page chrome for the desktop Generate Image / Generate Video pages, // Shared page chrome for the desktop Generate Image / Generate Video pages,
// mirroring the webapp's CreateMediaPageShell: a centered hero over the // mirroring the webapp's CreateMediaPageShell: a centered empty-state panel,
// decorative background, and a scrollable generation feed (padded past the // and a scrollable generation feed (padded past the fixed promptbox) once
// fixed promptbox) once there is anything to show. // there is anything to show.
// //
// The hero + background behave like a splash screen: they stay up while the // The empty state (and optional background) behave like a splash screen:
// feed is still loading (pages remount on tab switches, so this covers the // they stay up while the feed is still loading (pages remount on tab
// reload) and fade out once content arrives. With a genuinely empty library // switches, so this covers the reload) and fade out once content arrives.
// they simply stay — they ARE the empty state. // With a genuinely empty library they simply stay.
const SPLASH_FADE_MS = 700; const SPLASH_FADE_MS = 700;
@@ -17,7 +18,8 @@ interface DesktopCreatePageShellProps {
hasContent: boolean; hasContent: boolean;
emptyStateTitle: string; emptyStateTitle: string;
emptyStateSubtitle: string; emptyStateSubtitle: string;
/** Decorative page background (fixed-position); fades out with the hero. */ /** Optional decorative page background (fixed-position); fades out with
* the empty state. */
background?: ReactNode; background?: ReactNode;
/** Height of the fixed promptbox area, used to pad the feed's bottom. */ /** Height of the fixed promptbox area, used to pad the feed's bottom. */
bottomOffset: number; bottomOffset: number;
@@ -55,11 +57,11 @@ export function DesktopCreatePageShell({
}, [hasContent, splashState]); }, [hasContent, splashState]);
return ( return (
<div className="flex h-[calc(100vh-56px)] w-full bg-ui-background"> <div className="flex h-[calc(100vh-56px)] w-full bg-ui-background text-white">
<div className="relative h-full w-full"> <div className="relative h-full w-full">
{hasContent && ( {hasContent && (
<div <div
className="h-full w-full overflow-y-auto pt-4" className="h-full w-full overflow-y-auto pt-0.5"
style={{ paddingBottom: bottomOffset + 24 }} style={{ paddingBottom: bottomOffset + 24 }}
> >
{listContent} {listContent}
@@ -73,15 +75,14 @@ export function DesktopCreatePageShell({
splashState === "fading" ? "opacity-0" : "opacity-100", splashState === "fading" ? "opacity-0" : "opacity-100",
)} )}
> >
<div className="flex h-full w-full flex-col items-center justify-center pb-52"> <div
<div className="relative z-20 flex flex-col items-center justify-center text-center drop-shadow-xl"> className="relative z-20 flex h-full w-full items-center justify-center px-6"
<h1 className="text-7xl font-bold text-base-fg"> style={{ paddingBottom: bottomOffset }}
{emptyStateTitle} >
</h1> <CreateEmptyState
<span className="pt-2 text-xl text-base-fg opacity-80"> title={emptyStateTitle}
{emptyStateSubtitle} subtitle={emptyStateSubtitle}
</span> />
</div>
</div> </div>
{background} {background}
</div> </div>
@@ -92,7 +93,7 @@ export function DesktopCreatePageShell({
{hasContent && ( {hasContent && (
<div <div
aria-hidden aria-hidden
className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-64 bg-gradient-to-t from-ui-background to-transparent" className="pointer-events-none absolute inset-x-0 bottom-0 z-10 h-48 bg-gradient-to-t from-ui-background to-transparent"
/> />
)} )}
@@ -180,7 +180,7 @@ function SelectionDownloadBar({
type="button" type="button"
onClick={handleDownload} onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0} 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 <DynamicIcon
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon} icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
@@ -226,8 +226,8 @@ function ItemActions({
const buttonClass = const buttonClass =
variant === "card" variant === "card"
? "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"
: "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";
const handleRecreate = useCallback( const handleRecreate = useCallback(
async (e: React.MouseEvent) => { async (e: React.MouseEvent) => {
@@ -305,7 +305,7 @@ function ItemActions({
aria-label="Make Video" aria-label="Make Video"
className={buttonClass} className={buttonClass}
> >
<VideoIcon className="text-sm" /> <VideoIcon className="text-sm" />
</button> </button>
</Tooltip> </Tooltip>
)} )}
@@ -106,8 +106,8 @@ export const UploadEntryCard = ({
return ( return (
<div <div
className={twMerge( className={twMerge(
"bg-ui-background/60 relative flex h-full flex-col items-center justify-center gap-8 overflow-hidden rounded-2xl border-2 border-dashed border-ui-panel-border p-10 text-center transition-colors", "relative flex h-full flex-col items-center justify-center gap-6 overflow-hidden rounded-[3px] border border-dashed border-white/15 bg-ui-background p-8 text-center transition-colors hover:border-white/40",
isDragActive && "border-primary/80 bg-primary/5", isDragActive && "border-white bg-white/10 hover:border-white",
disabled && "pointer-events-none opacity-60", disabled && "pointer-events-none opacity-60",
)} )}
onDragEnter={handleDragEnter} onDragEnter={handleDragEnter}
@@ -124,35 +124,28 @@ export const UploadEntryCard = ({
onChange={handleFileChange} onChange={handleFileChange}
disabled={disabled} disabled={disabled}
/> />
<div className="flex flex-col items-center gap-6"> <div className="flex flex-col items-center gap-4">
<div className="relative"> <div
<div className={twMerge(
className={twMerge( "flex h-14 w-14 items-center justify-center border",
"relative flex h-32 w-32 items-center justify-center rounded-2xl border-2 shadow-xl backdrop-blur-sm", accentBackgroundClass,
accentBackgroundClass, accentBorderClass,
accentBorderClass, )}
)} >
> <DynamicIcon icon={icon} className="text-xl text-white" />
<DynamicIcon
icon={icon}
className="text-5xl text-white drop-shadow-lg"
/>
</div>
</div> </div>
<div className="space-y-3"> <div>
<h3 className="text-4xl font-bold tracking-tight text-base-fg"> <h3 className="text-lg font-semibold text-base-fg">{title}</h3>
{title} <p className="mx-auto mt-1 max-w-md text-sm text-base-fg/60">
</h3>
<p className="mx-auto max-w-md text-base leading-relaxed text-base-fg/70">
{description} {description}
</p> </p>
</div> </div>
<div className="mt-4 flex flex-wrap justify-center gap-4"> <div className="mt-2 flex flex-wrap justify-center gap-2">
<Button <Button
variant="primary" variant="primary"
icon={primaryIcon} icon={primaryIcon}
onClick={handlePrimaryClick} onClick={handlePrimaryClick}
className="px-8 py-3 text-base font-semibold shadow-lg" className="px-3 py-2"
disabled={disabled} disabled={disabled}
> >
{primaryLabel} {primaryLabel}
@@ -162,7 +155,7 @@ export const UploadEntryCard = ({
variant="action" variant="action"
icon={secondaryIcon} icon={secondaryIcon}
onClick={onSecondaryClick} onClick={onSecondaryClick}
className="border-2 px-8 py-3 text-base font-semibold" className="px-3 py-2"
disabled={disabled} disabled={disabled}
> >
{secondaryLabel} {secondaryLabel}
@@ -173,7 +166,7 @@ export const UploadEntryCard = ({
variant="action" variant="action"
icon={tertiaryIcon} icon={tertiaryIcon}
onClick={onTertiaryClick} onClick={onTertiaryClick}
className="border-2 px-8 py-3 text-base font-semibold" className="px-3 py-2"
disabled={disabled} disabled={disabled}
> >
{tertiaryLabel} {tertiaryLabel}
@@ -24,7 +24,7 @@ export const ConfirmationModal = ({
onClose, onClose,
onOk, onOk,
okText = "OK", okText = "OK",
okColor = "bg-brand-success", okColor = "",
onCancel, onCancel,
cancelText = "Cancel", cancelText = "Cancel",
canHide, canHide,
@@ -52,7 +52,7 @@ export const ConfirmationModal = ({
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<div className="fixed inset-0 bg-black/40" /> <div className="fixed inset-0 bg-black/80" />
</Transition.Child> </Transition.Child>
<div className="fixed inset-0 flex w-screen items-center justify-center p-4"> <div className="fixed inset-0 flex w-screen items-center justify-center p-4">
<Transition.Child <Transition.Child
@@ -64,15 +64,15 @@ export const ConfirmationModal = ({
leaveFrom="opacity-100 scale-100" leaveFrom="opacity-100 scale-100"
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<Dialog.Panel className="w-full max-w-md transform overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-5 text-left align-middle shadow-xl transition-all"> <Dialog.Panel className="w-full max-w-md transform overflow-hidden border border-white/15 bg-ui-modal p-5 text-left align-middle transition-all">
<Dialog.Title <Dialog.Title
as="h4" as="h4"
className="mb-4 text-xl font-bold text-white" className="mb-4 font-display text-xl tracking-tight text-white"
> >
{title} {title}
</Dialog.Title> </Dialog.Title>
<div className="mt-2">{text}</div> <div className="mt-2 text-sm leading-relaxed text-white/70">{text}</div>
{canHide && ( {canHide && (
<div className="mt-2"> <div className="mt-2">
<Checkbox id="hide-dialog" label="Do not show this again" /> <Checkbox id="hide-dialog" label="Do not show this again" />
@@ -84,7 +84,7 @@ export const ConfirmationModal = ({
<Button <Button
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className="rounded-lg px-3 py-2" className="px-3 py-2"
variant="secondary" variant="secondary"
> >
{cancelText} {cancelText}
@@ -94,7 +94,7 @@ export const ConfirmationModal = ({
<Button <Button
type="button" type="button"
onClick={handleOk} onClick={handleOk}
className={[okColor, "rounded-lg px-3 py-2"].join(" ")} className={[okColor, "px-3 py-2"].join(" ").trim()}
> >
{okText} {okText}
</Button> </Button>
@@ -37,11 +37,11 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
transition={{ duration: 0.1, ease: "easeOut" }} transition={{ duration: 0.1, ease: "easeOut" }}
drag drag
dragMomentum={false} dragMomentum={false}
className="pointer-events-auto z-10 flex w-72 flex-col overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel shadow-2xl" className="pointer-events-auto z-10 flex w-72 flex-col overflow-hidden border border-white/15 bg-ui-panel"
> >
<div className="bg-ui-panel-header flex cursor-move select-none items-center justify-between border-b border-ui-panel-border px-4 py-3"> <div className="flex cursor-move select-none items-center justify-between border-b border-white/15 px-4 py-3">
<div className="flex items-center gap-2 text-xs font-bold uppercase tracking-wider text-base-fg"> <div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
<CoinsIcon className="text-yellow-400" /> <CoinsIcon className="text-primary" />
Cost Breakdown Cost Breakdown
</div> </div>
<button <button
@@ -53,10 +53,10 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
</div> </div>
<div className="space-y-4 bg-ui-panel p-4"> <div className="space-y-4 bg-ui-panel p-4">
<div className="border-ui-controls-border rounded-lg border bg-ui-controls/50 p-3"> <div className="border border-white/15 bg-white/5 p-3">
<div className="mb-1 flex items-center justify-between"> <div className="mb-1 flex items-center justify-between">
<span className="text-sm text-base-fg/80">Total Cost</span> <span className="text-sm text-base-fg/80">Total Cost</span>
<span className="text-lg font-bold text-base-fg"> <span className="text-lg font-semibold tabular-nums tracking-tight text-base-fg">
{credits} Credits {credits} Credits
</span> </span>
</div> </div>
@@ -66,7 +66,7 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
</div> </div>
<div className="space-y-1"> <div className="space-y-1">
<label className="text-xs font-medium text-base-fg/80"> <label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Currency Currency
</label> </label>
<Select <Select
@@ -77,7 +77,7 @@ export const CostModal = ({ credits = 1, onClose }: CostModalProps) => {
/> />
</div> </div>
<div className="mt-2 border-t border-ui-panel-border pt-3 text-center text-[10px] text-base-fg/40"> <div className="mt-2 border-t border-white/15 pt-3 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-base-fg/40">
1 Credit = $0.01 USD 1 Credit = $0.01 USD
</div> </div>
</div> </div>
@@ -84,7 +84,7 @@ export const UploadFilesMedia = ({
<h6 className="z-10 text-red">{assetFile.error}</h6> <h6 className="z-10 text-red">{assetFile.error}</h6>
)} )}
<div className="relative m-auto aspect-square w-full overflow-hidden rounded-lg bg-brand-secondary"> <div className="relative m-auto aspect-square w-full overflow-hidden border border-white/15 bg-ui-controls">
{!assetFile.value && ( {!assetFile.value && (
<h6 className="absolute left-0 top-1/2 -mt-5 w-full text-center"> <h6 className="absolute left-0 top-1/2 -mt-5 w-full text-center">
File Preview File Preview
@@ -85,7 +85,7 @@ export const UploadImageMedia = ({
<H6 className="z-10 text-red"> {assetFile.error} </H6> <H6 className="z-10 text-red"> {assetFile.error} </H6>
)} )}
<div className="relative m-auto aspect-square w-full overflow-hidden rounded-lg bg-brand-secondary"> <div className="relative m-auto aspect-square w-full overflow-hidden border border-white/15 bg-ui-controls">
{!assetFile.value && ( {!assetFile.value && (
<H6 className="absolute left-0 top-1/2 -mt-5 w-full text-center"> <H6 className="absolute left-0 top-1/2 -mt-5 w-full text-center">
File Preview File Preview
@@ -61,7 +61,7 @@ export function UploadImageMediaModal({
return ( return (
<> <>
<LoadingDots className="mb-1 bg-transparent" /> <LoadingDots className="mb-1 bg-transparent" />
<div className="w-100 text-center opacity-50">Uploading...</div> <div className="hud-label w-full text-center text-base-fg/50">Uploading...</div>
</> </>
); );
case UploaderStates.success: case UploaderStates.success:
@@ -59,7 +59,7 @@ export function UploadModalMedia({
return ( return (
<> <>
<LoadingDots className="mb-1 bg-transparent" /> <LoadingDots className="mb-1 bg-transparent" />
<div className="w-100 text-center opacity-50">Uploading...</div> <div className="hud-label w-full text-center text-base-fg/50">Uploading...</div>
</> </>
); );
case UploaderStates.success: case UploaderStates.success:
@@ -20,14 +20,14 @@ export function ErrorDialog() {
<Modal <Modal
title={errorDialogTitle} title={errorDialogTitle}
titleIcon={TriangleAlertIcon} titleIcon={TriangleAlertIcon}
titleIconClassName="text-brand-primary" titleIconClassName="text-red-400"
isOpen={showErrorDialog} isOpen={showErrorDialog}
onClose={() => setShowErrorDialog(false)} onClose={() => setShowErrorDialog(false)}
showClose={false} showClose={false}
> >
<div> <div>
{errorDialogMessage} {errorDialogMessage}
<div className="flex justify-end"> <div className="mt-4 flex justify-end">
<Button <Button
type="button" type="button"
onClick={() => setShowErrorDialog(false)} onClick={() => setShowErrorDialog(false)}
@@ -3,8 +3,10 @@ import { twMerge } from "tailwind-merge";
import { import {
useGenerateApps, useGenerateApps,
useEditApps, useEditApps,
getAppCardPalette,
getBadgeStyles, getBadgeStyles,
goToApp, goToApp,
type FullAppItem,
} from "~/config/appMenu"; } from "~/config/appMenu";
export const AppsQuickMenu = () => { export const AppsQuickMenu = () => {
@@ -12,101 +14,68 @@ export const AppsQuickMenu = () => {
const editApps = useEditApps(); const editApps = useEditApps();
return ( return (
<div className="grid w-[680px] grid-cols-2 gap-3"> <div className="grid w-[680px] grid-cols-2 gap-3">
<div> <AppsQuickMenuSection title="Create" apps={generateApps} />
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Create</h3> <AppsQuickMenuSection title="Edit" apps={editApps} />
<div className="space-y-0.5">
{generateApps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
disabled={!app.action}
className={twMerge(
"group flex w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors",
app.action
? "cursor-pointer hover:bg-base-fg/10"
: "cursor-default opacity-60",
)}
>
<div
className={twMerge(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
app.color || "bg-ui-panel",
)}
>
<DynamicIcon icon={app.icon} className="text-sm" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[13px] font-medium">
{app.label}
</div>
{app.badge && (
<span
className={twMerge(
"shrink-0 rounded-full px-2 py-1 text-[9px] font-bold uppercase tracking-wider",
getBadgeStyles(app.badge),
)}
>
{app.badge}
</span>
)}
</div>
<div className="truncate text-[11px] opacity-60">
{app.description}
</div>
</div>
</button>
))}
</div>
</div>
<div>
<h3 className="mb-2 px-2 text-xs font-semibold opacity-50">Edit</h3>
<div className="space-y-0.5">
{editApps.map((app) => (
<button
key={app.id}
onClick={() => goToApp(app.action)}
disabled={!app.action}
className={twMerge(
"group flex w-full items-center gap-3 rounded-md px-2 py-2 text-left transition-colors",
app.action
? "cursor-pointer hover:bg-base-fg/10"
: "cursor-default opacity-60",
)}
>
<div
className={twMerge(
"flex h-8 w-8 shrink-0 items-center justify-center rounded-md transition-colors",
app.color || "bg-ui-panel",
)}
>
<DynamicIcon icon={app.icon} className="text-sm" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[13px] font-medium">
{app.label}
</div>
{app.badge && (
<span
className={twMerge(
"shrink-0 rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wider",
getBadgeStyles(app.badge),
)}
>
{app.badge}
</span>
)}
</div>
<div className="truncate text-[11px] opacity-60">
{app.description}
</div>
</div>
</button>
))}
</div>
</div>
</div> </div>
); );
}; };
const AppsQuickMenuSection = ({
title,
apps,
}: {
title: string;
apps: FullAppItem[];
}) => (
<div>
<h3 className="hud-label mb-2 px-2 text-base-fg/50">{title}</h3>
<div className="space-y-0.5">
{apps.map((app) => {
const palette = getAppCardPalette(app.id);
return (
<button
key={app.id}
onClick={() => goToApp(app.action)}
disabled={!app.action}
className={twMerge(
"group flex w-full items-center gap-3 rounded-[3px] px-2 py-2 text-left transition-colors",
app.action
? "cursor-pointer hover:bg-white/[0.08]"
: "cursor-default opacity-60",
)}
>
<div
className={twMerge(
"flex h-8 w-8 shrink-0 items-center justify-center border transition-colors",
palette.iconBg,
palette.iconColor,
)}
>
<DynamicIcon icon={app.icon} className="text-sm" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<div className="truncate text-[13px] font-medium">
{app.label}
</div>
{app.badge && (
<span
className={twMerge(
"shrink-0 border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase leading-none tracking-[0.12em]",
getBadgeStyles(app.badge),
)}
>
{app.badge}
</span>
)}
</div>
<div className="truncate text-[11px] text-base-fg/60">
{app.description}
</div>
</div>
</button>
);
})}
</div>
</div>
);
@@ -1,7 +1,6 @@
import { useSignals } from "@preact/signals-react/runtime"; import { useSignals } from "@preact/signals-react/runtime";
import { AUTH_STATUS } from "~/enums"; import { AUTH_STATUS } from "~/enums";
import { authentication } from "~/signals"; import { authentication } from "~/signals";
import { Button } from "@storyteller/ui-button";
// import ProfileDropdown from "./ProfileDropdown"; // import ProfileDropdown from "./ProfileDropdown";
export const AuthButtons = ({ export const AuthButtons = ({
@@ -19,15 +18,14 @@ export const AuthButtons = ({
return ( return (
<> <>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<span className="text-white/20">|</span> <button
<Button className="flex h-8 items-center gap-1.5 rounded-[3px] bg-white px-3.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors hover:bg-white/80"
className="h-[38px]"
onClick={() => { onClick={() => {
loginSignUpPressed(); loginSignUpPressed();
}} }}
> >
Login / Sign Up Login / Sign Up
</Button> </button>
</div> </div>
</> </>
); );
@@ -56,20 +56,20 @@ export default function ProfileDropdown() {
> >
<Menu.Items <Menu.Items
static static
className="absolute right-[-5px] mt-2.5 w-36 overflow-hidden rounded-lg bg-brand-secondary shadow-lg focus:outline-none" className="absolute right-0 z-50 mt-2 w-48 origin-top-right overflow-hidden rounded-[3px] border border-white/15 bg-[#101014] focus:outline-none"
> >
<Menu.Item key={0}> <Menu.Item key={0}>
{({ active }) => ( {({ active }) => (
<a <a
className={twMerge( className={twMerge(
"duration-50 group flex w-full items-center gap-2 bg-action/60 px-3 py-[10px] text-start text-sm font-medium text-white transition-all", "group flex w-full items-center gap-2 px-4 py-2 text-start text-sm text-white/70 transition-colors",
active && "bg-action-500/80", active && "bg-white/10",
)} )}
href={profileUrl} href={profileUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
> >
<UserIcon /> <UserIcon className="text-[11px] text-white/50" />
My Profile My Profile
</a> </a>
)} )}
@@ -79,12 +79,15 @@ export default function ProfileDropdown() {
{({ active }) => ( {({ active }) => (
<button <button
className={twMerge( className={twMerge(
"duration-50 group flex w-full items-center gap-2 bg-action/60 px-3 py-[10px] text-start text-sm font-medium text-white transition-all", "group flex w-full items-center gap-2 px-4 py-2 text-start text-sm text-white/70 transition-colors",
active && "bg-action-500/80", active && "bg-white/10",
)} )}
onClick={() => option.onClick()} onClick={() => option.onClick()}
> >
<DynamicIcon icon={option.icon} /> <DynamicIcon
icon={option.icon}
className="text-[11px] text-white/50"
/>
{option.label} {option.label}
</button> </button>
)} )}
@@ -1,12 +1,11 @@
import { useState } from "react"; import { useState } from "react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { useSignals } from "@preact/signals-react/runtime"; import { useSignals } from "@preact/signals-react/runtime";
import { LoaderCircleIcon, PencilIcon } from "lucide-react"; import { ChevronRightIcon, LoaderCircleIcon, PencilIcon } from "lucide-react";
import { scene, signalScene, authentication } from "~/signals"; import { scene, signalScene, authentication } from "~/signals";
import { usePageSceneStore } from "@storyteller/ui-pagescene"; import { usePageSceneStore } from "@storyteller/ui-pagescene";
import { Input } from "@storyteller/ui-input"; import { Input } from "@storyteller/ui-input";
import { MediaFilesApi } from "~/Classes/ApiManager/MediaFilesApi"; import { MediaFilesApi } from "~/Classes/ApiManager/MediaFilesApi";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
interface Props { interface Props {
pageName: string; pageName: string;
@@ -95,27 +94,29 @@ export const SceneTitleInput = ({ pageName }: Props) => {
data-tauri-drag-region data-tauri-drag-region
> >
{!showInput && ( {!showInput && (
<div className="flex items-center gap-2.5" data-tauri-drag-region> <div className="flex items-center gap-1.5" data-tauri-drag-region>
{getCreatorIcon( <span
ModelCreator.ArtCraft, className="hud-label text-nowrap text-base-fg/70"
"h-5 w-5 icon-auto-contrast opacity-60", data-tauri-drag-region
)} >
<span className="text-nowrap opacity-60">{pageName}</span> {pageName}
<span className="opacity-60">/</span> </span>
<ChevronRightIcon
aria-hidden="true"
className="h-3 w-3 shrink-0 text-base-fg/50"
/>
{isSceneOwner ? ( {isSceneOwner ? (
<button <button
className="ml-0.5 rounded-md px-2 py-1 transition-all hover:cursor-text hover:bg-white/[8%]" className="flex 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"
onClick={handleShowInput} onClick={handleShowInput}
data-tauri-drag-region="false" data-tauri-drag-region="false"
> >
{scene.value.title || ""} <span className="truncate">{scene.value.title || ""}</span>
<PencilIcon <PencilIcon className="ml-2 shrink-0 text-sm opacity-50" />
className="ml-2 text-sm opacity-50" />
</button> </button>
) : ( ) : (
<div className="ml-0.5 rounded-md px-2 py-1"> <div className="max-w-[280px] truncate border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
{scene.value.title || ""} {scene.value.title || ""}
</div> </div>
)} )}
@@ -128,8 +129,8 @@ export const SceneTitleInput = ({ pageName }: Props) => {
disabled={scene.value.ownerToken !== userInfo.value?.user_token} disabled={scene.value.ownerToken !== userInfo.value?.user_token}
className="w-[420px]" className="w-[420px]"
inputClassName={twMerge( inputClassName={twMerge(
"text-center h-8 focus:outline-brand-primary", "text-center h-[34px] text-sm font-semibold focus:outline-white/60",
isSaving && "outline-brand-secondary", isSaving && "outline-white/30",
)} )}
isError={!isValid} isError={!isValid}
value={scene.value.title || ""} value={scene.value.title || ""}
@@ -152,7 +153,9 @@ export const SceneTitleInput = ({ pageName }: Props) => {
</div> </div>
)} )}
{isSaving && <LoaderCircleIcon className="animate-spin" />} {isSaving && (
<LoaderCircleIcon className="shrink-0 animate-spin text-sm opacity-70" />
)}
</div> </div>
); );
}; };
@@ -41,6 +41,7 @@ import { coverImageCache } from "~/pages/PageImageTo3DObject/ImageTo3DStore";
import { useCreditsState } from "@storyteller/credits"; import { useCreditsState } from "@storyteller/credits";
import { getMetaForTask, cleanupOldEntries } from "./taskEnqueueMeta"; import { getMetaForTask, cleanupOldEntries } from "./taskEnqueueMeta";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import dayjs from "dayjs";
type InProgressTask = { type InProgressTask = {
id: string; id: string;
@@ -159,7 +160,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
delay={300} delay={300}
> >
<button <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" aria-label="Copy prompt"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -195,7 +196,7 @@ const InProgressCard = ({
const hasRefImages = task.refImageUrls && task.refImageUrls.length > 0; const hasRefImages = task.refImageUrls && task.refImageUrls.length > 0;
const thumbnailContent = hasRefImages ? ( const thumbnailContent = hasRefImages ? (
<div className="relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded"> <div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden">
<img <img
src={task.refImageUrls![0]} src={task.refImageUrls![0]}
alt="Reference" alt="Reference"
@@ -212,13 +213,13 @@ const InProgressCard = ({
size="lg" /> size="lg" />
</div> </div>
{task.refImageUrls!.length > 1 && ( {task.refImageUrls!.length > 1 && (
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80"> <div className="absolute bottom-0.5 right-0.5 bg-black/60 px-1 text-[9px] text-white/80">
+{task.refImageUrls!.length - 1} +{task.refImageUrls!.length - 1}
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="flex h-[86px] w-[86px] 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 <LoaderCircleIcon
className="animate-spin text-base-fg/60" className="animate-spin text-base-fg/60"
@@ -227,7 +228,7 @@ const InProgressCard = ({
); );
return ( 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 items-center gap-2.5">
{thumbnailContent} {thumbnailContent}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -239,7 +240,7 @@ const InProgressCard = ({
content="Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages." content="Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages."
position="top" position="top"
strategy="fixed" strategy="fixed"
className="w-[200px] text-wrap bg-yellow-400/60 backdrop-blur-3xl" className="w-[200px] text-wrap bg-yellow-400/60"
zIndex={50} zIndex={50}
delay={100} delay={100}
> >
@@ -249,7 +250,7 @@ const InProgressCard = ({
</Tooltip> </Tooltip>
)} )}
</div> </div>
<div className="ml-2 shrink-0 text-[11px] tabular-nums text-base-fg/60"> <div className="ml-2 shrink-0 text-xs tabular-nums text-base-fg/60">
{progressPercent}% {progressPercent}%
</div> </div>
</div> </div>
@@ -259,9 +260,9 @@ const InProgressCard = ({
</div> </div>
)} )}
<div className="mt-1.5 flex items-center gap-2"> <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 <div
className="h-1.5 rounded bg-brand-primary-400" className="h-1.5 bg-white"
style={{ style={{
width: `${Math.max(0, Math.min(100, task.progress))}%`, width: `${Math.max(0, Math.min(100, task.progress))}%`,
}} }}
@@ -269,7 +270,7 @@ const InProgressCard = ({
</div> </div>
</div> </div>
{timeLabel && ( {timeLabel && (
<div className="mt-1 text-[11px] text-base-fg/50">{timeLabel}</div> <div className="mt-1 text-xs text-base-fg/50">{timeLabel}</div>
)} )}
{task.prompt && <PromptLine prompt={task.prompt} className="mt-0" />} {task.prompt && <PromptLine prompt={task.prompt} className="mt-0" />}
</div> </div>
@@ -277,7 +278,7 @@ const InProgressCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />} {task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && ( {onDismiss && (
<button <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" aria-label="Dismiss"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -304,12 +305,12 @@ const CompletedCard = ({
}) => { }) => {
return ( return (
<div <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} onClick={onClick}
role={onClick ? "button" : undefined} role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1} tabIndex={onClick ? 0 : -1}
> >
<div className="h-[86px] w-[86px] shrink-0 overflow-hidden rounded bg-ui-controls"> <div className="h-[72px] w-[72px] shrink-0 overflow-hidden bg-ui-controls">
{task.thumbnailUrl ? ( {task.thumbnailUrl ? (
<img <img
src={task.thumbnailUrl} src={task.thumbnailUrl}
@@ -342,7 +343,7 @@ const CompletedCard = ({
)} )}
{task.completedAt && ( {task.completedAt && (
<div className="text-xs text-base-fg opacity-60"> <div className="text-xs text-base-fg opacity-60">
{task.completedAt.toISOString()} {dayjs(task.completedAt).format("MMM D, h:mm A")}
</div> </div>
)} )}
{task.prompt && <PromptLine prompt={task.prompt} />} {task.prompt && <PromptLine prompt={task.prompt} />}
@@ -351,7 +352,7 @@ const CompletedCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />} {task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && ( {onDismiss && (
<button <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" aria-label="Dismiss"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -398,7 +399,7 @@ const FailedCard = ({
const hasRefImages = task.refImageUrls && task.refImageUrls.length > 0; const hasRefImages = task.refImageUrls && task.refImageUrls.length > 0;
const thumbnailContent = hasRefImages ? ( const thumbnailContent = hasRefImages ? (
<div className="relative h-[86px] w-[86px] shrink-0 overflow-hidden rounded"> <div className="relative h-[72px] w-[72px] shrink-0 overflow-hidden">
<img <img
src={task.refImageUrls![0]} src={task.refImageUrls![0]}
alt="Reference" alt="Reference"
@@ -415,13 +416,13 @@ const FailedCard = ({
size="lg" /> size="lg" />
</div> </div>
{task.refImageUrls!.length > 1 && ( {task.refImageUrls!.length > 1 && (
<div className="absolute bottom-0.5 right-0.5 rounded bg-black/60 px-1 text-[9px] text-white/80"> <div className="absolute bottom-0.5 right-0.5 bg-black/60 px-1 text-[9px] text-white/80">
+{task.refImageUrls!.length - 1} +{task.refImageUrls!.length - 1}
</div> </div>
)} )}
</div> </div>
) : ( ) : (
<div className="flex h-[86px] w-[86px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10"> <div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-red-500/10">
<CircleAlertIcon <CircleAlertIcon
className="text-red-400" className="text-red-400"
@@ -430,7 +431,7 @@ const FailedCard = ({
); );
return ( 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 items-center gap-2.5">
{thumbnailContent} {thumbnailContent}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -445,7 +446,7 @@ const FailedCard = ({
</div> </div>
)} )}
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden"> <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} {statusLabel}
</span> </span>
{task.failureReason && ( {task.failureReason && (
@@ -478,13 +479,18 @@ const FailedCard = ({
</div> </div>
)} )}
</div> </div>
{task.failedAt && (
<div className="mt-0.5 text-[11px] text-base-fg/40">
{dayjs(task.failedAt).format("MMM D, h:mm A")}
</div>
)}
{task.prompt && <PromptLine prompt={task.prompt} />} {task.prompt && <PromptLine prompt={task.prompt} />}
</div> </div>
<div className="ml-auto flex shrink-0 items-center gap-1"> <div className="ml-auto flex shrink-0 items-center gap-1">
{task.prompt && <CopyPromptButton prompt={task.prompt} />} {task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && ( {onDismiss && (
<button <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" aria-label="Dismiss"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
@@ -1002,23 +1008,21 @@ export const TaskQueue = () => {
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}> <Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
<div className="relative"> <div className="relative">
{badgeCount > 0 && ( {badgeCount > 0 && (
<div className="absolute -right-1 -top-1 z-20 flex h-[17px] w-[17px] items-center justify-center rounded-full bg-brand-primary-400 text-[13px] font-medium text-white"> <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} {badgeCount}
</div> </div>
)} )}
<PopoverMenu <PopoverMenu
mode="default" mode="default"
buttonClassName="h-[34px] w-[34px] !p-0 relative" 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-[400px] p-2 bg-ui-panel mt-2.5" panelClassName="w-[400px] p-2 bg-[#101014] border border-white/15 mt-2 rounded-[3px]"
position="bottom" position="bottom"
align="end" align="end"
triggerIcon={ triggerIcon={
inProgressCount > 0 ? ( inProgressCount > 0 ? (
<LoaderCircleIcon <LoaderCircleIcon className="animate-spin text-[11px]" />
className="animate-spin" />
) : ( ) : (
<ListChecksIcon /> <ListChecksIcon className="text-[11px]" />
) )
} }
onOpenChange={handleOpenChange} onOpenChange={handleOpenChange}
@@ -1037,7 +1041,7 @@ export const TaskQueue = () => {
<div> <div>
{inProgress.length > 0 && ( {inProgress.length > 0 && (
<div className="mb-4"> <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 In Progress
</div> </div>
{inProgress.map((t) => ( {inProgress.map((t) => (
@@ -1056,7 +1060,7 @@ export const TaskQueue = () => {
{failed.length > 0 && ( {failed.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<div className="mb-1 flex items-center justify-between px-1"> <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 Failed
</div> </div>
<button <button
@@ -1081,7 +1085,7 @@ export const TaskQueue = () => {
{completed.length > 0 && ( {completed.length > 0 && (
<div> <div>
<div className="mb-1 flex items-center justify-between px-1"> <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 Completed
</div> </div>
<button <button
@@ -1159,7 +1163,7 @@ export const TaskQueue = () => {
showClose={false} showClose={false}
> >
<div className="flex h-full flex-col"> <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"> <div className="flex items-center justify-between p-3">
<h2 className="text-lg font-semibold">Task Queue</h2> <h2 className="text-lg font-semibold">Task Queue</h2>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -1207,7 +1211,7 @@ export const TaskQueue = () => {
<div> <div>
{inProgress.length > 0 && ( {inProgress.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<div className="mb-2 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 In Progress
</div> </div>
{inProgress.map((t) => ( {inProgress.map((t) => (
@@ -1223,7 +1227,7 @@ export const TaskQueue = () => {
)} )}
{failed.length > 0 && ( {failed.length > 0 && (
<div className="mb-4"> <div className="mb-4">
<div className="mb-2 px-1 text-xs uppercase tracking-wide text-red-400/70"> <div className="mb-1 px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-red-400/70">
Failed Failed
</div> </div>
{failed.map((t) => ( {failed.map((t) => (
@@ -1237,7 +1241,7 @@ export const TaskQueue = () => {
)} )}
{completed.length > 0 && ( {completed.length > 0 && (
<div> <div>
<div className="mb-2 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">
Completed Completed
</div> </div>
{completed.map((t) => ( {completed.map((t) => (
@@ -1,8 +1,7 @@
import { CalculatorIcon, CheckIcon, CircleAlertIcon, CoinsIcon, GemIcon, HouseIcon, ImagesIcon, MinusIcon, PictureInPicture2Icon, SettingsIcon, SquareIcon, XIcon } from "lucide-react"; import { CalculatorIcon, CheckIcon, ChevronRightIcon, CircleAlertIcon, CoinsIcon, GemIcon, HouseIcon, ImagesIcon, MinusIcon, PictureInPicture2Icon, SettingsIcon, SquareIcon, XIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons"; import { DynamicIcon } from "@storyteller/icons";
import { signal } from "@preact/signals-react"; import { signal } from "@preact/signals-react";
import { useSignals } from "@preact/signals-react/runtime"; import { useSignals } from "@preact/signals-react/runtime";
import { getCreatorIcon, ModelCreator } from "@storyteller/model-list";
import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits"; import { useCreditsState, type CreditsIconStatus } from "@storyteller/credits";
import { gtagEvent } from "@storyteller/google-analytics"; import { gtagEvent } from "@storyteller/google-analytics";
import { ProviderBillingModal } from "@storyteller/provider-billing-modal"; import { ProviderBillingModal } from "@storyteller/provider-billing-modal";
@@ -42,7 +41,7 @@ import {
} from "@storyteller/ui-generation-list"; } from "@storyteller/ui-generation-list";
import { SettingsModal } from "@storyteller/ui-settings-modal"; import { SettingsModal } from "@storyteller/ui-settings-modal";
import { Tooltip } from "@storyteller/ui-tooltip"; import { Tooltip } from "@storyteller/ui-tooltip";
import { useEffect, useRef, useState } from "react"; import { Fragment, useEffect, useRef, useState } from "react";
import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu"; import { APP_DESCRIPTORS, goToApp } from "~/config/appMenu";
import { import {
applyMakeVideoFromImage, applyMakeVideoFromImage,
@@ -167,10 +166,10 @@ const CreditsCoinWithStatus = ({
} }
> >
<span className="relative inline-flex"> <span className="relative inline-flex">
<CoinsIcon className="text-primary" /> <CoinsIcon className="text-[11px] text-primary" />
{showBadge && ( {showBadge && (
<span <span
className={`absolute -right-1.5 -top-1.5 flex h-3 w-3 items-center justify-center rounded-full ring-1 ring-ui-background ${badgeColorClass}`} className={`absolute -right-1.5 -top-1.5 flex h-3 w-3 items-center justify-center ring-1 ring-ui-panel ${badgeColorClass}`}
> >
<DynamicIcon icon={badgeIconDef} className="text-[7px]" /> <DynamicIcon icon={badgeIconDef} className="text-[7px]" />
</span> </span>
@@ -347,40 +346,50 @@ export const TopBar = ({ pageName }: Props) => {
} }
}; };
const getPageTitle = (): string => { const getPageCrumbs = (): string[] => {
switch (tabStore.activeTabId) { switch (tabStore.activeTabId) {
case "2D": case "2D":
return "Canvas"; return ["Studio", "Canvas"];
case "3D": case "3D":
return "3D Editor"; return ["Studio", "3D Editor"];
case "IMAGE": case "IMAGE":
return "Create Image"; return ["Create", "Image"];
case "VIDEO": case "VIDEO":
return "Create Video"; return ["Create", "Video"];
case "AUDIO": case "AUDIO":
return "Create Audio"; return ["Create", "Audio"];
case "EDIT": case "EDIT":
return "Edit Image"; return ["Studio", "Edit Image"];
case "VIDEO_FRAME_EXTRACTOR": case "VIDEO_FRAME_EXTRACTOR":
return "Video Frame Extractor"; return ["Studio", "Frame Extractor"];
case "VIDEO_WATERMARK_REMOVAL": case "VIDEO_WATERMARK_REMOVAL":
return "Video Watermark Remover"; return ["Studio", "Video Watermark Remover"];
case "IMAGE_WATERMARK_REMOVAL": case "IMAGE_WATERMARK_REMOVAL":
return "Image Watermark Remover"; return ["Studio", "Image Watermark Remover"];
case "IMAGE_TO_3D_OBJECT": case "IMAGE_TO_3D_OBJECT":
return "Image to 3D Object"; return ["Create", "3D Object"];
case "IMAGE_TO_3D_WORLD": case "IMAGE_TO_3D_WORLD":
return "Image to 3D World"; return ["Create", "3D World"];
case "APPS": case "REMOVE_BACKGROUND":
return "ArtCraft"; return ["Studio", "Remove Background"];
case "ANGLES":
return ["Create", "Angles"];
case "STORYBOARD":
return ["Create", "Storyboard"];
case "BACKGROUND_CHANGE": case "BACKGROUND_CHANGE":
return "Background Change"; return ["Studio", "Background Change"];
case "VIDEO_EDITOR":
return ["Studio", "Edit Video"];
case "MOODBOARD":
return ["Studio", "Moodboard"];
case "APPS":
return ["Home"];
default: default:
return "Artcraft"; return ["ArtCraft"];
} }
}; };
const pageTitle = getPageTitle(); const pageCrumbs = getPageCrumbs();
const { toggleModal: toggleSubscriptionModal } = usePricingModalStore(); const { toggleModal: toggleSubscriptionModal } = usePricingModalStore();
const { toggleModal: toggleCreditsModal } = useCreditsModalStore(); const { toggleModal: toggleCreditsModal } = useCreditsModalStore();
@@ -418,7 +427,7 @@ export const TopBar = ({ pageName }: Props) => {
return ( return (
<> <>
<header <header
className="fixed left-0 top-0 z-[60] w-full border-b border-ui-panel-border bg-ui-background" className="fixed left-0 top-0 z-[60] w-full border-b border-ui-border bg-ui-panel"
data-tauri-drag-region data-tauri-drag-region
> >
<nav <nav
@@ -479,27 +488,47 @@ export const TopBar = ({ pageName }: Props) => {
</div> </div>
<div <div
className={`${tabStore.activeTabId === "3D" ? "no-drag" : ""} flex items-center justify-center gap-2 font-medium`} className={`${tabStore.activeTabId === "3D" ? "no-drag" : ""} flex min-w-0 items-center justify-center gap-2 font-medium`}
data-tauri-drag-region data-tauri-drag-region
> >
{tabStore.activeTabId === "3D" ? ( {tabStore.activeTabId === "3D" ? (
<SceneTitleInput pageName={pageName} /> <SceneTitleInput pageName={pageName} />
) : ( ) : (
<h1 <h1
className="flex items-center gap-2.5 text-base-fg" className="hud-label flex min-w-0 items-center gap-1.5 text-base-fg/70"
data-tauri-drag-region data-tauri-drag-region
> >
{getCreatorIcon( {pageCrumbs.map((crumb, i) => {
ModelCreator.ArtCraft, const isLast = i === pageCrumbs.length - 1;
"h-5 w-5 icon-auto-contrast opacity-50", return (
)} <Fragment key={`${crumb}-${i}`}>
{pageTitle} {i > 0 && (
<ChevronRightIcon
aria-hidden="true"
className="h-3 w-3 shrink-0 text-base-fg/50"
data-tauri-drag-region
/>
)}
<span
className={
isLast
? "truncate bg-ui-ink px-1.5 py-1 text-ui-panel"
: "truncate"
}
aria-current={isLast ? "page" : undefined}
data-tauri-drag-region
>
{crumb}
</span>
</Fragment>
);
})}
</h1> </h1>
)} )}
</div> </div>
<div className="flex justify-end gap-2" data-tauri-drag-region> <div className="flex justify-end gap-2" data-tauri-drag-region>
<div className="no-drag flex items-center gap-1.5"> <div className="no-drag flex items-center gap-1.5 sm:gap-2">
{(tabStore.activeTabId === "IMAGE" || {(tabStore.activeTabId === "IMAGE" ||
tabStore.activeTabId === "VIDEO") && <GallerySelectToggle />} tabStore.activeTabId === "VIDEO") && <GallerySelectToggle />}
{tabStore.activeTabId === "VIDEO" && <GalleryAutoplayToggle />} {tabStore.activeTabId === "VIDEO" && <GalleryAutoplayToggle />}
@@ -508,26 +537,26 @@ export const TopBar = ({ pageName }: Props) => {
tabStore.activeTabId === "AUDIO") && <GalleryViewToggle />} tabStore.activeTabId === "AUDIO") && <GalleryViewToggle />}
<PopoverMenu <PopoverMenu
position="bottom" position="bottom"
align="center" align="end"
triggerIcon={ triggerIcon={
<CreditsCoinWithStatus iconStatus={creditsIconStatus} /> <CreditsCoinWithStatus iconStatus={creditsIconStatus} />
} }
triggerLabel={ triggerLabel={
<span className="whitespace-nowrap text-sm font-medium"> <span className="whitespace-nowrap text-sm font-medium">
{sumTotalCredits} Credits {sumTotalCredits.toLocaleString()}
</span> </span>
} }
buttonClassName="h-[30px] px-2 ps-1.5 bg-transparent hover:bg-ui-controls/30 border-0 shadow-none" 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-3 bg-ui-panel border border-ui-panel-border text-base-fg" panelClassName="mt-2 bg-[#101014] border border-white/15 text-white rounded-[3px]"
> >
{(close) => ( {(close) => (
<div className="w-72 p-2.5 text-base-fg"> <div className="w-72 p-3 text-white">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<span className="flex items-center gap-1.5 text-sm font-medium text-base-fg/80"> <span className="text-sm font-medium text-white/70">
Your credit balance Your credit balance
</span> </span>
<button <button
className="text-sm font-medium text-primary-400 transition-all hover:text-primary-300" className="text-sm font-medium text-primary transition-colors hover:text-primary-300"
onClick={() => { onClick={() => {
close(); close();
toggleCreditsModal(); toggleCreditsModal();
@@ -536,15 +565,13 @@ export const TopBar = ({ pageName }: Props) => {
Buy credits Buy credits
</button> </button>
</div> </div>
<div className="flex items-center gap-2 text-4xl font-bold text-base-fg"> <div className="flex items-center gap-2 text-3xl font-semibold tracking-tight text-white">
<CoinsIcon <CoinsIcon className="text-xl text-primary" />
{sumTotalCredits.toLocaleString()}
className="text-2xl text-primary" />
{sumTotalCredits}
</div> </div>
<button <button
className="mt-2 flex items-center gap-1.5 text-xs text-base-fg/50 transition-colors hover:text-primary" className="mt-2 flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/50 transition-colors hover:text-white"
onClick={() => { onClick={() => {
close(); close();
useCostBreakdownModalStore.getState().openModal(); useCostBreakdownModalStore.getState().openModal();
@@ -556,7 +583,7 @@ export const TopBar = ({ pageName }: Props) => {
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Button <Button
variant="action" variant="secondary"
className="h-9 grow" className="h-9 grow"
onClick={() => { onClick={() => {
close(); close();
@@ -586,21 +613,35 @@ export const TopBar = ({ pageName }: Props) => {
variant="primary" variant="primary"
icon={GemIcon} icon={GemIcon}
onClick={toggleSubscriptionModal} onClick={toggleSubscriptionModal}
className="transition-all duration-300" className="h-8 px-3"
> >
Upgrade Upgrade
</Button> </Button>
)} )}
<UploadImagesButton className="h-[34px] w-[34px]" /> <TaskQueue />
<button
type="button"
onClick={handleOpenGalleryModal}
aria-label="My Library"
className="flex h-8 items-center gap-1.5 rounded-[3px] border border-white/15 px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80 transition-colors hover:border-white/30 hover:bg-white/10"
>
<ImagesIcon className="text-[11px]" />
<span className="hidden whitespace-nowrap xl:block">
My Library
</span>
</button>
<UploadImagesButton />
<Tooltip content="Settings" position="bottom" delay={300}> <Tooltip content="Settings" position="bottom" delay={300}>
<Button <Button
aria-label="Settings" aria-label="Settings"
variant="secondary" variant="secondary"
icon={SettingsIcon} icon={SettingsIcon}
iconClassName="h-5 w-5 shrink-0" iconClassName="h-4 w-4 shrink-0"
className="h-[34px] w-[34px] p-0" className="h-8 w-8 border-white/15 bg-transparent p-0 text-white/80 hover:border-white/30 hover:bg-white/10"
onClick={() => { onClick={() => {
setSettingsSection("general"); setSettingsSection("general");
setIsSettingsModalOpen(true); setIsSettingsModalOpen(true);
@@ -608,19 +649,6 @@ export const TopBar = ({ pageName }: Props) => {
}} }}
/> />
</Tooltip> </Tooltip>
<Button
variant="secondary"
icon={ImagesIcon}
onClick={handleOpenGalleryModal}
>
<span className="hidden whitespace-nowrap text-base-fg xl:block">
My Library
</span>
</Button>
{/* <Activity /> */}
<TaskQueue />
</div> </div>
<div className="no-drag"> <div className="no-drag">
@@ -631,14 +659,14 @@ export const TopBar = ({ pageName }: Props) => {
<div className="no-drag flex items-center"> <div className="no-drag flex items-center">
<Button <Button
variant="secondary" variant="secondary"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent text-base-fg opacity-70 shadow-none hover:bg-ui-controls/20 hover:opacity-100" className="h-[32px] w-[44px] rounded-none border-0 bg-transparent p-0 text-base-fg opacity-70 shadow-none hover:bg-white/10 hover:opacity-100"
onClick={minimize} onClick={minimize}
> >
<MinusIcon className="text-xs" /> <MinusIcon className="text-xs" />
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent text-base-fg opacity-70 shadow-none hover:bg-ui-controls/20 hover:opacity-100" className="h-[32px] w-[44px] rounded-none border-0 bg-transparent p-0 text-base-fg opacity-70 shadow-none hover:bg-white/10 hover:opacity-100"
onClick={toggleMaximize} onClick={toggleMaximize}
> >
<DynamicIcon <DynamicIcon
@@ -648,10 +676,10 @@ export const TopBar = ({ pageName }: Props) => {
</Button> </Button>
<Button <Button
variant="secondary" variant="secondary"
className="h-[32px] w-[44px] rounded-none border-0 bg-transparent text-base-fg opacity-70 shadow-none hover:bg-red/10 hover:text-red" className="h-[32px] w-[44px] rounded-none border-0 bg-transparent p-0 text-base-fg opacity-70 shadow-none hover:bg-red hover:text-white hover:opacity-100"
onClick={close} onClick={close}
> >
<XIcon className="text-lg" /> <XIcon className="text-lg" />
</Button> </Button>
</div> </div>
)} )}
@@ -18,8 +18,11 @@ export const UploadImagesButton = ({ className }: Props) => {
<Button <Button
variant="secondary" variant="secondary"
icon={UploadIcon} icon={UploadIcon}
iconClassName="h-5 w-5 shrink-0" iconClassName="h-4 w-4 shrink-0"
className={twMerge("h-[38px] w-[38px] p-0", className)} className={twMerge(
"h-8 w-8 rounded-[3px] border border-white/15 bg-transparent p-0 text-white/80 shadow-none hover:border-white/30 hover:bg-white/10",
className,
)}
onClick={() => setIsOpen(true)} onClick={() => setIsOpen(true)}
/> />
</Tooltip> </Tooltip>
@@ -83,98 +83,122 @@ export interface FullAppItem {
} }
// Per-app card palette for the Apps page (webapp-home-style cards). Derived // Per-app card palette for the Apps page (webapp-home-style cards). Derived
// from each app's Tailwind color family so the icon box, its border, and the // from each app's Tailwind color family so the icon tile, its border, and the
// hover gradient all share a hue. Keyed by app id; falls back to a neutral // hover state all share a hue. Keyed by app id; falls back to a neutral tone
// slate when an id is missing. // when an id is missing.
interface AppCardPalette { interface AppCardPalette {
/** Hover gradient overlay, e.g. "from-blue-500/20 to-blue-500/0". */ /** Hover tint for the card, e.g. "from-blue-500/20 to-blue-500/0". Legacy. */
accent: string; accent: string;
/** Icon box background + border, e.g. "bg-blue-500/20 border-blue-400/30". */ /** Icon tile background + border, e.g. "bg-blue-500/20 border-blue-400/40". */
iconBg: string; iconBg: string;
/** Icon glyph color, e.g. "text-blue-300". */ /** Icon glyph color, e.g. "text-blue-300". */
iconColor: string; iconColor: string;
/** Card hover border + background, e.g. "hover:border-blue-400/60 hover:bg-blue-500/10". */
hoverStyle: string;
} }
const APP_CARD_PALETTES: Record<string, AppCardPalette> = { const APP_CARD_PALETTES: Record<string, AppCardPalette> = {
"text-to-image": { "text-to-image": {
accent: "from-blue-500/20 to-blue-500/0", accent: "from-blue-500/20 to-blue-500/0",
iconBg: "bg-blue-500/20 border-blue-400/30", iconBg: "bg-blue-500/20 border-blue-400/40",
iconColor: "text-blue-300", iconColor: "text-blue-300",
hoverStyle: "hover:border-blue-400/60 hover:bg-blue-500/10",
}, },
"image-to-video": { "image-to-video": {
accent: "from-amber-500/20 to-amber-500/0", accent: "from-purple-500/20 to-purple-500/0",
iconBg: "bg-amber-500/20 border-amber-400/30", iconBg: "bg-purple-500/20 border-purple-400/40",
iconColor: "text-amber-300", iconColor: "text-purple-300",
hoverStyle: "hover:border-purple-400/60 hover:bg-purple-500/10",
}, },
"create-audio": { "create-audio": {
accent: "from-pink-500/20 to-pink-500/0", accent: "from-pink-500/20 to-pink-500/0",
iconBg: "bg-pink-500/20 border-pink-400/30", iconBg: "bg-pink-500/20 border-pink-400/40",
iconColor: "text-pink-300", iconColor: "text-pink-300",
hoverStyle: "hover:border-pink-400/60 hover:bg-pink-500/10",
}, },
"image-to-3d-object": { "image-to-3d-object": {
accent: "from-emerald-500/20 to-emerald-500/0", accent: "from-cyan-500/20 to-cyan-500/0",
iconBg: "bg-emerald-500/20 border-emerald-400/30", iconBg: "bg-cyan-500/20 border-cyan-400/40",
iconColor: "text-emerald-300", iconColor: "text-cyan-300",
hoverStyle: "hover:border-cyan-400/60 hover:bg-cyan-500/10",
}, },
"image-to-3d-world": { "image-to-3d-world": {
accent: "from-teal-500/20 to-teal-500/0",
iconBg: "bg-teal-500/20 border-teal-400/40",
iconColor: "text-teal-300",
hoverStyle: "hover:border-teal-400/60 hover:bg-teal-500/10",
},
"3d-editor": {
accent: "from-amber-500/20 to-amber-500/0",
iconBg: "bg-amber-500/20 border-amber-400/40",
iconColor: "text-amber-300",
hoverStyle: "hover:border-amber-400/60 hover:bg-amber-500/10",
},
"background-change": {
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/20 border-emerald-400/40",
iconColor: "text-emerald-300",
hoverStyle: "hover:border-emerald-400/60 hover:bg-emerald-500/10",
},
"video-editor": {
accent: "from-rose-500/20 to-rose-500/0",
iconBg: "bg-rose-500/20 border-rose-400/40",
iconColor: "text-rose-300",
hoverStyle: "hover:border-rose-400/60 hover:bg-rose-500/10",
},
moodboard: {
accent: "from-indigo-500/20 to-indigo-500/0",
iconBg: "bg-indigo-500/20 border-indigo-400/40",
iconColor: "text-indigo-300",
hoverStyle: "hover:border-indigo-400/60 hover:bg-indigo-500/10",
},
"video-frame-extractor": {
accent: "from-orange-500/20 to-orange-500/0",
iconBg: "bg-orange-500/20 border-orange-400/40",
iconColor: "text-orange-300",
hoverStyle: "hover:border-orange-400/60 hover:bg-orange-500/10",
},
"edit-image": {
accent: "from-sky-500/20 to-sky-500/0", accent: "from-sky-500/20 to-sky-500/0",
iconBg: "bg-sky-500/20 border-sky-400/30", iconBg: "bg-sky-500/20 border-sky-400/40",
iconColor: "text-sky-300", iconColor: "text-sky-300",
}, hoverStyle: "hover:border-sky-400/60 hover:bg-sky-500/10",
angles: {
accent: "from-lime-500/20 to-lime-500/0",
iconBg: "bg-lime-500/20 border-lime-400/30",
iconColor: "text-lime-300",
},
storyboard: {
accent: "from-fuchsia-500/20 to-fuchsia-500/0",
iconBg: "bg-fuchsia-500/20 border-fuchsia-400/30",
iconColor: "text-fuchsia-300",
}, },
"2d-canvas": { "2d-canvas": {
accent: "from-sky-500/20 to-sky-500/0", accent: "from-sky-500/20 to-sky-500/0",
iconBg: "bg-sky-500/20 border-sky-400/30", iconBg: "bg-sky-500/20 border-sky-400/40",
iconColor: "text-sky-300", iconColor: "text-sky-300",
}, hoverStyle: "hover:border-sky-400/60 hover:bg-sky-500/10",
"3d-editor": {
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/20 border-emerald-400/30",
iconColor: "text-emerald-300",
},
"edit-image": {
accent: "from-purple-500/20 to-purple-500/0",
iconBg: "bg-purple-500/20 border-purple-400/30",
iconColor: "text-purple-300",
}, },
"remove-background": { "remove-background": {
accent: "from-violet-500/20 to-violet-500/0", accent: "from-violet-500/20 to-violet-500/0",
iconBg: "bg-violet-500/20 border-violet-400/30", iconBg: "bg-violet-500/20 border-violet-400/40",
iconColor: "text-violet-300", iconColor: "text-violet-300",
hoverStyle: "hover:border-violet-400/60 hover:bg-violet-500/10",
}, },
"video-frame-extractor": { angles: {
accent: "from-rose-500/20 to-rose-500/0", accent: "from-lime-500/20 to-lime-500/0",
iconBg: "bg-rose-500/20 border-rose-400/30", iconBg: "bg-lime-500/20 border-lime-400/40",
iconColor: "text-rose-300", iconColor: "text-lime-300",
hoverStyle: "hover:border-lime-400/60 hover:bg-lime-500/10",
}, },
"background-change": { storyboard: {
accent: "from-orange-500/20 to-orange-500/0", accent: "from-fuchsia-500/20 to-fuchsia-500/0",
iconBg: "bg-orange-500/20 border-orange-400/30", iconBg: "bg-fuchsia-500/20 border-fuchsia-400/40",
iconColor: "text-orange-300", iconColor: "text-fuchsia-300",
}, hoverStyle: "hover:border-fuchsia-400/60 hover:bg-fuchsia-500/10",
"video-editor": {
accent: "from-teal-500/20 to-teal-500/0",
iconBg: "bg-teal-500/20 border-teal-400/30",
iconColor: "text-teal-300",
}, },
"video-watermark-removal": { "video-watermark-removal": {
accent: "from-cyan-500/20 to-cyan-500/0", accent: "from-yellow-500/20 to-yellow-500/0",
iconBg: "bg-cyan-500/20 border-cyan-400/30", iconBg: "bg-yellow-500/20 border-yellow-400/40",
iconColor: "text-cyan-300", iconColor: "text-yellow-300",
hoverStyle: "hover:border-yellow-400/60 hover:bg-yellow-500/10",
}, },
"image-watermark-removal": { "image-watermark-removal": {
accent: "from-indigo-500/20 to-indigo-500/0", accent: "from-red-500/20 to-red-500/0",
iconBg: "bg-indigo-500/20 border-indigo-400/30", iconBg: "bg-red-500/20 border-red-400/40",
iconColor: "text-indigo-300", iconColor: "text-red-300",
hoverStyle: "hover:border-red-400/60 hover:bg-red-500/10",
}, },
}; };
@@ -182,6 +206,7 @@ const FALLBACK_APP_CARD_PALETTE: AppCardPalette = {
accent: "from-white/10 to-white/0", accent: "from-white/10 to-white/0",
iconBg: "bg-ui-controls border-ui-controls-border", iconBg: "bg-ui-controls border-ui-controls-border",
iconColor: "text-base-fg", iconColor: "text-base-fg",
hoverStyle: "hover:border-white/30 hover:bg-white/10",
}; };
export const getAppCardPalette = (id: string): AppCardPalette => export const getAppCardPalette = (id: string): AppCardPalette =>
@@ -387,13 +412,13 @@ export const useEditApps = (): FullAppItem[] => {
export const getBadgeStyles = (badge?: string) => { export const getBadgeStyles = (badge?: string) => {
switch (badge) { switch (badge) {
case "NEW": case "NEW":
return "bg-teal-600 text-white"; return "border-purple-400/40 text-purple-300";
case "BEST": case "BEST":
return "bg-primary text-white"; return "border-primary/30 text-ui-accent-ink";
case "SOON": case "SOON":
return "bg-gray-600 text-white"; return "border-white/20 text-base-fg/50";
case "BETA": case "BETA":
return "bg-amber-500 text-white"; return "border-amber-400/40 text-amber-300";
default: default:
return ""; return "";
} }
@@ -481,7 +481,7 @@ export const Angles = () => {
/* ──── Upload screen ──── */ /* ──── Upload screen ──── */
<div className="flex flex-1 items-center justify-center p-8 lg:p-16"> <div className="flex flex-1 items-center justify-center p-8 lg:p-16">
<div className="w-full max-w-5xl"> <div className="w-full max-w-5xl">
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-panel shadow-lg"> <div className="relative aspect-video overflow-hidden border border-ui-panel-border bg-ui-panel">
<UploadEntryCard <UploadEntryCard
icon={CrosshairIcon} icon={CrosshairIcon}
title="Angles" title="Angles"
@@ -497,7 +497,7 @@ export const Angles = () => {
disabled={isLoadingImage} disabled={isLoadingImage}
/> />
{isLoadingImage && ( {isLoadingImage && (
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm"> <div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center">
<LoadingSpinner className="h-12 w-12" /> <LoadingSpinner className="h-12 w-12" />
</div> </div>
)} )}
@@ -518,7 +518,7 @@ export const Angles = () => {
{/* Full-bleed image display */} {/* Full-bleed image display */}
<div className="flex h-full w-full items-center justify-center px-16 pb-56 pt-16"> <div className="flex h-full w-full items-center justify-center px-16 pb-56 pt-16">
<div <div
className="relative overflow-hidden rounded-xl shadow-lg" className="relative overflow-hidden"
style={imageContainerStyle} style={imageContainerStyle}
> >
{activeAngle ? ( {activeAngle ? (
@@ -583,12 +583,12 @@ export const Angles = () => {
{/* ── Floating bottom angle controls ── */} {/* ── Floating bottom angle controls ── */}
<div className="absolute bottom-4 left-1/2 z-10 w-[calc(100%-32px)] max-w-[860px] -translate-x-1/2"> <div className="absolute bottom-4 left-1/2 z-10 w-[calc(100%-32px)] max-w-[860px] -translate-x-1/2">
<div className="glass flex items-center gap-4 rounded-xl px-4 lg:gap-5 lg:px-5"> <div className="glass flex items-center gap-4 rounded-none px-4 lg:gap-5 lg:px-5">
{/* Orbit sphere */} {/* Orbit sphere */}
<div className="relative shrink-0 px-5 py-5"> <div className="relative shrink-0 px-5 py-5">
<button <button
onClick={() => handleTiltStep(1)} onClick={() => handleTiltStep(1)}
className="absolute left-1/2 top-0 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80" className="absolute left-1/2 top-0 z-10 -translate-x-1/2 rounded-[3px] p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
> >
<ChevronUpIcon <ChevronUpIcon
@@ -596,7 +596,7 @@ export const Angles = () => {
</button> </button>
<button <button
onClick={() => handleTiltStep(-1)} onClick={() => handleTiltStep(-1)}
className="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80" className="absolute bottom-0 left-1/2 z-10 -translate-x-1/2 rounded-[3px] p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
> >
<ChevronDownIcon <ChevronDownIcon
@@ -604,7 +604,7 @@ export const Angles = () => {
</button> </button>
<button <button
onClick={() => handleRotationStep(-1)} onClick={() => handleRotationStep(-1)}
className="absolute left-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80" className="absolute left-0 top-1/2 z-10 -translate-y-1/2 rounded-[3px] p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
> >
<ChevronLeftIcon <ChevronLeftIcon
@@ -612,7 +612,7 @@ export const Angles = () => {
</button> </button>
<button <button
onClick={() => handleRotationStep(1)} onClick={() => handleRotationStep(1)}
className="absolute right-0 top-1/2 z-10 -translate-y-1/2 p-1 text-base-fg/40 transition-colors hover:text-base-fg/80" className="absolute right-0 top-1/2 z-10 -translate-y-1/2 rounded-[3px] p-1 text-base-fg/40 transition-colors hover:text-base-fg/80"
> >
<ChevronRightIcon <ChevronRightIcon
@@ -632,7 +632,7 @@ export const Angles = () => {
{/* Sliders group */} {/* Sliders group */}
<div className="flex min-w-0 flex-1 flex-col gap-2"> <div className="flex min-w-0 flex-1 flex-col gap-2">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg"> <span className="w-16 shrink-0 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Rotation Rotation
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -645,13 +645,13 @@ export const Angles = () => {
suffix="°" suffix="°"
/> />
</div> </div>
<span className="w-10 shrink-0 text-left text-xs tabular-nums text-base-fg/70"> <span className="w-10 shrink-0 text-left font-mono text-xs tabular-nums text-base-fg/70">
{rotationToDisplay(angleConfig.rotation)}° {rotationToDisplay(angleConfig.rotation)}°
</span> </span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg"> <span className="w-16 shrink-0 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Tilt Tilt
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -664,13 +664,13 @@ export const Angles = () => {
suffix="°" suffix="°"
/> />
</div> </div>
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70"> <span className="w-9 shrink-0 text-left font-mono text-xs tabular-nums text-base-fg/70">
{angleConfig.tilt}° {angleConfig.tilt}°
</span> </span>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="w-14 shrink-0 text-xs font-medium text-base-fg"> <span className="w-16 shrink-0 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Zoom Zoom
</span> </span>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
@@ -682,7 +682,7 @@ export const Angles = () => {
onChange={handleZoomSlider} onChange={handleZoomSlider}
/> />
</div> </div>
<span className="w-9 shrink-0 text-left text-xs tabular-nums text-base-fg/70"> <span className="w-9 shrink-0 text-left font-mono text-xs tabular-nums text-base-fg/70">
{angleConfig.zoom} {angleConfig.zoom}
</span> </span>
</div> </div>
@@ -23,28 +23,35 @@ export const AppsIndexPage = () => {
// flush against the right edge; horizontal padding lives on the inner // flush against the right edge; horizontal padding lives on the inner
// wrapper instead. // wrapper instead.
<div className="fixed inset-0 overflow-y-auto bg-ui-background pt-[56px] text-base-fg"> <div className="fixed inset-0 overflow-y-auto bg-ui-background pt-[56px] text-base-fg">
<main className="mx-auto max-w-6xl px-6 pb-20 pt-12 sm:pt-20"> <main className="mx-auto w-full max-w-6xl px-5 pb-20 pt-8 sm:px-8 sm:pt-12">
<h1 className="mx-auto text-center text-4xl font-semibold tracking-tight sm:text-6xl"> <p className="hud-label mb-4 text-ui-accent-ink">
What will you <span className="text-primary">craft</span> today? Your creative workspace
</p>
<h1 className="max-w-3xl font-display text-3xl leading-tight tracking-tight sm:text-5xl">
What will you <span className="text-ui-accent-ink">craft</span> today?
</h1> </h1>
<p className="mx-auto mt-3 max-w-xl text-center text-lg text-base-fg/55"> <p className="mt-4 max-w-xl text-sm leading-relaxed text-base-fg/70 sm:text-base">
Every tool in your toolbox - pick one and start creating. Start with an idea. Choose a tool to bring it to life.
</p> </p>
<div className="mt-12 space-y-10"> {categories.map((category, index) => (
{categories.map((category) => ( <section
<section key={category.title}> key={category.title}
<h2 className="mb-4 text-sm font-semibold text-base-fg/85"> className={twMerge(
{category.title} "border-t border-ui-border pt-6",
</h2> index === 0 ? "mt-8 sm:mt-10" : "mt-10",
<div className="grid auto-rows-min gap-3 sm:grid-cols-2 lg:grid-cols-3"> )}
{category.apps.map((app) => ( >
<AppCard key={app.id} app={app} /> <h2 className="hud-label mb-4 text-base-fg/70">
))} {category.title}
</div> </h2>
</section> <div className="grid auto-rows-fr gap-3 md:grid-cols-2 xl:grid-cols-3">
))} {category.apps.map((app) => (
</div> <AppCard key={app.id} app={app} />
))}
</div>
</section>
))}
</main> </main>
</div> </div>
); );
@@ -59,27 +66,14 @@ function AppCard({ app }: { app: FullAppItem }) {
onClick={() => goToApp(app.action)} onClick={() => goToApp(app.action)}
disabled={!enabled} disabled={!enabled}
className={twMerge( className={twMerge(
"group relative block overflow-hidden rounded-2xl bg-ui-controls/50 p-5 text-left transition-colors", "group relative flex h-full rounded-[3px] border border-ui-border bg-white/5 p-5 text-left transition-colors duration-150 focus-visible:border-primary",
enabled enabled ? twMerge("cursor-pointer", palette.hoverStyle) : "cursor-default opacity-60",
? "cursor-pointer hover:bg-ui-controls"
: "cursor-default opacity-60",
)} )}
> >
{/* Hover accent — gradient wash matching the app's hue. */} <div className="relative flex w-full items-start gap-4">
{enabled && (
<div
aria-hidden
className={twMerge(
"pointer-events-none absolute -inset-px rounded-2xl bg-gradient-to-br opacity-0 transition-opacity group-hover:opacity-100",
palette.accent,
)}
/>
)}
<div className="relative flex items-start gap-4">
<div <div
className={twMerge( className={twMerge(
"flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border", "flex h-10 w-10 shrink-0 items-center justify-center border",
palette.iconBg, palette.iconBg,
palette.iconColor, palette.iconColor,
)} )}
@@ -88,15 +82,17 @@ function AppCard({ app }: { app: FullAppItem }) {
</div> </div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="flex min-w-0 items-center gap-2"> <div className="flex min-w-0 flex-wrap items-center gap-2">
<h3 className="truncate text-base font-semibold text-base-fg"> <h3 className="text-base font-bold uppercase tracking-tight text-ui-ink">
{app.label} {app.label}
</h3> </h3>
{app.badge && ( {app.badge && (
<span <span
className={twMerge( className={twMerge(
"shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-bold uppercase leading-none tracking-wider", "shrink-0 border px-1.5 py-1 font-mono text-[9px] font-medium leading-none tracking-wider",
getBadgeStyles(app.badge), app.badge === "SOON"
? getBadgeStyles(app.badge)
: "border-primary/30 text-ui-accent-ink",
)} )}
> >
{app.badge} {app.badge}
@@ -104,12 +100,12 @@ function AppCard({ app }: { app: FullAppItem }) {
)} )}
</div> </div>
{enabled && ( {enabled && (
<ArrowRightIcon <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" />
className="text-sm text-base-fg/40 transition-all group-hover:translate-x-0.5 group-hover:text-base-fg/70" /> </span>
)} )}
</div> </div>
<p className="mt-1 text-sm leading-snug text-base-fg/55"> <p className="mt-2 text-sm leading-relaxed text-base-fg/70">
{app.description} {app.description}
</p> </p>
</div> </div>
@@ -8,7 +8,6 @@ import {
FilterMediaClasses, FilterMediaClasses,
} from "@storyteller/api"; } from "@storyteller/api";
import { useOmniGenAudioModels, useAudioCostEstimate } from "@storyteller/omni-gen"; import { useOmniGenAudioModels, useAudioCostEstimate } from "@storyteller/omni-gen";
import BackgroundGallery from "../PageImage/BackgroundGallery";
import { import {
galleryModalLightboxImage, galleryModalLightboxImage,
galleryModalLightboxMediaId, galleryModalLightboxMediaId,
@@ -149,7 +148,6 @@ const CreateAudio = () => {
hasContent={hasContent} hasContent={hasContent}
emptyStateTitle="Create Audio" emptyStateTitle="Create Audio"
emptyStateSubtitle="Describe a song, a sound, or a sample" emptyStateSubtitle="Describe a song, a sound, or a sample"
background={<BackgroundGallery />}
bottomOffset={promptHeight + 40} bottomOffset={promptHeight + 40}
listContent={ listContent={
<DesktopGenerationGallery <DesktopGenerationGallery
@@ -1,11 +1,9 @@
import { useCallback, useEffect, useRef, useState } from "react"; import { useCallback, useEffect, useRef, useState } from "react";
import { SparklesIcon } from "lucide-react";
import { toast } from "@storyteller/ui-toaster"; import { toast } from "@storyteller/ui-toaster";
import { UploadImageMedia, UploadVideoMedia } from "@storyteller/api"; import { UploadImageMedia, UploadVideoMedia } from "@storyteller/api";
import { import {
PromptBoxVFX, PromptBoxVFX,
SubTabStrip, SubTabStrip,
TruchetPattern,
VFXResultCard, VFXResultCard,
VFXShowcaseView, VFXShowcaseView,
newIdempotencyToken, newIdempotencyToken,
@@ -14,6 +12,7 @@ import {
VFX_NOT_AVAILABLE_ERROR, VFX_NOT_AVAILABLE_ERROR,
VFX_SHOWCASE, VFX_SHOWCASE,
} from "@storyteller/ui-vfx"; } from "@storyteller/ui-vfx";
import { CreateEmptyState } from "~/components/generation-feed/CreateEmptyState";
export const PageBackgroundChange = () => { export const PageBackgroundChange = () => {
const subTab = useVFXStore((s) => s.subTab); const subTab = useVFXStore((s) => s.subTab);
@@ -113,34 +112,17 @@ export const PageBackgroundChange = () => {
className="relative flex flex-1 items-center justify-center px-6" className="relative flex flex-1 items-center justify-center px-6"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }} style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
> >
<div <CreateEmptyState
aria-hidden title="No background changes yet"
className="pointer-events-none absolute inset-0 z-0" subtitle="Upload a source video and a reference image, then optionally add a prompt."
style={{ />
maskImage:
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
WebkitMaskImage:
"radial-gradient(ellipse 60% 60% at 50% 45%, black 30%, transparent 85%)",
}}
>
<TruchetPattern
intensity={0.8}
className="absolute inset-0 h-full w-full"
/>
</div>
<div className="relative z-10">
<EmptyState
title="No background changes yet"
subtitle="Upload a source video and a reference image, then optionally add a prompt."
/>
</div>
</div> </div>
) : ( ) : (
<div <div
className="flex-1 overflow-y-auto" className="flex-1 overflow-y-auto"
style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }} style={{ paddingBottom: Math.max(promptBoxHeight + 36, 240) }}
> >
<div className="flex flex-col gap-10 px-6 pt-6"> <div className="flex flex-col items-center gap-10 px-6 pt-6">
{history.map((r) => ( {history.map((r) => (
<VFXResultCard <VFXResultCard
key={r.id} key={r.id}
@@ -155,6 +137,7 @@ export const PageBackgroundChange = () => {
failureReason: r.failureReason, failureReason: r.failureReason,
}} }}
onDismiss={() => dismissResult(r.id)} onDismiss={() => dismissResult(r.id)}
className="w-[min(960px,calc(100vw-32px))]"
/> />
))} ))}
</div> </div>
@@ -163,7 +146,7 @@ export const PageBackgroundChange = () => {
<div <div
aria-hidden aria-hidden
className="via-ui-background/85 pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background to-transparent" className="pointer-events-none fixed bottom-0 left-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background via-ui-background/85 to-transparent"
/> />
<div <div
@@ -184,19 +167,4 @@ export const PageBackgroundChange = () => {
); );
}; };
interface EmptyStateProps {
title: string;
subtitle: string;
}
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-base-fg/5 ring-1 ring-base-fg/10">
<SparklesIcon className="text-2xl" />
</div>
<h3 className="text-2xl font-bold text-base-fg">{title}</h3>
<p className="max-w-xs text-sm text-base-fg/60">{subtitle}</p>
</div>
);
export default PageBackgroundChange; export default PageBackgroundChange;
@@ -1,17 +0,0 @@
.stretch-background-image {
background-image:
linear-gradient(
0deg,
rgb(var(--st-photo-tint-rgb) / var(--st-photo-tint-alpha)),
rgb(var(--st-photo-tint-rgb) / var(--st-photo-tint-alpha))
),
url("/resources/images/digital-mountains.webp");
background-repeat: no-repeat;
background-size: cover;
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
filter: grayscale(var(--st-photo-grayscale));
}
@@ -1,16 +0,0 @@
import "./Background.css";
export default function GalleryBackground() {
return (
<>
<div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_49%,_rgb(var(--st-controls-rgb)_/_var(--st-gallery-vignette-alpha))_100%)]" />
<div className="fixed inset-0 z-0 overflow-hidden">
<div
className={`stretch-background-image h-full w-full opacity-30 transition-opacity duration-1000`}
>
<div className="absolute inset-0 grid grid-cols-5 gap-6 px-6 opacity-35" />
</div>
</div>
</>
);
}
@@ -2,7 +2,6 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { JobContextType } from "@storyteller/common"; import { JobContextType } from "@storyteller/common";
import { PromptBoxImage } from "@storyteller/ui-promptbox"; import { PromptBoxImage } from "@storyteller/ui-promptbox";
import { UploadImageMedia, FilterMediaClasses } from "@storyteller/api"; import { UploadImageMedia, FilterMediaClasses } from "@storyteller/api";
import BackgroundGallery from "./BackgroundGallery";
import { import {
useTextToImagePageModelList, useTextToImagePageModelList,
ModelPage, ModelPage,
@@ -158,7 +157,6 @@ const TextToImage = ({ imageMediaId, imageUrl }: TextToImageProps) => {
hasContent={hasContent} hasContent={hasContent}
emptyStateTitle="Create Image" emptyStateTitle="Create Image"
emptyStateSubtitle="Add a prompt, then generate" emptyStateSubtitle="Add a prompt, then generate"
background={<BackgroundGallery />}
bottomOffset={promptHeight + 40} bottomOffset={promptHeight + 40}
listContent={ listContent={
<DesktopGenerationGallery <DesktopGenerationGallery
@@ -6,7 +6,6 @@ export const ImageTo3DObject = () => {
title="Generate 3D Object" title="Generate 3D Object"
subtitle="Transform your image into a 3D object with textures." subtitle="Transform your image into a 3D object with textures."
variant="object" variant="object"
backgroundImage="/resources/images/floating-cubes.webp"
/> />
); );
}; };
@@ -6,7 +6,6 @@ export const ImageTo3DWorld = () => {
title="Image to 3D World" title="Image to 3D World"
subtitle="Generate 3D worlds from an image with World Labs" subtitle="Generate 3D worlds from an image with World Labs"
variant="world" variant="world"
backgroundImage="/resources/images/room-of-items.webp"
/> />
); );
}; };
@@ -104,7 +104,7 @@ export const ImageWatermarkRemover = () => {
return ( return (
<> <>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg"> <div className="flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-background text-base-fg">
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<main <main
className={ className={
@@ -115,7 +115,7 @@ export const ImageWatermarkRemover = () => {
> >
{!imageUrl ? ( {!imageUrl ? (
<div className="w-full max-w-5xl"> <div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg"> <div className="aspect-video overflow-hidden border border-ui-panel-border bg-ui-panel">
<UploadEntryCard <UploadEntryCard
icon={DropletIcon} icon={DropletIcon}
title="Remove Image Watermark" title="Remove Image Watermark"
@@ -133,7 +133,7 @@ export const ImageWatermarkRemover = () => {
</div> </div>
) : ( ) : (
<div className="flex w-full max-w-5xl flex-col gap-5"> <div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg"> <div className="w-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="relative w-full bg-black"> <div className="relative w-full bg-black">
<Button <Button
icon={RotateCwIcon} icon={RotateCwIcon}
@@ -142,7 +142,7 @@ export const ImageWatermarkRemover = () => {
setImageUrl(""); setImageUrl("");
setImageDimensions(null); setImageDimensions(null);
}} }}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80" className="absolute right-3 top-3 z-10 border border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
> >
Switch Image Switch Image
</Button> </Button>
@@ -151,7 +151,7 @@ export const ImageWatermarkRemover = () => {
ref={imageRef} ref={imageRef}
src={imageUrl} src={imageUrl}
alt="Selected pic" alt="Selected pic"
className="max-h-[70vh] max-w-full rounded-lg object-contain shadow-2xl" className="max-h-[70vh] max-w-full object-contain"
onLoad={(e) => { onLoad={(e) => {
const img = e.currentTarget; const img = e.currentTarget;
setImageDimensions({ setImageDimensions({
@@ -177,12 +177,12 @@ export const ImageWatermarkRemover = () => {
</Button> </Button>
</div> </div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg"> <div className="border border-ui-panel-border bg-ui-panel p-6">
<div> <div>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60"> <div className="mb-4 flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<ImagesIcon <ImagesIcon
className="text-primary" /> className="text-ui-accent-ink" />
Image Information Image Information
</div> </div>
{imageDimensions ? ( {imageDimensions ? (
@@ -191,7 +191,7 @@ export const ImageWatermarkRemover = () => {
<span className="font-medium text-base-fg/70"> <span className="font-medium text-base-fg/70">
Resolution Resolution
</span> </span>
<span className="font-mono text-lg font-bold text-base-fg"> <span className="font-mono text-lg font-semibold tabular-nums text-base-fg">
{imageDimensions.width} × {imageDimensions.height} {imageDimensions.width} × {imageDimensions.height}
</span> </span>
</div> </div>
@@ -199,7 +199,7 @@ export const ImageWatermarkRemover = () => {
<span className="font-medium text-base-fg/70"> <span className="font-medium text-base-fg/70">
Aspect Ratio Aspect Ratio
</span> </span>
<span className="font-mono text-lg font-bold text-base-fg"> <span className="font-mono text-lg font-semibold tabular-nums text-base-fg">
{( {(
imageDimensions.width / imageDimensions.height imageDimensions.width / imageDimensions.height
).toFixed(2)} ).toFixed(2)}
@@ -543,12 +543,12 @@ export const RemoveBackground = () => {
return ( return (
<> <>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg"> <div className="flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg">
<div className="flex flex-1 items-center justify-center overflow-y-auto p-16"> <div className="flex flex-1 items-center justify-center overflow-y-auto p-16">
<main className="flex h-full w-full flex-col items-center justify-center"> <main className="flex h-full w-full flex-col items-center justify-center">
{showUploadScreen ? ( {showUploadScreen ? (
<div className="w-full max-w-5xl"> <div className="w-full max-w-5xl">
<div className="relative aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg"> <div className="relative aspect-video overflow-hidden border border-ui-panel-border bg-ui-background">
<UploadEntryCard <UploadEntryCard
icon={WandSparklesIcon} icon={WandSparklesIcon}
title="Remove Background" title="Remove Background"
@@ -564,7 +564,7 @@ export const RemoveBackground = () => {
disabled={isLoadingImage} disabled={isLoadingImage}
/> />
{isLoadingImage && ( {isLoadingImage && (
<div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center backdrop-blur-sm"> <div className="bg-ui-panel/80 absolute inset-0 flex items-center justify-center">
<LoadingSpinner className="h-12 w-12" /> <LoadingSpinner className="h-12 w-12" />
</div> </div>
)} )}
@@ -580,9 +580,9 @@ export const RemoveBackground = () => {
onMouseDown={handleCompareMouseDown} onMouseDown={handleCompareMouseDown}
disabled={!activeImage || isProcessing} disabled={!activeImage || isProcessing}
className={twMerge( className={twMerge(
"border-ui-controls-border select-none border-2 px-6 py-2.5 text-sm font-semibold transition-all", "border-ui-controls-border select-none border px-6 py-2.5 text-sm font-semibold transition-all",
isHoldingCompare isHoldingCompare
? "border-primary bg-primary/20" ? "border-white bg-white/10"
: "border-ui-controls-border", : "border-ui-controls-border",
(!activeImage || isProcessing) && (!activeImage || isProcessing) &&
"cursor-not-allowed opacity-50", "cursor-not-allowed opacity-50",
@@ -598,7 +598,7 @@ export const RemoveBackground = () => {
onClick={handleDownload} onClick={handleDownload}
disabled={!activeImage || isProcessing} disabled={!activeImage || isProcessing}
className={twMerge( className={twMerge(
"select-none border-2 border-primary px-6 py-2.5 text-sm font-semibold transition-all", "select-none px-6 py-2.5 text-sm font-semibold transition-all",
(!activeImage || isProcessing) && (!activeImage || isProcessing) &&
"cursor-not-allowed opacity-50", "cursor-not-allowed opacity-50",
)} )}
@@ -610,11 +610,11 @@ export const RemoveBackground = () => {
<div className="flex flex-1 items-center justify-center"> <div className="flex flex-1 items-center justify-center">
<div <div
className="relative overflow-hidden rounded-2xl border border-ui-panel-border shadow-xl" className="relative overflow-hidden border border-ui-panel-border"
style={imageContainerStyle} style={imageContainerStyle}
> >
{isProcessing && ( {isProcessing && (
<div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/60 backdrop-blur-sm"> <div className="absolute inset-0 z-20 flex flex-col items-center justify-center bg-black/70">
{currentOriginalUrl && ( {currentOriginalUrl && (
<img <img
src={currentOriginalUrl} src={currentOriginalUrl}
@@ -624,12 +624,12 @@ export const RemoveBackground = () => {
)} )}
<div className="relative z-10 flex flex-col items-center gap-4"> <div className="relative z-10 flex flex-col items-center gap-4">
<div className="relative"> <div className="relative">
<div className="h-16 w-16 animate-spin rounded-full border-4 border-primary-500/30 border-t-primary-500" /> <div className="h-16 w-16 animate-spin rounded-full border-2 border-white/20 border-t-white" />
<WandSparklesIcon <WandSparklesIcon
className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl text-primary-400" /> className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-2xl text-white" />
</div> </div>
<span className="text-lg font-semibold text-white"> <span className="font-mono text-sm font-semibold uppercase tracking-[0.12em] text-white">
Removing Background... Removing Background...
</span> </span>
</div> </div>
@@ -679,7 +679,7 @@ export const RemoveBackground = () => {
<div <div
ref={progressBarRef} ref={progressBarRef}
className="absolute bottom-0 top-0 w-1 bg-primary-500 shadow-lg shadow-primary-500/50" className="absolute bottom-0 top-0 w-1 bg-white"
style={{ style={{
left: `${revealProgressRef.current}%`, left: `${revealProgressRef.current}%`,
transform: "translateX(-50%)", transform: "translateX(-50%)",
@@ -695,7 +695,7 @@ export const RemoveBackground = () => {
</div> </div>
</div> </div>
<div className="mt-auto flex shrink-0 items-center gap-3 rounded-xl border border-ui-panel-border bg-ui-background p-2"> <div className="mt-auto flex shrink-0 items-center gap-3 border border-ui-panel-border bg-ui-background p-2">
<input <input
type="file" type="file"
ref={fileInputRef} ref={fileInputRef}
@@ -711,7 +711,7 @@ export const RemoveBackground = () => {
position="top" position="top"
showIconsInList showIconsInList
buttonClassName={twMerge( buttonClassName={twMerge(
"h-14 w-14 border-2 border-dashed border-ui-panel-border bg-ui-controls/50", "h-14 w-14 rounded-[3px] border border-dashed border-white/20 bg-white/[0.015] hover:border-white/40 hover:bg-white/5",
isProcessing && "cursor-not-allowed opacity-50", isProcessing && "cursor-not-allowed opacity-50",
)} )}
triggerIcon={ triggerIcon={
@@ -724,10 +724,10 @@ export const RemoveBackground = () => {
key={img.id} key={img.id}
onClick={() => handleThumbnailClick(img)} onClick={() => handleThumbnailClick(img)}
className={twMerge( className={twMerge(
"relative h-14 w-14 overflow-hidden rounded-lg border-2 transition-all", "relative h-14 w-14 overflow-hidden rounded-[3px] border transition-all",
img.id === activeImageId img.id === activeImageId
? "border-primary ring-2 ring-primary/30" ? "border-white"
: "border-transparent hover:border-primary/50", : "border-white/15 hover:border-white/30",
)} )}
> >
<img <img
@@ -40,11 +40,11 @@ const formatTime = (s: number): string => {
const EmptyState = ({ onAdd }: { onAdd: () => void }) => ( const EmptyState = ({ onAdd }: { onAdd: () => void }) => (
<div className="flex h-[calc(100vh-56px)] w-full items-center justify-center bg-ui-background"> <div className="flex h-[calc(100vh-56px)] w-full items-center justify-center bg-ui-background">
<div className="flex flex-col items-center gap-4 text-center"> <div className="flex flex-col items-center gap-4 text-center">
<div className="flex h-20 w-20 items-center justify-center rounded-2xl bg-white/5"> <div className="flex h-20 w-20 items-center justify-center border border-white/15 bg-white/[0.015]">
<ImagesIcon className="text-4xl text-base-fg/40" /> <ImagesIcon className="text-4xl text-base-fg/40" />
</div> </div>
<div> <div>
<p className="text-lg font-semibold text-base-fg">No shots yet</p> <p className="font-display text-2xl tracking-tight text-base-fg">No shots yet</p>
<p className="mt-1 text-sm text-base-fg/50">Add your first shot to get started</p> <p className="mt-1 text-sm text-base-fg/50">Add your first shot to get started</p>
</div> </div>
<Button variant="primary" icon={PlusIcon} onClick={onAdd}> <Button variant="primary" icon={PlusIcon} onClick={onAdd}>
@@ -68,63 +68,63 @@ const MetadataEditor = ({ board, onUpdate, onDelete, onUpload, onSketch }: Metad
<aside className="flex w-[280px] shrink-0 flex-col border-r border-ui-panel-border bg-ui-panel"> <aside className="flex w-[280px] shrink-0 flex-col border-r border-ui-panel-border bg-ui-panel">
<div className="flex-1 overflow-y-auto p-4"> <div className="flex-1 overflow-y-auto p-4">
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<div className="text-xs font-medium uppercase tracking-wider text-base-fg/40"> <div className="hud-label text-base-fg/40">
Shot {board.shotNumber} Shot {board.shotNumber}
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-base-fg/60">Title</label> <label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Title</label>
<input <input
type="text" type="text"
value={board.title} value={board.title}
onChange={(e) => onUpdate({ title: e.target.value })} onChange={(e) => onUpdate({ title: e.target.value })}
placeholder="Shot title" placeholder="Shot title"
className="w-full rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-primary focus:outline-none" className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-white/60 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-base-fg/60">Dialogue</label> <label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Dialogue</label>
<textarea <textarea
value={board.dialogue} value={board.dialogue}
onChange={(e) => onUpdate({ dialogue: e.target.value })} onChange={(e) => onUpdate({ dialogue: e.target.value })}
placeholder="Character dialogue..." placeholder="Character dialogue..."
rows={3} rows={3}
className="w-full resize-none rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-primary focus:outline-none" className="w-full resize-none rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-white/60 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-base-fg/60">Action</label> <label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Action</label>
<textarea <textarea
value={board.action} value={board.action}
onChange={(e) => onUpdate({ action: e.target.value })} onChange={(e) => onUpdate({ action: e.target.value })}
placeholder="On-screen action..." placeholder="On-screen action..."
rows={3} rows={3}
className="w-full resize-none rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-primary focus:outline-none" className="w-full resize-none rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-white/60 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-base-fg/60">Notes</label> <label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Notes</label>
<textarea <textarea
value={board.notes} value={board.notes}
onChange={(e) => onUpdate({ notes: e.target.value })} onChange={(e) => onUpdate({ notes: e.target.value })}
placeholder="Director notes..." placeholder="Director notes..."
rows={3} rows={3}
className="w-full resize-none rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-primary focus:outline-none" className="w-full resize-none rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm text-base-fg placeholder:text-base-fg/30 focus:border-white/60 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label className="mb-1 block text-xs text-base-fg/60">Duration (seconds)</label> <label className="mb-1 block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">Duration (seconds)</label>
<input <input
type="number" type="number"
min={0} min={0}
step={0.5} step={0.5}
value={board.duration} value={board.duration}
onChange={(e) => onUpdate({ duration: parseFloat(e.target.value) || 0 })} onChange={(e) => onUpdate({ duration: parseFloat(e.target.value) || 0 })}
className="w-full rounded-md border border-ui-panel-border bg-ui-background px-3 py-2 text-sm text-base-fg focus:border-primary focus:outline-none" className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 font-mono text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
/> />
</div> </div>
</div> </div>
@@ -170,7 +170,7 @@ const MainPreview = ({
style={{ pointerEvents: "none" }} style={{ pointerEvents: "none" }}
/> />
{board.title && ( {board.title && (
<div className="absolute bottom-4 left-1/2 -translate-x-1/2 rounded-lg bg-black/60 px-4 py-2 backdrop-blur-sm"> <div className="absolute bottom-4 left-1/2 -translate-x-1/2 border border-white/15 bg-black/70 px-4 py-2">
<p className="text-sm font-medium text-white">{board.title}</p> <p className="text-sm font-medium text-white">{board.title}</p>
</div> </div>
)} )}
@@ -178,7 +178,7 @@ const MainPreview = ({
) : ( ) : (
<button <button
onClick={onUploadClick} onClick={onUploadClick}
className="flex flex-col items-center gap-3 rounded-xl border-2 border-dashed border-ui-panel-border p-12 text-base-fg/40 transition-colors hover:border-primary/50 hover:text-base-fg/70" className="flex flex-col items-center gap-3 border border-dashed border-white/20 bg-white/[0.015] p-12 text-base-fg/40 transition-colors hover:border-white/40 hover:bg-white/5 hover:text-base-fg/70"
> >
<UploadIcon className="text-3xl" /> <UploadIcon className="text-3xl" />
<span className="text-sm">Click to upload an image</span> <span className="text-sm">Click to upload an image</span>
@@ -332,9 +332,9 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
onClick={() => setTool(t)} onClick={() => setTool(t)}
title={label} title={label}
className={twMerge( className={twMerge(
"flex h-8 w-8 items-center justify-center rounded-md transition-colors", "flex h-8 w-8 items-center justify-center rounded-[3px] transition-colors",
tool === t tool === t
? "bg-primary text-white" ? "bg-white text-black"
: "text-base-fg/60 hover:bg-white/10 hover:text-base-fg", : "text-base-fg/60 hover:bg-white/10 hover:text-base-fg",
)} )}
> >
@@ -352,7 +352,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
<div className="mx-1 h-5 w-px bg-ui-panel-border" /> <div className="mx-1 h-5 w-px bg-ui-panel-border" />
{/* Color picker */} {/* Color picker */}
<label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-md border border-ui-panel-border" title="Stroke color"> <label className="relative flex h-8 w-8 cursor-pointer items-center justify-center rounded-[3px] border border-white/15 hover:border-white/30" title="Stroke color">
<span <span
className="h-4 w-4 rounded-sm border border-white/20" className="h-4 w-4 rounded-sm border border-white/20"
style={{ background: color }} style={{ background: color }}
@@ -367,16 +367,16 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
{/* Brush size */} {/* Brush size */}
<div className="flex items-center gap-1.5"> <div className="flex items-center gap-1.5">
<span className="text-[10px] text-base-fg/40">Size</span> <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-base-fg/40">Size</span>
<input <input
type="range" type="range"
min={1} min={1}
max={32} max={32}
value={brushSize} value={brushSize}
onChange={(e) => setBrushSize(Number(e.target.value))} onChange={(e) => setBrushSize(Number(e.target.value))}
className="w-20 accent-primary" className="w-20 accent-white"
/> />
<span className="w-5 text-center text-[10px] text-base-fg/50">{brushSize}</span> <span className="w-5 text-center font-mono text-[10px] tabular-nums text-base-fg/50">{brushSize}</span>
</div> </div>
<div className="mx-1 h-5 w-px bg-ui-panel-border" /> <div className="mx-1 h-5 w-px bg-ui-panel-border" />
@@ -385,7 +385,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
onClick={handleUndo} onClick={handleUndo}
disabled={undoStack.length === 0} disabled={undoStack.length === 0}
title="Undo" title="Undo"
className="flex h-8 w-8 items-center justify-center rounded-md text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg disabled:opacity-30" className="flex h-8 w-8 items-center justify-center rounded-[3px] text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg disabled:opacity-30"
> >
<RotateCcwIcon className="text-sm" /> <RotateCcwIcon className="text-sm" />
</button> </button>
@@ -393,7 +393,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
<button <button
onClick={handleClear} onClick={handleClear}
title="Clear canvas" title="Clear canvas"
className="flex h-8 w-8 items-center justify-center rounded-md text-base-fg/60 transition-colors hover:bg-white/10 hover:text-red-400" className="flex h-8 w-8 items-center justify-center rounded-[3px] text-base-fg/60 transition-colors hover:bg-white/10 hover:text-red-400"
> >
<XIcon className="text-sm" /> <XIcon className="text-sm" />
</button> </button>
@@ -401,7 +401,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
<button <button
onClick={onExit} onClick={onExit}
title="Done sketching" title="Done sketching"
className="ml-auto flex h-8 items-center gap-1.5 rounded-md bg-primary px-3 text-xs font-medium text-white transition-colors hover:bg-primary-400" className="ml-auto flex h-8 items-center gap-1.5 rounded-[3px] bg-white px-3 text-xs font-medium text-black transition-colors hover:bg-white/85"
> >
<CheckIcon className="text-xs" /> <CheckIcon className="text-xs" />
Done Done
@@ -421,7 +421,7 @@ const SketchCanvas = ({ initialImageDataUrl, onSave, onExit }: SketchCanvasProps
touchAction: "none", touchAction: "none",
display: "block", display: "block",
}} }}
className="rounded shadow-xl" className="border border-white/15"
onPointerDown={handlePointerDown} onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove} onPointerMove={handlePointerMove}
onPointerUp={handlePointerUp} onPointerUp={handlePointerUp}
@@ -467,11 +467,11 @@ const ThumbnailItem = ({
onDragEnd={onDragEnd} onDragEnd={onDragEnd}
onClick={onSelect} onClick={onSelect}
className={twMerge( className={twMerge(
"relative flex h-[60px] w-[100px] shrink-0 cursor-pointer overflow-hidden rounded-md border-2 transition-all select-none", "relative flex h-[60px] w-[100px] shrink-0 cursor-pointer overflow-hidden rounded-[3px] border transition-all select-none",
isSelected isSelected
? "border-primary ring-2 ring-primary/30" ? "border-white"
: "border-transparent hover:border-white/20", : "border-white/15 hover:border-white/30",
isDragOver && "border-primary/60 opacity-60", isDragOver && "border-white/60 opacity-60",
)} )}
> >
{board.imageDataUrl ? ( {board.imageDataUrl ? (
@@ -482,7 +482,7 @@ const ThumbnailItem = ({
</div> </div>
)} )}
<div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-0.5"> <div className="absolute bottom-0 left-0 right-0 bg-black/60 px-1.5 py-0.5">
<span className="text-[10px] text-white/70">{board.shotNumber}</span> <span className="font-mono text-[10px] tabular-nums text-white/85">{board.shotNumber}</span>
</div> </div>
</div> </div>
); );
@@ -515,7 +515,7 @@ const Filmstrip = ({
<div className="flex h-[80px] shrink-0 items-center gap-2 overflow-x-auto border-t border-ui-panel-border bg-ui-panel px-3"> <div className="flex h-[80px] shrink-0 items-center gap-2 overflow-x-auto border-t border-ui-panel-border bg-ui-panel px-3">
<button <button
onClick={onAdd} onClick={onAdd}
className="flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-md border-2 border-dashed border-ui-panel-border bg-transparent text-base-fg/40 transition-colors hover:border-primary/50 hover:text-primary" className="flex h-[60px] w-[60px] shrink-0 items-center justify-center rounded-[3px] border border-dashed border-white/20 bg-white/[0.015] text-base-fg/40 transition-colors hover:border-white/40 hover:bg-white/5 hover:text-base-fg"
title="Add shot" title="Add shot"
> >
<PlusIcon /> <PlusIcon />
@@ -705,12 +705,12 @@ const Timeline = ({
const playheadLeft = currentTimeSeconds * PIXELS_PER_SECOND; const playheadLeft = currentTimeSeconds * PIXELS_PER_SECOND;
return ( return (
<div className="shrink-0 border-t border-ui-panel-border bg-[#1a1a1a]"> <div className="shrink-0 border-t border-ui-panel-border bg-ui-panel">
{/* Header row */} {/* Header row */}
<div className="flex items-center justify-between border-b border-ui-panel-border/40 px-3 py-1"> <div className="flex items-center justify-between border-b border-ui-panel-border/40 px-3 py-1">
<span className="font-mono text-xs text-base-fg/50">{formatTime(currentTimeSeconds)}</span> <span className="font-mono text-xs tabular-nums text-base-fg/50">{formatTime(currentTimeSeconds)}</span>
<span className="text-xs text-base-fg/25">Timeline</span> <span className="hud-label text-base-fg/25">Timeline</span>
<span className="font-mono text-xs text-base-fg/50">{formatTime(totalDuration)}</span> <span className="font-mono text-xs tabular-nums text-base-fg/50">{formatTime(totalDuration)}</span>
</div> </div>
{/* Lane */} {/* Lane */}
@@ -729,7 +729,7 @@ const Timeline = ({
style={{ left: i * PIXELS_PER_SECOND }} style={{ left: i * PIXELS_PER_SECOND }}
> >
<div className="h-2 w-px bg-white/10" /> <div className="h-2 w-px bg-white/10" />
<span className="text-[9px] text-base-fg/25">{i}s</span> <span className="font-mono text-[9px] tabular-nums text-base-fg/25">{i}s</span>
</div> </div>
))} ))}
@@ -743,9 +743,9 @@ const Timeline = ({
<div <div
key={board.id} key={board.id}
className={twMerge( className={twMerge(
"absolute top-6 bottom-1 flex items-stretch overflow-hidden rounded border transition-colors", "absolute top-6 bottom-1 flex items-stretch overflow-hidden border transition-colors",
isSelected isSelected
? "border-primary bg-primary/25" ? "border-white bg-white/15"
: "border-white/15 bg-white/5 hover:bg-white/10", : "border-white/15 bg-white/5 hover:bg-white/10",
)} )}
style={{ style={{
@@ -776,13 +776,13 @@ const Timeline = ({
"{board.dialogue}" "{board.dialogue}"
</span> </span>
)} )}
<span className="text-[9px] text-base-fg/30">{board.duration.toFixed(1)}s</span> <span className="font-mono text-[9px] tabular-nums text-base-fg/30">{board.duration.toFixed(1)}s</span>
</div> </div>
{/* Resize handle */} {/* Resize handle */}
<div <div
data-resize="true" data-resize="true"
className="absolute right-0 top-0 bottom-0 w-2 rounded-r opacity-0 transition-opacity hover:bg-white/30 hover:opacity-100" className="absolute right-0 top-0 bottom-0 w-2 opacity-0 transition-opacity hover:bg-white/30 hover:opacity-100"
style={{ cursor: "ew-resize" }} style={{ cursor: "ew-resize" }}
onPointerDown={(e) => handleResizePointerDown(e, board.id, board.duration)} onPointerDown={(e) => handleResizePointerDown(e, board.id, board.duration)}
title="Drag to resize duration" title="Drag to resize duration"
@@ -804,10 +804,10 @@ const Timeline = ({
{/* Playhead */} {/* Playhead */}
<div <div
className="pointer-events-none absolute top-0 bottom-0 z-20 w-px bg-primary" className="pointer-events-none absolute top-0 bottom-0 z-20 w-px bg-white"
style={{ left: playheadLeft }} style={{ left: playheadLeft }}
> >
<div className="absolute -left-[4px] top-0 h-0 w-0 border-x-[4px] border-t-[7px] border-x-transparent border-t-primary" /> <div className="absolute -left-[4px] top-0 h-0 w-0 border-x-[4px] border-t-[7px] border-x-transparent border-t-white" />
</div> </div>
</div> </div>
</div> </div>
@@ -845,14 +845,14 @@ const PlaybackControls = ({
onNextScene, onNextScene,
}: PlaybackControlsProps) => { }: PlaybackControlsProps) => {
const transportBtnClass = const transportBtnClass =
"flex h-9 w-9 items-center justify-center rounded-md text-base-fg/70 transition-colors hover:bg-white/10 hover:text-base-fg active:scale-95"; "flex h-9 w-9 items-center justify-center rounded-[3px] text-base-fg/70 transition-colors hover:bg-white/10 hover:text-base-fg active:scale-95";
return ( return (
<div className="flex h-[52px] shrink-0 items-center border-t border-ui-panel-border bg-[#111] px-4"> <div className="flex h-[52px] shrink-0 items-center border-t border-ui-panel-border bg-ui-background px-4">
{/* Left stats */} {/* Left stats */}
<div className="flex w-32 flex-col"> <div className="flex w-32 flex-col">
<span className="font-mono text-xs text-base-fg/60">{formatTime(currentTimeSeconds)}</span> <span className="font-mono text-xs tabular-nums text-base-fg/60">{formatTime(currentTimeSeconds)}</span>
<span className="text-[10px] text-base-fg/30"> <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-base-fg/30">
{currentBoardNumber !== null ? `Shot ${currentBoardNumber}` : "—"} {currentBoardNumber !== null ? `Shot ${currentBoardNumber}` : "—"}
</span> </span>
</div> </div>
@@ -876,7 +876,7 @@ const PlaybackControls = ({
{/* Play / Pause */} {/* Play / Pause */}
<button <button
className="mx-1 flex h-10 w-10 items-center justify-center rounded-full bg-primary text-white transition-all hover:bg-primary-400 active:scale-95" className="mx-1 flex h-10 w-10 items-center justify-center rounded-[3px] bg-white text-black transition-all hover:bg-white/85 active:scale-95"
onClick={isPlaying ? onPause : onPlay} onClick={isPlaying ? onPause : onPlay}
title={isPlaying ? "Pause" : "Play"} title={isPlaying ? "Pause" : "Play"}
> >
@@ -901,8 +901,8 @@ const PlaybackControls = ({
{/* Right stats */} {/* Right stats */}
<div className="flex w-32 flex-col items-end"> <div className="flex w-32 flex-col items-end">
<span className="font-mono text-xs text-base-fg/60">{formatTime(totalDuration)}</span> <span className="font-mono text-xs tabular-nums text-base-fg/60">{formatTime(totalDuration)}</span>
<span className="text-[10px] text-base-fg/30">{totalBoards} shot{totalBoards !== 1 ? "s" : ""}</span> <span className="font-mono text-[10px] uppercase tracking-[0.12em] text-base-fg/30">{totalBoards} shot{totalBoards !== 1 ? "s" : ""}</span>
</div> </div>
</div> </div>
); );
@@ -1,17 +0,0 @@
.video-background-image {
background-image:
linear-gradient(
0deg,
rgb(var(--st-photo-tint-rgb) / var(--st-photo-tint-alpha)),
rgb(var(--st-photo-tint-rgb) / var(--st-photo-tint-alpha))
),
url("/resources/images/forest-trees.webp");
background-repeat: no-repeat;
background-size: cover;
position: fixed;
right: 0;
bottom: 0;
min-width: 100%;
min-height: 100%;
filter: grayscale(var(--st-photo-grayscale));
}
@@ -1,16 +0,0 @@
import "./Background.css";
export default function GalleryBackground() {
return (
<>
<div className="pointer-events-none fixed inset-0 z-[1] overflow-hidden bg-[radial-gradient(50%_50%_at_50%_50%,_transparent_49%,_rgb(var(--st-controls-rgb)_/_var(--st-gallery-vignette-alpha))_100%)]" />
<div className="fixed inset-0 z-0 overflow-hidden">
<div
className={`video-background-image h-full w-full opacity-30 transition-opacity duration-1000`}
>
<div className="absolute inset-0 grid grid-cols-5 gap-6 px-6 opacity-35" />
</div>
</div>
</>
);
}
@@ -10,7 +10,6 @@ import {
UploadAudioMedia, UploadAudioMedia,
FilterMediaClasses, FilterMediaClasses,
} from "@storyteller/api"; } from "@storyteller/api";
import BackgroundGallery from "./BackgroundGallery";
import { import {
ClassyModelSelector, ClassyModelSelector,
useImageToVideoPageModelList, useImageToVideoPageModelList,
@@ -186,7 +185,6 @@ const ImageToVideo = ({ imageMediaId, imageUrl }: ImageToVideoProps) => {
hasContent={hasContent} hasContent={hasContent}
emptyStateTitle="Create Video" emptyStateTitle="Create Video"
emptyStateSubtitle="Choose an image, add a prompt, then generate" emptyStateSubtitle="Choose an image, add a prompt, then generate"
background={<BackgroundGallery />}
bottomOffset={promptHeight + 40} bottomOffset={promptHeight + 40}
listContent={ listContent={
<DesktopGenerationGallery <DesktopGenerationGallery
@@ -457,7 +457,7 @@ export const VideoFrameExtractor = () => {
return ( return (
<> <>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg"> <div className="flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-background text-base-fg">
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<main <main
className={ className={
@@ -468,7 +468,7 @@ export const VideoFrameExtractor = () => {
> >
{!videoUrl ? ( {!videoUrl ? (
<div className="w-full max-w-5xl"> <div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg"> <div className="aspect-video overflow-hidden border border-ui-panel-border bg-ui-panel">
<UploadEntryCard <UploadEntryCard
icon={ImagesIcon} icon={ImagesIcon}
title="Extract Video Frames" title="Extract Video Frames"
@@ -486,7 +486,7 @@ export const VideoFrameExtractor = () => {
</div> </div>
) : ( ) : (
<div className="flex w-full max-w-5xl flex-col gap-5"> <div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-xl border border-ui-panel-border bg-ui-controls/40"> <div className="w-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="relative aspect-video w-full bg-black"> <div className="relative aspect-video w-full bg-black">
<Button <Button
icon={RotateCwIcon} icon={RotateCwIcon}
@@ -500,7 +500,7 @@ export const VideoFrameExtractor = () => {
setSavedFrames(new Set()); setSavedFrames(new Set());
setConvertingFrames(new Set()); setConvertingFrames(new Set());
}} }}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80" className="absolute right-3 top-3 z-10 border border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
> >
Switch Video Switch Video
</Button> </Button>
@@ -516,17 +516,17 @@ export const VideoFrameExtractor = () => {
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4"> <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div <div
ref={progressBarRef} ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20" className="group relative mb-3 h-3 cursor-pointer bg-white/15"
onClick={handleProgressBarClick} onClick={handleProgressBarClick}
> >
<div <div
className="absolute h-full overflow-hidden rounded-full bg-primary" className="absolute h-full overflow-hidden bg-white"
style={{ style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`, width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}} }}
/> />
<div <div
className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize rounded-full bg-yellow-400 shadow-lg" className="absolute top-1/2 h-6 w-1.5 -translate-y-1/2 cursor-ew-resize bg-yellow-400"
style={{ style={{
left: `${duration ? (startTime / duration) * 100 : 0}%`, left: `${duration ? (startTime / duration) * 100 : 0}%`,
}} }}
@@ -570,7 +570,7 @@ export const VideoFrameExtractor = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => seekToFrame("first")} onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20" className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame" title="First Frame"
> >
<SkipBackIcon <SkipBackIcon
@@ -579,7 +579,7 @@ export const VideoFrameExtractor = () => {
</button> </button>
<button <button
onClick={togglePlayPause} onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30" className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
> >
<DynamicIcon <DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon} icon={isPlaying ? PauseIcon : PlayIcon}
@@ -588,7 +588,7 @@ export const VideoFrameExtractor = () => {
</button> </button>
<button <button
onClick={() => seekToFrame("last")} onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20" className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame" title="Last Frame"
> >
<SkipForwardIcon <SkipForwardIcon
@@ -598,7 +598,7 @@ export const VideoFrameExtractor = () => {
<div className="mx-1 h-6 w-px bg-white/20" /> <div className="mx-1 h-6 w-px bg-white/20" />
<button <button
onClick={toggleMute} onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20" className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
> >
<DynamicIcon <DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon} icon={isMuted ? VolumeXIcon : Volume2Icon}
@@ -617,7 +617,7 @@ export const VideoFrameExtractor = () => {
className="w-20 accent-white" className="w-20 accent-white"
/> />
</div> </div>
<div className="font-mono text-sm text-white"> <div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)} {formatTime(currentTime)} / {formatTime(duration)}
</div> </div>
</div> </div>
@@ -640,24 +640,24 @@ export const VideoFrameExtractor = () => {
<div className="grid w-full grid-cols-1 gap-5 md:grid-cols-2"> <div className="grid w-full grid-cols-1 gap-5 md:grid-cols-2">
<div className="flex flex-col gap-5"> <div className="flex flex-col gap-5">
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg"> <div className="group border border-ui-panel-border bg-ui-panel p-6">
<div> <div>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60"> <div className="mb-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Start Time Start Time
</div> </div>
<div className="font-mono text-3xl font-bold text-primary drop-shadow-sm"> <div className="font-mono text-3xl font-semibold tabular-nums text-ui-accent-ink">
{formatTimePrecise(startTime)} {formatTimePrecise(startTime)}
</div> </div>
<div className="mt-3 flex items-center gap-2 text-xs text-base-fg/60"> <div className="mt-3 flex items-center gap-2 text-xs text-base-fg/60">
<div className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-400 shadow-lg shadow-yellow-400/50" /> <div className="h-2.5 w-2.5 animate-pulse rounded-full bg-yellow-400" />
<span>Adjust via the yellow marker on the video</span> <span>Adjust via the yellow marker on the video</span>
</div> </div>
</div> </div>
</div> </div>
<div className="group rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg"> <div className="group border border-ui-panel-border bg-ui-panel p-6">
<div> <div>
<div className="mb-4 text-xs font-semibold uppercase tracking-wider text-base-fg/60"> <div className="mb-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Video Information Video Information
</div> </div>
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
@@ -665,7 +665,7 @@ export const VideoFrameExtractor = () => {
<span className="font-medium text-base-fg/70"> <span className="font-medium text-base-fg/70">
Duration Duration
</span> </span>
<span className="font-mono text-lg font-bold text-base-fg"> <span className="font-mono text-lg font-semibold tabular-nums text-base-fg">
{formatTime(duration)} {formatTime(duration)}
</span> </span>
</div> </div>
@@ -674,7 +674,7 @@ export const VideoFrameExtractor = () => {
<span className="font-medium text-base-fg/70"> <span className="font-medium text-base-fg/70">
Resolution Resolution
</span> </span>
<span className="font-mono font-bold text-base-fg"> <span className="font-mono font-semibold tabular-nums text-base-fg">
{videoRef.current.videoWidth} ×{" "} {videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight} {videoRef.current.videoHeight}
</span> </span>
@@ -685,12 +685,12 @@ export const VideoFrameExtractor = () => {
</div> </div>
</div> </div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg"> <div className="border border-ui-panel-border bg-ui-panel p-6">
<div> <div>
<h3 className="mb-5 flex items-center gap-2 text-sm font-semibold uppercase tracking-wider text-base-fg/60"> <h3 className="mb-5 flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<SparklesIcon <SparklesIcon
className="text-primary" /> className="text-ui-accent-ink" />
Extraction Settings Extraction Settings
</h3> </h3>
@@ -698,7 +698,7 @@ export const VideoFrameExtractor = () => {
<div> <div>
<label <label
htmlFor="numFrames" htmlFor="numFrames"
className="mb-2.5 block text-sm font-semibold text-base-fg" className="mb-1.5 block text-xs font-medium text-base-fg/60"
> >
Number of Frames Number of Frames
</label> </label>
@@ -716,14 +716,14 @@ export const VideoFrameExtractor = () => {
), ),
) )
} }
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary" className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
/> />
</div> </div>
<div> <div>
<label <label
htmlFor="frameDistance" htmlFor="frameDistance"
className="mb-2.5 block text-sm font-semibold text-base-fg" className="mb-1.5 block text-xs font-medium text-base-fg/60"
> >
Distance (ms) Distance (ms)
</label> </label>
@@ -738,11 +738,11 @@ export const VideoFrameExtractor = () => {
Math.max(1, parseInt(e.target.value) || 1), Math.max(1, parseInt(e.target.value) || 1),
) )
} }
className="border-ui-controls-border w-full rounded-xl border-2 bg-ui-controls px-4 py-3.5 text-base-fg focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary" className="w-full rounded-[3px] border border-white/15 bg-ui-controls px-3 py-2 text-sm tabular-nums text-base-fg focus:border-white/60 focus:outline-none"
/> />
</div> </div>
<div className="text-xs leading-relaxed text-base-fg/80"> <div className="text-xs leading-relaxed text-base-fg/60">
Extracting multiple frames helps capture a sharp, Extracting multiple frames helps capture a sharp,
non-blurred frame. non-blurred frame.
</div> </div>
@@ -754,26 +754,26 @@ export const VideoFrameExtractor = () => {
{extractedFrames.length > 0 && ( {extractedFrames.length > 0 && (
<div <div
ref={extractedFramesRef} ref={extractedFramesRef}
className="rounded-2xl border border-ui-panel-border bg-ui-controls p-6 shadow-lg" className="border border-ui-panel-border bg-ui-panel p-6"
> >
<div> <div>
<div className="mb-6 flex items-center justify-between"> <div className="mb-4 flex items-center justify-between">
<h3 className="flex items-center gap-2 text-xl font-bold uppercase tracking-wider text-base-fg"> <h3 className="flex items-center gap-2 text-sm font-semibold text-base-fg">
<ImagesIcon <ImagesIcon
className="text-primary" /> className="text-ui-accent-ink" />
Extracted Frames Extracted Frames
</h3> </h3>
<div className="bg-ui-badge rounded-full border-2 border-primary/30 px-5 py-2 text-sm font-bold text-base-fg shadow-lg"> <div className="border border-white/20 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase leading-none tracking-[0.12em] text-base-fg/70">
{extractedFrames.length}{" "} {extractedFrames.length}{" "}
{extractedFrames.length === 1 ? "Frame" : "Frames"} {extractedFrames.length === 1 ? "Frame" : "Frames"}
</div> </div>
</div> </div>
<div className="grid grid-cols-2 gap-5"> <div className="grid grid-cols-2 gap-3">
{extractedFrames.map((frame, index) => ( {extractedFrames.map((frame, index) => (
<div <div
key={frame.id} key={frame.id}
className="group relative overflow-hidden rounded-xl border-2 border-ui-panel-border bg-ui-controls" className="group relative overflow-hidden border border-ui-panel-border bg-ui-background transition-colors hover:border-white/30"
> >
<div className="aspect-video overflow-hidden bg-black"> <div className="aspect-video overflow-hidden bg-black">
<img <img
@@ -782,12 +782,12 @@ export const VideoFrameExtractor = () => {
className="h-full w-full object-contain" className="h-full w-full object-contain"
/> />
</div> </div>
<div className="space-y-2 bg-ui-background p-3"> <div className="space-y-2 border-t border-ui-panel-border/60 bg-ui-background p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<span className="bg-ui-badge rounded border border-ui-panel-border px-2 py-1 text-xs font-bold text-base-fg"> <span className="border border-white/20 px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase leading-none tracking-[0.12em] text-base-fg/70">
#{index + 1} #{index + 1}
</span> </span>
<div className="font-mono text-xs font-semibold text-base-fg/70"> <div className="font-mono text-xs tabular-nums text-base-fg/70">
{formatTimePrecise(frame.timestamp)} {formatTimePrecise(frame.timestamp)}
</div> </div>
</div> </div>
@@ -203,7 +203,7 @@ export const VideoWatermarkRemover = () => {
return ( return (
<> <>
<div className="bg-ui-panel-gradient flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-panel text-base-fg"> <div className="flex h-[calc(100vh-56px)] w-full overflow-hidden bg-ui-background text-base-fg">
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
<main <main
className={ className={
@@ -214,7 +214,7 @@ export const VideoWatermarkRemover = () => {
> >
{!videoUrl ? ( {!videoUrl ? (
<div className="w-full max-w-5xl"> <div className="w-full max-w-5xl">
<div className="aspect-video overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg"> <div className="aspect-video overflow-hidden border border-ui-panel-border bg-ui-panel">
<UploadEntryCard <UploadEntryCard
icon={DropletIcon} icon={DropletIcon}
title="Remove Video Watermark" title="Remove Video Watermark"
@@ -232,7 +232,7 @@ export const VideoWatermarkRemover = () => {
</div> </div>
) : ( ) : (
<div className="flex w-full max-w-5xl flex-col gap-5"> <div className="flex w-full max-w-5xl flex-col gap-5">
<div className="w-full overflow-hidden rounded-2xl border border-ui-panel-border bg-ui-background shadow-lg"> <div className="w-full overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="relative aspect-video w-full bg-black"> <div className="relative aspect-video w-full bg-black">
<Button <Button
icon={RotateCwIcon} icon={RotateCwIcon}
@@ -241,7 +241,7 @@ export const VideoWatermarkRemover = () => {
setVideoUrl(""); setVideoUrl("");
setCurrentTime(0); setCurrentTime(0);
}} }}
className="absolute right-3 top-3 z-10 border-2 border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80" className="absolute right-3 top-3 z-10 border border-red/50 px-3 py-1.5 text-sm hover:border-red/80 hover:bg-red/80"
> >
Switch Video Switch Video
</Button> </Button>
@@ -256,11 +256,11 @@ export const VideoWatermarkRemover = () => {
<div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4"> <div className="absolute bottom-0 left-0 right-0 bg-gradient-to-t from-black/90 to-transparent p-4">
<div <div
ref={progressBarRef} ref={progressBarRef}
className="group relative mb-3 h-3 cursor-pointer rounded-full bg-white/20" className="group relative mb-3 h-3 cursor-pointer bg-white/15"
onClick={handleProgressBarClick} onClick={handleProgressBarClick}
> >
<div <div
className="absolute h-full overflow-hidden rounded-full bg-primary" className="absolute h-full overflow-hidden bg-white"
style={{ style={{
width: `${duration ? (currentTime / duration) * 100 : 0}%`, width: `${duration ? (currentTime / duration) * 100 : 0}%`,
}} }}
@@ -270,7 +270,7 @@ export const VideoWatermarkRemover = () => {
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<button <button
onClick={() => seekToFrame("first")} onClick={() => seekToFrame("first")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20" className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="First Frame" title="First Frame"
> >
<SkipBackIcon <SkipBackIcon
@@ -279,7 +279,7 @@ export const VideoWatermarkRemover = () => {
</button> </button>
<button <button
onClick={togglePlayPause} onClick={togglePlayPause}
className="flex h-9 w-9 items-center justify-center rounded-full bg-white/20 hover:bg-white/30" className="flex h-9 w-9 items-center justify-center rounded-[3px] bg-white/20 transition-colors hover:bg-white/30"
> >
<DynamicIcon <DynamicIcon
icon={isPlaying ? PauseIcon : PlayIcon} icon={isPlaying ? PauseIcon : PlayIcon}
@@ -288,7 +288,7 @@ export const VideoWatermarkRemover = () => {
</button> </button>
<button <button
onClick={() => seekToFrame("last")} onClick={() => seekToFrame("last")}
className="flex h-8 w-8 items-center justify-center rounded-full bg-white/10 hover:bg-white/20" className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
title="Last Frame" title="Last Frame"
> >
<SkipForwardIcon <SkipForwardIcon
@@ -298,7 +298,7 @@ export const VideoWatermarkRemover = () => {
<div className="mx-1 h-6 w-px bg-white/20" /> <div className="mx-1 h-6 w-px bg-white/20" />
<button <button
onClick={toggleMute} onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-full bg-white/10 hover:bg-white/20" className="flex h-7 w-7 items-center justify-center rounded-[3px] bg-white/10 transition-colors hover:bg-white/20"
> >
<DynamicIcon <DynamicIcon
icon={isMuted ? VolumeXIcon : Volume2Icon} icon={isMuted ? VolumeXIcon : Volume2Icon}
@@ -317,7 +317,7 @@ export const VideoWatermarkRemover = () => {
className="w-20 accent-white" className="w-20 accent-white"
/> />
</div> </div>
<div className="font-mono text-sm text-white"> <div className="font-mono text-sm tabular-nums text-white">
{formatTime(currentTime)} / {formatTime(duration)} {formatTime(currentTime)} / {formatTime(duration)}
</div> </div>
</div> </div>
@@ -338,12 +338,12 @@ export const VideoWatermarkRemover = () => {
</Button> </Button>
</div> </div>
<div className="rounded-2xl border border-ui-panel-border bg-ui-background p-6 shadow-lg"> <div className="border border-ui-panel-border bg-ui-panel p-6">
<div> <div>
<div className="mb-4 flex items-center gap-2 text-xs font-semibold uppercase tracking-wider text-base-fg/60"> <div className="mb-4 flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<VideoIcon <VideoIcon
className="text-primary" /> className="text-ui-accent-ink" />
Video Information Video Information
</div> </div>
<div className="space-y-3 text-sm"> <div className="space-y-3 text-sm">
@@ -351,7 +351,7 @@ export const VideoWatermarkRemover = () => {
<span className="font-medium text-base-fg/70"> <span className="font-medium text-base-fg/70">
Duration Duration
</span> </span>
<span className="font-mono text-lg font-bold text-base-fg"> <span className="font-mono text-lg font-semibold tabular-nums text-base-fg">
{formatTime(duration)} {formatTime(duration)}
</span> </span>
</div> </div>
@@ -360,7 +360,7 @@ export const VideoWatermarkRemover = () => {
<span className="font-medium text-base-fg/70"> <span className="font-medium text-base-fg/70">
Resolution Resolution
</span> </span>
<span className="font-mono font-bold text-base-fg"> <span className="font-mono font-semibold tabular-nums text-base-fg">
{videoRef.current.videoWidth} ×{" "} {videoRef.current.videoWidth} ×{" "}
{videoRef.current.videoHeight} {videoRef.current.videoHeight}
</span> </span>
+1 -1
View File
@@ -38,7 +38,7 @@ export const links: LinksFunction = () => [
}, },
{ {
rel: "stylesheet", rel: "stylesheet",
href: "https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&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",
}, },
]; ];
+184 -53
View File
@@ -1,6 +1,6 @@
* { * {
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #363636 #171717; scrollbar-color: #2c2c2f #0b0b0c;
} }
/* Works on Chrome, Edge, and Safari */ /* Works on Chrome, Edge, and Safari */
@@ -9,13 +9,12 @@
} }
*::-webkit-scrollbar-track { *::-webkit-scrollbar-track {
background: #171717; background: #0b0b0c;
} }
*::-webkit-scrollbar-thumb { *::-webkit-scrollbar-thumb {
background-color: #363636; background-color: #2c2c2f;
border-radius: 4px; border: 2px solid #0b0b0c;
border: 2px solid #171717;
} }
/* Light theme: the scrollbar track/thumb must be light, not the dark defaults. */ /* Light theme: the scrollbar track/thumb must be light, not the dark defaults. */
@@ -46,18 +45,37 @@ input[type="number"] {
appearance: textfield; appearance: textfield;
} }
/* Display face: Archivo, matching artcraft-webapp. */
h1, h1,
h2, h2,
h3, h3,
h4, h4,
h5, h5,
h6 { h6 {
font-family: "Outfit", sans-serif; font-family:
"Archivo",
system-ui,
-apple-system,
BlinkMacSystemFont,
"Segoe UI",
Roboto,
"Helvetica Neue",
Arial,
sans-serif;
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%;
font-weight: 620;
} }
body { body {
font-family: font-family:
"Source Sans 3",
system-ui, system-ui,
-apple-system, -apple-system,
BlinkMacSystemFont, BlinkMacSystemFont,
@@ -70,22 +88,61 @@ body {
background-color: var(--st-bg); background-color: var(--st-bg);
color-scheme: var(--st-color-scheme, dark); color-scheme: var(--st-color-scheme, dark);
user-select: none; user-select: none;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
::selection {
background: #2d81ff;
color: #ffffff;
}
:focus-visible {
outline: 2px solid #74aaff;
outline-offset: 3px;
}
/* Mono HUD micro-label, matching the webapp/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;
font-weight: 400;
letter-spacing: -0.01em;
}
/* Display utility carries the ax-display treatment (Archivo at weight 620,
118% width). Plain (unlayered) rule so it wins over font-weight
utilities like font-semibold. */
.font-display {
font-stretch: 118%;
font-weight: 620;
} }
/* ===================== THEME VARIABLES ===================== */ /* ===================== THEME VARIABLES ===================== */
/* The default (the "gray" theme id) is the brutalist system shared with
artcraft-webapp: near-black surfaces, white/15 hairline borders. */
:root { :root {
--st-bg: #121212; --st-bg: #121316;
/* gray theme default */ --st-panel: #101014;
--st-panel: #1f1f1f; --st-panel-border: rgba(255, 255, 255, 0.15);
--st-panel-border: #3f3f3f; --st-modal: #101014;
--st-modal: #1f1f1f;
/* default modal surface (can be gradient per theme) */ /* default modal surface (can be gradient per theme) */
--st-controls: #3e3e41; --st-controls: #1e1f22;
--st-controls-rgb: 62 62 65; --st-controls-rgb: 30 31 34;
--st-controls-border: #3f3f3f; --st-controls-border: rgba(255, 255, 255, 0.15);
--st-fg: #ffffff; --st-sunken: #060607;
--st-fg-rgb: 255 255 255; --st-fg: #f2f1ee;
--st-divider: #515168; --st-fg-rgb: 242 241 238;
--st-divider: rgba(255, 255, 255, 0.15);
--st-gallery-vignette-alpha: 0.6; --st-gallery-vignette-alpha: 0.6;
/* Photo tint (default neutral) */ /* Photo tint (default neutral) */
--st-photo-tint-rgb: 0 0 0; --st-photo-tint-rgb: 0 0 0;
@@ -95,28 +152,45 @@ body {
--st-badge-alpha: 0.4; --st-badge-alpha: 0.4;
/* shadcn design tokens consumed by @storyteller/ui-video-editor. /* shadcn design tokens consumed by @storyteller/ui-video-editor.
Dark-theme palette — the editor only runs in dark mode today. Same values as artcraft-webapp's styles.css. */
--primary matches the webapp's brand blue (#2d81ff) so the --background: 225 10% 8%;
selection ring is consistent across hosts. */
--background: 0 0% 9%;
--foreground: 0 0% 98%; --foreground: 0 0% 98%;
--card: 0 0% 12%; --card: 240 11% 7%;
--card-foreground: 0 0% 98%; --card-foreground: 0 0% 98%;
--popover: 0 0% 12%; --popover: 240 11% 7%;
--popover-foreground: 0 0% 98%; --popover-foreground: 0 0% 98%;
--primary: 216 100% 59%; --primary: 216 100% 59%;
--primary-foreground: 0 0% 98%; --primary-foreground: 0 0% 98%;
--secondary: 0 0% 24%; --secondary: 240 4% 9%;
--secondary-foreground: 0 0% 98%; --secondary-foreground: 0 0% 98%;
--muted: 0 0% 18%; --muted: 240 4% 9%;
--muted-foreground: 0 0% 64%; --muted-foreground: 0 0% 64%;
--accent: 0 0% 16%; --accent: 240 4% 9%;
--accent-foreground: 0 0% 98%; --accent-foreground: 0 0% 98%;
--destructive: 0 70% 50%; --destructive: 0 70% 50%;
--destructive-foreground: 0 0% 98%; --destructive-foreground: 0 0% 98%;
--border: 0 0% 18%; --border: 0 0% 100% / 0.15;
--input: 0 0% 18%; --input: 0 0% 100% / 0.15;
--ring: 217 91% 60%; --ring: 217 100% 73%;
/* Motion tokens, mirrored from artcraft-webapp. */
--ease-out: cubic-bezier(0.22, 1, 0.36, 1);
--ease-in-out: cubic-bezier(0.65, 0, 0.35, 1);
--ease-spring: cubic-bezier(0.34, 1.56, 0.64, 1);
--ease-emphasis: cubic-bezier(0.32, 0.72, 0, 1);
--dur-fast: 140ms;
--dur-base: 220ms;
--dur-slow: 340ms;
--dur-page: 260ms;
}
@media (prefers-reduced-motion: reduce) {
:root {
--dur-fast: 1ms;
--dur-base: 1ms;
--dur-slow: 1ms;
--dur-page: 1ms;
}
} }
/* Light theme */ /* Light theme */
@@ -130,6 +204,7 @@ body {
--st-controls-rgb: 238 238 239; --st-controls-rgb: 238 238 239;
--st-controls-border: #dcdcdf; --st-controls-border: #dcdcdf;
/* requested light button border */ /* requested light button border */
--st-sunken: #ececef;
--st-fg: #111827; --st-fg: #111827;
--st-fg-rgb: 17 24 39; --st-fg-rgb: 17 24 39;
--st-divider: #d1d5db; --st-divider: #d1d5db;
@@ -141,18 +216,19 @@ body {
--st-badge-alpha: 0.1; --st-badge-alpha: 0.1;
} }
/* Gray (current) */ /* Default dark (brutalist, matches artcraft-webapp) */
.theme-gray { .theme-gray {
--st-bg: #121212; --st-bg: #121316;
--st-panel: #1f1f1f; --st-panel: #101014;
--st-panel-border: #272727; --st-panel-border: rgba(255, 255, 255, 0.15);
--st-modal: #1f1f1f; --st-modal: #101014;
--st-controls: #3e3e41; --st-controls: #1e1f22;
--st-controls-rgb: 62 62 65; --st-controls-rgb: 30 31 34;
--st-controls-border: #464646; --st-controls-border: rgba(255, 255, 255, 0.15);
--st-fg: #ffffff; --st-sunken: #060607;
--st-fg-rgb: 255 255 255; --st-fg: #f2f1ee;
--st-divider: #272727; --st-fg-rgb: 242 241 238;
--st-divider: rgba(255, 255, 255, 0.15);
--st-color-scheme: dark; --st-color-scheme: dark;
--st-gallery-vignette-alpha: 0.3; --st-gallery-vignette-alpha: 0.3;
--st-photo-tint-rgb: 0 0 0; --st-photo-tint-rgb: 0 0 0;
@@ -165,11 +241,12 @@ body {
.theme-black { .theme-black {
--st-bg: #000000; --st-bg: #000000;
--st-panel: #0a0a0a; --st-panel: #0a0a0a;
--st-panel-border: #242424; --st-panel-border: rgba(255, 255, 255, 0.15);
--st-modal: #0a0a0a; --st-modal: #0a0a0a;
--st-controls: #111111; --st-controls: #111111;
--st-controls-rgb: 17 17 17; --st-controls-rgb: 17 17 17;
--st-controls-border: #242424; --st-controls-border: rgba(255, 255, 255, 0.15);
--st-sunken: #000000;
--st-fg: #ffffff; --st-fg: #ffffff;
--st-fg-rgb: 255 255 255; --st-fg-rgb: 255 255 255;
--st-divider: #303030; --st-divider: #303030;
@@ -193,7 +270,8 @@ body {
radial-gradient(520px 280px at 110% 120%, #1cb6be33, transparent), #0f1220; radial-gradient(520px 280px at 110% 120%, #1cb6be33, transparent), #0f1220;
--st-controls: #1a1f35; --st-controls: #1a1f35;
--st-controls-rgb: 26 31 53; --st-controls-rgb: 26 31 53;
--st-controls-border: #1e2237; --st-controls-border: rgba(255, 255, 255, 0.15);
--st-sunken: #05070d;
--st-fg: #f8fafc; --st-fg: #f8fafc;
--st-fg-rgb: 248 250 252; --st-fg-rgb: 248 250 252;
--st-divider: #2a3152; --st-divider: #2a3152;
@@ -217,7 +295,8 @@ body {
radial-gradient(700px 360px at 115% 120%, #fb923c88, transparent), #1a121a; radial-gradient(700px 360px at 115% 120%, #fb923c88, transparent), #1a121a;
--st-controls: #271927; --st-controls: #271927;
--st-controls-rgb: 39 25 39; --st-controls-rgb: 39 25 39;
--st-controls-border: #3a253a; --st-controls-border: rgba(255, 255, 255, 0.15);
--st-sunken: #0d070b;
--st-fg: #f8fafc; --st-fg: #f8fafc;
--st-fg-rgb: 248 250 252; --st-fg-rgb: 248 250 252;
--st-divider: #4a2e3a; --st-divider: #4a2e3a;
@@ -238,14 +317,10 @@ body {
background: var(--st-panel); background: var(--st-panel);
} }
/* Panel surface. Brutalist system: flat, no gradient (class name kept
for lib compatibility). */
.bg-ui-panel-gradient { .bg-ui-panel-gradient {
background: background: var(--st-panel);
radial-gradient(
60.68% 60.68% at 50% 0%,
rgba(255, 255, 255, 0.08) 0%,
rgba(0, 0, 0, 0) 70%
),
var(--st-panel);
background-color: var(--st-panel); background-color: var(--st-panel);
} }
@@ -331,7 +406,6 @@ img {
.promptbox-scrollbar::-webkit-scrollbar-thumb { .promptbox-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2); background-color: rgba(255, 255, 255, 0.2);
border-radius: 9999px;
} }
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover { .promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
@@ -730,3 +804,60 @@ img {
.promptbox-drop-chip { .promptbox-drop-chip {
animation: promptbox-drop-chip-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both; animation: promptbox-drop-chip-in 0.25s cubic-bezier(0.22, 1, 0.36, 1) both;
} }
/* ===================== Brutalist extras (from artcraft-webapp) ========== */
.liquid-glass {
background: rgba(11, 11, 12, 0.92);
border: 1px solid rgba(255, 255, 255, 0.15);
}
@keyframes toast-in {
from {
opacity: 0;
translate: 0 8px;
}
to {
opacity: 1;
translate: 0 0;
}
}
.animate-toast-in {
animation: toast-in 0.2s ease-out both;
}
.bg-dots {
background-image: radial-gradient(#ffffff20 1px, transparent 0);
background-color: var(--st-panel);
background-size: 33px 33px;
background-position: center;
}
/* Fine registration marks echo the landing page section corners. */
.frame-corner-mark {
position: absolute;
width: 11px;
height: 11px;
margin: -5px;
pointer-events: none;
background:
linear-gradient(rgba(242, 241, 238, 0.4), rgba(242, 241, 238, 0.4))
center / 1px 11px no-repeat,
linear-gradient(rgba(242, 241, 238, 0.4), rgba(242, 241, 238, 0.4))
center / 11px 1px no-repeat;
}
/* Contained section borders match the landing without decorative extensions. */
.create-empty-section {
margin-inline: 12px;
border: 1px solid rgba(242, 241, 238, 0.2);
background: rgba(242, 241, 238, 0.015);
}
@media (min-width: 640px) {
.create-empty-section {
margin-inline: 32px;
}
}
@@ -2,29 +2,46 @@
@tailwind components; @tailwind components;
@tailwind utilities; @tailwind utilities;
/* Subtle 3px corners on interactive controls (matches artcraft-webapp).
The brutalist system keeps large surfaces (panels, modals, tables),
passive labels, and media tiles square; anything pressable gets a soft
corner for affordance. 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;
}
}
@layer components { @layer components {
.filter-tab { .filter-tab {
@apply text-nowrap rounded-lg border border-[#363636] bg-ui-controls-button px-2.5 py-1 text-[13px] font-medium text-white/80 transition-all duration-150 hover:border-brand-primary/50 hover:bg-assets-background/80; @apply text-nowrap rounded-[3px] border border-white/15 bg-white/5 px-2.5 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 transition-colors duration-150 hover:border-white/25 hover:bg-white/[0.07] hover:text-white;
} }
.filter-tab.active { .filter-tab.active {
@apply border border-brand-primary text-white hover:border-brand-primary hover:bg-ui-controls-button; @apply border-white bg-white text-black hover:border-white hover:bg-white hover:text-black;
} }
.filter-tab:disabled { .filter-tab:disabled {
@apply border-transparent bg-ui-controls-button/50 hover:border-transparent disabled:text-white/30; @apply border-transparent bg-white/[0.03] text-white/30 hover:border-transparent hover:bg-white/[0.03];
} }
/* Brutalist system: former "glass" surfaces are flat near-black panels
with white/15 hairline borders (class name kept for lib compatibility). */
.glass { .glass {
@apply relative border border-white/10 shadow-lg backdrop-blur-xl transition-colors duration-300; @apply relative rounded-[3px] border border-white/15 backdrop-blur-sm transition-colors duration-300;
@apply bg-[#303032]/90 !important; @apply bg-[#1E1F22]/95 !important;
} }
/* Light theme: the glass surface must be a light panel, not the dark /* Light theme: the glass surface must be a light panel, not the dark
hard-coded fill above. Tint + border swapped to read on a light bg. */ hard-coded fill above. Tint + border swapped to read on a light bg. */
.theme-light .glass { .theme-light .glass {
@apply border-black/10 !important; @apply border-black/15 !important;
background-color: rgb(248 248 248 / 0.92) !important; background-color: rgb(248 248 248 / 0.95) !important;
} }
} }
+41
View File
@@ -12,6 +12,36 @@ export default {
extend: { extend: {
fontFamily: { fontFamily: {
"custom-font": ["Fira Sans", "sans-serif"], "custom-font": ["Fira Sans", "sans-serif"],
// Brutalist type system, matching artcraft-webapp.
mono: [
"Geist Mono",
"ui-monospace",
"SFMono-Regular",
"Menlo",
"Consolas",
"monospace",
],
display: [
"Archivo",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"sans-serif",
],
sans: [
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"sans-serif",
],
}, },
keyframes: { keyframes: {
fadeIn: { fadeIn: {
@@ -225,12 +255,20 @@ export default {
wrapper: "#39394d", wrapper: "#39394d",
}, },
ui: { ui: {
// Brutalist system (matches artcraft-webapp): near-black surfaces
// with white/15 hairline borders. Values come from the --st-* theme
// variables in app/src/styles/base.css.
background: "var(--st-bg)", background: "var(--st-bg)",
panel: "var(--st-panel)", panel: "var(--st-panel)",
"panel-border": "var(--st-panel-border)", "panel-border": "var(--st-panel-border)",
border: "var(--st-panel-border)",
controls: "rgb(var(--st-controls-rgb) / <alpha-value>)", controls: "rgb(var(--st-controls-rgb) / <alpha-value>)",
"controls-button": "rgb(var(--st-controls-rgb) / <alpha-value>)", "controls-button": "rgb(var(--st-controls-rgb) / <alpha-value>)",
"controls-border": "var(--st-controls-border)",
divider: "var(--st-divider)", divider: "var(--st-divider)",
ink: "rgb(var(--st-fg-rgb) / <alpha-value>)",
"accent-ink": "#74aaff",
sunken: "var(--st-sunken)",
}, },
"base-fg": "rgb(var(--st-fg-rgb) / <alpha-value>)", "base-fg": "rgb(var(--st-fg-rgb) / <alpha-value>)",
media: { media: {
@@ -288,6 +326,9 @@ export default {
}, },
plugins: [], plugins: [],
safelist: [ safelist: [
// lucide-react adds this class at runtime; keep the base-layer sizing rule
// in styles/tailwind.css from being purged.
"lucide",
"bg-character-selected", "bg-character-selected",
"bg-character-unselected", "bg-character-unselected",
"bg-character-clip", "bg-character-clip",
@@ -12,7 +12,7 @@ export const Badge = ({ label, className, icon }: BadgeProps) => {
return ( return (
<div <div
className={twMerge( className={twMerge(
"flex items-center gap-1 rounded-[6px] bg-ui-controls/45 px-[4px] py-[1px] text-[10px] shadow-sm", "flex items-center gap-1 rounded-none border border-ui-controls-border bg-black/40 px-1.5 py-px font-mono text-[10px] font-medium uppercase tracking-[0.1em]",
className className
)} )}
> >
@@ -101,7 +101,7 @@ export const ButtonDropdown = ({
<Menu.Items <Menu.Items
static static
className={twMerge( className={twMerge(
"absolute z-20 mt-1 w-max divide-y divide-gray-100 overflow-hidden rounded-lg bg-brand-secondary py-1.5 shadow-xl focus:outline-none", "absolute z-20 mt-2 w-max min-w-48 overflow-hidden rounded-[3px] border border-ui-panel-border bg-ui-controls p-1.5 text-base-fg focus:outline-none",
align === "left" ? "left-0" : "right-0", align === "left" ? "left-0" : "right-0",
)} )}
> >
@@ -109,19 +109,19 @@ export const ButtonDropdown = ({
{options.map((option, index) => ( {options.map((option, index) => (
<Fragment key={index}> <Fragment key={index}>
{option.divider && ( {option.divider && (
<div className="my-1.5 border-t border-white/5" /> <div className="my-1.5 border-t border-white/15" />
)} )}
<Menu.Item> <Menu.Item>
{({ active }) => ( {({ active }) => (
<button <button
disabled={option.disabled} disabled={option.disabled}
className={twMerge( className={twMerge(
"duration-50 bg-brand-secondary font-medium text-white transition-all", "rounded-[3px] bg-transparent font-medium text-base-fg transition-colors duration-150",
active ? "bg-brand-secondary-800" : "", active ? "bg-white/10" : "",
option.disabled option.disabled
? "pointer-events-none opacity-40" ? "pointer-events-none opacity-40"
: "", : "",
"group flex w-full items-center py-1.5 pl-7 pr-4 text-sm", "group flex w-full items-center gap-2 px-2.5 py-2 text-sm",
option.className, option.className,
)} )}
onClick={() => handleOptionClick(index)} onClick={() => handleOptionClick(index)}
@@ -130,11 +130,11 @@ export const ButtonDropdown = ({
{option.icon && ( {option.icon && (
<DynamicIcon <DynamicIcon
icon={option.icon} icon={option.icon}
className="mr-2" className="mr-2 h-4 w-4 shrink-0"
/> />
)} )}
<div className="grow text-start">{option.label}</div> <div className="grow text-start">{option.label}</div>
<div className="ml-10 font-normal text-white/75"> <div className="ml-6 font-mono text-[11px] font-normal text-white/45">
{option.description && option.description} {option.description && option.description}
</div> </div>
{showSelected && ( {showSelected && (
@@ -15,12 +15,14 @@ interface ButtonIconSelectProps {
options: Option[]; options: Option[];
onOptionChange?: (value: string) => void; onOptionChange?: (value: string) => void;
selectedOption?: string; selectedOption?: string;
buttonClassName?: string;
} }
export function ButtonIconSelect({ export function ButtonIconSelect({
options, options,
onOptionChange, onOptionChange,
selectedOption, selectedOption,
buttonClassName,
}: ButtonIconSelectProps) { }: ButtonIconSelectProps) {
const [internalSelectedOption, setInternalSelectedOption] = useState<string>( const [internalSelectedOption, setInternalSelectedOption] = useState<string>(
selectedOption || options[0].value selectedOption || options[0].value
@@ -54,7 +56,8 @@ export function ButtonIconSelect({
text ? "h-auto w-auto gap-2 px-3 py-1.5" : "w-9", text ? "h-auto w-auto gap-2 px-3 py-1.5" : "w-9",
internalSelectedOption === value internalSelectedOption === value
? "border-brand-primary bg-brand-primary/20" ? "border-brand-primary bg-brand-primary/20"
: "border-transparent hover:bg-ui-panel/[0.4]" : "border-transparent hover:bg-ui-panel/[0.4]",
buttonClassName,
)} )}
onClick={() => handleOptionChange(value)} onClick={() => handleOptionChange(value)}
> >
@@ -72,7 +75,8 @@ export function ButtonIconSelect({
text ? "h-auto w-auto gap-2 px-3 py-1.5" : "w-9", text ? "h-auto w-auto gap-2 px-3 py-1.5" : "w-9",
internalSelectedOption === value internalSelectedOption === value
? "border-brand-primary bg-brand-primary/20" ? "border-brand-primary bg-brand-primary/20"
: "border-transparent hover:bg-ui-panel/[0.4]" : "border-transparent hover:bg-ui-panel/[0.4]",
buttonClassName,
)} )}
onClick={() => handleOptionChange(value)} onClick={() => handleOptionChange(value)}
> >
@@ -20,12 +20,12 @@ export const ButtonIcon = ({
...rest ...rest
}: ButtonIconProps) => { }: ButtonIconProps) => {
const className = twMerge( const className = twMerge(
"box-content flex h-8 w-8 items-center justify-center rounded-lg transition-all duration-150", "box-content flex h-8 w-8 items-center justify-center rounded-[3px] transition-colors duration-150",
bgFill bgFill
? "bg-ui-controls-button hover:bg-ui-controls-button/[0.75]" ? "bg-ui-controls-button hover:bg-ui-controls-button/[0.75]"
: "bg-transparent hover:bg-ui-panel/[0.4]", : "bg-transparent hover:bg-ui-panel/[0.4]",
disabled && "opacity-50 hover:bg-transparent", disabled && "opacity-50 hover:bg-transparent",
propsClassName propsClassName,
); );
return ( return (
@@ -37,8 +37,8 @@ export const GenerateButton = ({
disabled && "opacity-50", disabled && "opacity-50",
)} )}
> >
<CoinsIcon className="text-xs text-white" /> <CoinsIcon className="text-xs" />
<span className="text-[13px] font-bold text-white/90"> <span className="text-[13px] font-bold">
{credits} {credits}
</span> </span>
</div> </div>
@@ -1,6 +1,6 @@
import type { LucideIcon } from "lucide-react"; import type { LucideIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons"; import { DynamicIcon } from "@storyteller/icons";
import { Button } from "./button"; import { Button, NEUTRAL_BUTTON_HOVER_CLASSES } from "./button";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
interface ToggleButtonProps { interface ToggleButtonProps {
@@ -26,14 +26,15 @@ export const ToggleButton = ({
return ( return (
<Button <Button
className={twMerge( className={twMerge(
// 34px matches the sibling toolbar controls (GenerateButton, the // Same box and type as the PopoverMenu triggers beside it (34px,
// PopoverMenu triggers); the 2px border is inside the box. // text-sm sans, flat control surface + hairline border), overriding
"flex h-[34px] items-center justify-center rounded-lg border-2 border-transparent text-sm text-white backdrop-blur-lg transition-all", // the Button base's mono uppercase label.
hasLabel ? "px-3" : "w-[34px]", "flex h-[34px] items-center justify-center rounded-[3px] border border-ui-controls-border bg-ui-controls py-0 font-sans text-sm font-medium normal-case tracking-normal text-base-fg transition-colors",
hasLabel ? "px-3" : "w-[34px] p-0",
isActive isActive
? "border-white/20 bg-brand-primary/40 hover:border-white/30 hover:bg-brand-primary/40" ? "border-white/40 bg-white/10 hover:border-white/40 hover:bg-white/15"
: "bg-[#5F5F68]/60 hover:bg-[#5F5F68]/90", : NEUTRAL_BUTTON_HOVER_CLASSES,
className className,
)} )}
variant="secondary" variant="secondary"
onClick={onClick} onClick={onClick}
@@ -46,7 +47,7 @@ export const ToggleButton = ({
/> />
)} )}
{label && ( {label && (
<span className="text-sm font-medium text-white/90">{label}</span> <span className="whitespace-nowrap">{label}</span>
)} )}
</span> </span>
</Button> </Button>
@@ -4,6 +4,9 @@ import { DynamicIcon } from "@storyteller/icons";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { ButtonHTMLAttributes, AnchorHTMLAttributes } from "react"; import { ButtonHTMLAttributes, AnchorHTMLAttributes } from "react";
export const NEUTRAL_BUTTON_HOVER_CLASSES =
"hover:bg-white/[0.07] hover:border-white/25";
type AnchorProps = Omit< type AnchorProps = Omit<
AnchorHTMLAttributes<HTMLAnchorElement>, AnchorHTMLAttributes<HTMLAnchorElement>,
keyof ButtonHTMLAttributes<HTMLButtonElement> keyof ButtonHTMLAttributes<HTMLButtonElement>
@@ -39,20 +42,22 @@ export const Button = ({
function getVariantClassNames(variant: string) { function getVariantClassNames(variant: string) {
switch (variant) { switch (variant) {
case "secondary": { case "secondary": {
return "bg-ui-controls text-base-fg border border-ui-controls-border hover:bg-ui-controls/80 focus-visible:outline-secondary"; // Resting bg-white/5 tint (matching the home grid cards) so the
// button reads as a surface, not a bare outline on the page bg.
return "bg-white/5 text-base-fg border border-ui-controls-border";
} }
case "action": { case "action": {
return "bg-ui-controls text-base-fg border border-ui-controls-border hover:bg-ui-controls/80 focus-visible:outline-action"; return "bg-ui-controls text-base-fg border border-ui-controls-border";
} }
case "destructive": { case "destructive": {
return "bg-red hover:bg-red/90 text-white focus-visible:outline-red"; return "bg-red-500 hover:bg-red-400 text-white";
} }
case "ghost": { case "ghost": {
return "bg-transparent text-base-fg border border-ui-controls-border/70 hover:bg-ui-controls/30 focus-visible:outline-primary-600"; return "bg-transparent text-base-fg/70 hover:text-base-fg";
} }
case "primary": case "primary":
default: { default: {
return "bg-primary hover:bg-primary-400 text-white focus-visible:outline-primary-600"; return "bg-white hover:bg-white/90 text-black font-bold";
} }
} }
} }
@@ -62,8 +67,10 @@ export const Button = ({
); );
const className = twMerge( const className = twMerge(
"w-fit text-sm font-medium rounded-lg px-3 py-1.5 border border-transparent shadow-sm focus-visible:outline focus-visible:outline-0 focus-visible:outline-offset-0 transition-all duration-150 flex gap-2 items-center justify-center active:scale-95 transform", "w-fit rounded-[3px] font-mono text-xs font-semibold uppercase tracking-[0.12em] px-3.5 py-2 border border-transparent focus-visible:outline focus-visible:outline-1 focus-visible:outline-offset-2 focus-visible:outline-white/60 transition-colors duration-150 flex gap-2 items-center justify-center",
getVariantClassNames(propsVariant), getVariantClassNames(propsVariant),
(propsVariant === "secondary" || propsVariant === "action" || propsVariant === "ghost") &&
NEUTRAL_BUTTON_HOVER_CLASSES,
propsClassName, propsClassName,
disabledClass, disabledClass,
); );
@@ -115,25 +115,27 @@ export const CameraSettingsModal = ({
showClose={false} showClose={false}
> >
<div className="grid h-full grid-cols-12 gap-3"> <div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:dark:bg-white/10"> <div className="relative col-span-4 p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-white/15">
<div className="flex items-center justify-between gap-2.5 py-0.5"> <div className="flex items-center justify-between gap-2.5 py-0.5">
<h2 className="text-[18px] font-semibold opacity-80">Camera</h2> <h2 className="hud-label text-base-fg/60">Camera</h2>
<Tooltip content="Add camera" position="top" delay={200}> <Tooltip content="Add camera" position="top" delay={200}>
<button <button
className="h-6 w-6 rounded-full text-white/70 transition-colors hover:text-white/100" className="flex h-6 w-6 items-center justify-center rounded-[3px] text-white/70 transition-colors hover:bg-white/10 hover:text-white"
onClick={onAddCamera} onClick={onAddCamera}
> >
<PlusIcon className="text-xl" /> <PlusIcon className="text-xl" />
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<hr className="my-2 w-full border-white/10" /> <hr className="my-2 w-full border-white/15" />
<div className="space-y-1"> <div className="space-y-1">
{cameras.map((camera) => ( {cameras.map((camera) => (
<button <button
key={camera.id} key={camera.id}
className={`h-9 w-full rounded-lg p-2 text-left transition-colors duration-100 hover:bg-[#63636B]/40 ${ className={`h-9 w-full rounded-[3px] border-l-2 p-2 text-left transition-colors duration-100 ${
camera.id === selectedCameraId ? "bg-[#63636B]/40" : "" camera.id === selectedCameraId
? "border-white bg-white/10"
: "border-transparent hover:bg-white/5"
}`} }`}
onClick={() => handleCameraSelect(camera)} onClick={() => handleCameraSelect(camera)}
> >
@@ -151,7 +153,7 @@ export const CameraSettingsModal = ({
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
<div> <div>
<div className="flex items-center justify-between gap-2.5 py-0.5 opacity-100"> <div className="flex items-center justify-between gap-2.5 py-0.5 opacity-100">
<h2 className="text-[18px] font-semibold"> <h2 className="font-display text-[18px] font-semibold">
{selectedCamera?.label || "Camera"} {selectedCamera?.label || "Camera"}
</h2> </h2>
<Tooltip <Tooltip
@@ -164,10 +166,10 @@ export const CameraSettingsModal = ({
delay={200} delay={200}
> >
<button <button
className={`h-6 w-6 rounded-lg transition-colors ${ className={`flex h-6 w-6 items-center justify-center rounded-[3px] transition-colors ${
selectedCamera?.id === "main" selectedCamera?.id === "main"
? "cursor-not-allowed text-white/30" ? "cursor-not-allowed text-white/30"
: "text-white/60 hover:text-white/100" : "text-white/60 hover:bg-white/10 hover:text-white"
}`} }`}
onClick={() => onClick={() =>
selectedCamera && selectedCamera &&
@@ -180,10 +182,10 @@ export const CameraSettingsModal = ({
</button> </button>
</Tooltip> </Tooltip>
</div> </div>
<hr className="my-2 w-full border-white/10" /> <hr className="my-2 w-full border-white/15" />
<div className="space-y-4"> <div className="space-y-4">
<div className="space-y-1"> <div className="space-y-1">
<Label htmlFor="camera-name" className="text-sm opacity-70"> <Label htmlFor="camera-name">
Name Name
</Label> </Label>
<Input <Input
@@ -199,7 +201,7 @@ export const CameraSettingsModal = ({
/> />
</div> </div>
<div className="space-y-2"> <div className="space-y-2">
<Label htmlFor="focal-length" className="text-sm opacity-70"> <Label htmlFor="focal-length">
Focal Length Focal Length
</Label> </Label>
<div <div
@@ -220,7 +222,7 @@ export const CameraSettingsModal = ({
showIncrement={true} showIncrement={true}
className="w-full" className="w-full"
/> />
<span className="min-w-[60px] text-sm"> <span className="min-w-[60px] font-mono text-sm tabular-nums">
{selectedCamera?.focalLength || 35}mm {selectedCamera?.focalLength || 35}mm
</span> </span>
</div> </div>
@@ -97,12 +97,12 @@ export const Checkbox = forwardRef<HTMLInputElement, CheckboxProps>(
<span <span
aria-hidden aria-hidden
className={twMerge( className={twMerge(
"relative inline-block shrink-0 rounded-[4px] border transition-colors", "relative inline-block shrink-0 rounded-[3px] border transition-colors",
boxDims, boxDims,
isChecked isChecked
? "border-primary bg-primary text-white" ? "border-white bg-white text-black"
: "border-ui-controls-border bg-ui-controls hover:border-primary/60", : "border-ui-controls-border bg-transparent hover:border-white/60",
"peer-focus-visible:ring-2 peer-focus-visible:ring-primary/40", "peer-focus-visible:ring-1 peer-focus-visible:ring-white/60",
checkboxClassName, checkboxClassName,
)} )}
> >
@@ -22,9 +22,9 @@ export const CloseButton = ({
<button <button
onClick={onClick} onClick={onClick}
className={twMerge( className={twMerge(
"flex items-center justify-center rounded-full bg-black/40 text-white/60 transition-all hover:bg-black/70 hover:text-white", "flex items-center justify-center rounded-[3px] bg-black/40 text-white/60 transition-colors hover:bg-white hover:text-black",
sizeClasses[size], sizeClasses[size],
className className,
)} )}
> >
<XIcon /> <XIcon />
@@ -13,14 +13,14 @@ export const DragAndDropZone = ({
}) => { }) => {
if (files.length === 0) { if (files.length === 0) {
return ( return (
<div className="flex cursor-pointer items-center gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3"> <div className="flex cursor-pointer items-center gap-3.5 rounded-none border border-dashed border-white/20 bg-white/[0.02] p-3 transition-colors hover:border-white/40 hover:bg-white/[0.05]">
<FileUpIcon className="text-4xl" /> <FileUpIcon className="text-4xl text-white/60" />
<div className="flex flex-col gap-0"> <div className="flex flex-col gap-0">
<p className="font-medium"> <p className="font-medium">
<u>Upload a file</u> or drop it here <u>Upload a file</u> or drop it here
</p> </p>
<p className="flex items-center gap-2 text-sm font-normal opacity-50"> <p className="flex items-center gap-2 font-mono text-[11px] font-normal uppercase tracking-[0.12em] opacity-50">
{fileTypes.join(", ").toString()} supported {fileTypes.join(", ").toString()} supported
</p> </p>
</div> </div>
@@ -37,7 +37,7 @@ export const DragAndDropZone = ({
: `${Math.floor(file.size / 1024)} KB`; : `${Math.floor(file.size / 1024)} KB`;
return ( return (
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3"> <div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-none border border-dashed border-white/20 bg-white/[0.02] p-3 transition-colors hover:border-white/40 hover:bg-white/[0.05]">
<DynamicIcon icon={fileIcon} className="text-4xl" /> <DynamicIcon icon={fileIcon} className="text-4xl" />
<div className="flex grow flex-col gap-0"> <div className="flex grow flex-col gap-0">
<p className="font-medium"> <p className="font-medium">
@@ -55,7 +55,7 @@ export const DragAndDropZone = ({
} }
return ( return (
<div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-lg border border-dashed border-[#363636] bg-brand-secondary p-3"> <div className="flex cursor-pointer items-center justify-between gap-3.5 rounded-none border border-dashed border-white/20 bg-white/[0.02] p-3 transition-colors hover:border-white/40 hover:bg-white/[0.05]">
<DynamicIcon icon={fileIcon} className="text-4xl" /> <DynamicIcon icon={fileIcon} className="text-4xl" />
<div className="flex grow flex-col gap-0"> <div className="flex grow flex-col gap-0">
<p className="font-medium">{files.length} files selected</p> <p className="font-medium">{files.length} files selected</p>
@@ -133,7 +133,7 @@ export function DragGhost({
zIndex: 10000, zIndex: 10000,
pointerEvents: "none", pointerEvents: "none",
}} }}
className="flex cursor-grabbing flex-col overflow-hidden rounded-xl shadow-[0_18px_40px_-12px_rgba(0,0,0,0.7)] ring-1 ring-white/20" className="flex cursor-grabbing flex-col overflow-hidden shadow-[0_18px_40px_-12px_rgba(0,0,0,0.7)] ring-1 ring-white/20"
> >
<img <img
src={state.thumbnail} src={state.thumbnail}
@@ -143,7 +143,7 @@ export function DragGhost({
/> />
{state.indicator && ( {state.indicator && (
<div <div
className={`absolute left-1.5 top-1.5 flex h-5 w-5 items-center justify-center rounded-full shadow-md ring-1 ring-black/30 ${ className={`absolute left-1.5 top-1.5 flex h-5 w-5 items-center justify-center shadow-md ring-1 ring-black/30 ${
state.indicator === "ok" ? "bg-emerald-500" : "bg-red-500" state.indicator === "ok" ? "bg-emerald-500" : "bg-red-500"
}`} }`}
> >
@@ -25,7 +25,7 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
onClick={() => onSetColor(c)} onClick={() => onSetColor(c)}
style={{ backgroundColor: c }} style={{ backgroundColor: c }}
className={twMerge( className={twMerge(
"h-5 w-5 rounded-full border border-black/20 transition-transform hover:scale-110", "h-5 w-5 rounded-[3px] border border-black/20 transition-transform hover:scale-110",
colorCode === c && colorCode === c &&
"ring-2 ring-white ring-offset-1 ring-offset-ui-panel", "ring-2 ring-white ring-offset-1 ring-offset-ui-panel",
)} )}
@@ -37,16 +37,16 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
aria-label="Clear color" aria-label="Clear color"
onClick={() => onSetColor(null)} onClick={() => onSetColor(null)}
className={twMerge( className={twMerge(
"flex h-5 w-5 items-center justify-center rounded-full border border-ui-panel-border text-base-fg/50 hover:text-base-fg", "flex h-5 w-5 items-center justify-center rounded-[3px] border border-ui-panel-border text-base-fg/50 hover:text-base-fg",
!colorCode && "ring-2 ring-white ring-offset-1 ring-offset-ui-panel", !colorCode && "ring-2 ring-white ring-offset-1 ring-offset-ui-panel",
)} )}
> >
<XIcon className="text-[10px]" /> <XIcon className="text-[10px]" />
</button> </button>
{/* Custom hex (native picker) */} {/* Custom hex (native picker) */}
<label <label
title="Custom color" title="Custom color"
className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-full border border-ui-panel-border" className="relative h-5 w-5 cursor-pointer overflow-hidden rounded-[3px] border border-ui-panel-border"
style={{ style={{
background: background:
"conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)", "conic-gradient(red, orange, yellow, lime, cyan, blue, magenta, red)",
@@ -55,7 +55,9 @@ export const FolderColorRow: React.FC<FolderColorRowProps> = ({
<input <input
type="color" type="color"
value={ value={
colorCode && /^#[0-9a-fA-F]{6}$/.test(colorCode) ? colorCode : "#888888" colorCode && /^#[0-9a-fA-F]{6}$/.test(colorCode)
? colorCode
: "#888888"
} }
onChange={(e) => onSetColor(e.target.value)} onChange={(e) => onSetColor(e.target.value)}
className="absolute -inset-1 h-[150%] w-[150%] cursor-pointer opacity-0" className="absolute -inset-1 h-[150%] w-[150%] cursor-pointer opacity-0"
@@ -48,7 +48,7 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
onClose={onClose} onClose={onClose}
title={title} title={title}
accessibleTitle={title} accessibleTitle={title}
className="w-80 max-w-[90vw]" className="w-80 max-w-[90vw] [&_h2]:font-sans [&_h2]:text-sm [&_h2]:font-semibold [&_h2]:tracking-normal [&_h2]:[font-stretch:normal]"
> >
<div className="space-y-3"> <div className="space-y-3">
{subtitle && ( {subtitle && (
@@ -57,26 +57,27 @@ export const FolderNameDialog: React.FC<FolderNameDialogProps> = ({
<input <input
ref={inputRef} ref={inputRef}
type="text" type="text"
aria-label="Folder name"
value={value} value={value}
onChange={(e) => setValue(e.target.value)} onChange={(e) => setValue(e.target.value)}
onKeyDown={(e) => { onKeyDown={(e) => {
if (e.key === "Enter") submit(); if (e.key === "Enter") submit();
}} }}
className="w-full rounded-md border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50" className="w-full rounded-[3px] border border-ui-panel-border bg-ui-controls/40 px-3 py-1.5 text-sm text-base-fg outline-none focus:ring-1 focus:ring-primary/50"
autoFocus autoFocus
/> />
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
<Button <Button
variant="action" variant="action"
onClick={onClose} onClick={onClose}
className="px-3 py-1 text-sm" className="px-3 py-1.5 text-[11px]"
> >
Cancel Cancel
</Button> </Button>
<Button <Button
onClick={submit} onClick={submit}
disabled={!value.trim()} disabled={!value.trim()}
className="px-3 py-1 text-sm" className="px-3 py-1.5 text-[11px]"
> >
{confirmLabel} {confirmLabel}
</Button> </Button>
@@ -1,9 +1,4 @@
import React, { import React, { useEffect, useLayoutEffect, useRef, useState } from "react";
useEffect,
useLayoutEffect,
useRef,
useState,
} from "react";
import { createPortal } from "react-dom"; import { createPortal } from "react-dom";
import { CheckIcon, EllipsisIcon, MusicIcon, UploadIcon } from "lucide-react"; import { CheckIcon, EllipsisIcon, MusicIcon, UploadIcon } from "lucide-react";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner"; import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
@@ -248,7 +243,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
type="button" type="button"
tabIndex={-1} tabIndex={-1}
className={twMerge( className={twMerge(
"w-full group relative overflow-visible rounded-md border-[3px] transition-colors outline-none focus:outline-none focus-visible:outline-none active:outline-none aspect-square", "w-full group relative overflow-visible border-[3px] transition-colors outline-none focus:outline-none focus-visible:outline-none active:outline-none aspect-square",
selected || bulkSelected selected || bulkSelected
? "border-primary" ? "border-primary"
: disabled : disabled
@@ -272,10 +267,8 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{isAudio ? ( {isAudio ? (
<div className="flex h-full w-full flex-col bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02]"> <div className="flex h-full w-full flex-col bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02]">
<div className="flex min-h-0 flex-1 items-center justify-center"> <div className="flex min-h-0 flex-1 items-center justify-center">
<div className="flex h-11 w-11 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15"> <div className="flex h-11 w-11 items-center justify-center bg-white/10 ring-1 ring-white/15">
<MusicIcon <MusicIcon className="text-lg text-white/70" />
className="text-lg text-white/70" />
</div> </div>
</div> </div>
<p className="line-clamp-2 shrink-0 px-2.5 pb-2.5 text-left text-xs leading-snug text-white/75"> <p className="line-clamp-2 shrink-0 px-2.5 pb-2.5 text-left text-xs leading-snug text-white/75">
@@ -316,12 +309,12 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
</> </>
)} )}
{selected && ( {selected && (
<div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-full bg-primary"> <div className="absolute left-2 top-2 flex h-6 w-6 items-center justify-center rounded-[3px] bg-primary">
<CheckIcon className="text-sm" /> <CheckIcon className="text-sm" />
</div> </div>
)} )}
{disabled && ( {disabled && (
<div className="absolute left-2 top-2 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80"> <div className="absolute left-2 top-2 bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80">
Added Added
</div> </div>
)} )}
@@ -356,10 +349,8 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
position="bottom" position="bottom"
align="end" align="end"
mode="default" mode="default"
triggerIcon={ triggerIcon={<EllipsisIcon className="text-base-fg" />}
<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"
}
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"
panelClassName="w-max min-w-44 p-1" panelClassName="w-max min-w-44 p-1"
closeOnUnhover closeOnUnhover
> >
@@ -394,7 +385,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
/> />
<div <div
ref={ctxPanelRef} ref={ctxPanelRef}
className="fixed z-[9999] w-max min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl" className="fixed z-[9999] w-max min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
style={{ left: ctxMenu.x, top: ctxMenu.y }} style={{ left: ctxMenu.x, top: ctxMenu.y }}
> >
<GalleryItemMenuItems <GalleryItemMenuItems
@@ -418,7 +409,7 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
{mode !== "select" && onBulkSelectToggle && ( {mode !== "select" && onBulkSelectToggle && (
<div <div
className={twMerge( className={twMerge(
"absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded border-2 cursor-pointer transition-all duration-100", "absolute left-2 top-2 z-[2] flex h-5 w-5 items-center justify-center rounded-[3px] border-2 cursor-pointer transition-all duration-100",
bulkSelected bulkSelected
? "bg-primary border-primary" ? "bg-primary border-primary"
: "border-white/60 bg-black/40 hover:border-white", : "border-white/60 bg-black/40 hover:border-white",
@@ -432,16 +423,12 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
onBulkSelectToggle?.(); onBulkSelectToggle?.();
}} }}
> >
{bulkSelected && ( {bulkSelected && <CheckIcon className="text-[10px] text-white" />}
<CheckIcon
className="text-[10px] text-white" />
)}
</div> </div>
)} )}
{/* Media class badge on hover — bottom-left */} {/* Media class badge on hover — bottom-left */}
{!disableTooltipAndBadge && item.mediaClass && ( {!disableTooltipAndBadge && item.mediaClass && (
<div className="pointer-events-none absolute left-2 bottom-2 z-[1] rounded-full bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150"> <div className="pointer-events-none absolute left-2 bottom-2 z-[1] bg-black/50 backdrop-blur-lg px-2 py-0.5 text-[11px] font-semibold uppercase tracking-wide text-white opacity-0 group-hover:opacity-100 transition-opacity duration-150">
{item.mediaClass === "dimensional" || {item.mediaClass === "dimensional" ||
item.mediaClass === "mesh" || item.mediaClass === "mesh" ||
item.mediaClass === "splat" item.mediaClass === "splat"
@@ -451,8 +438,8 @@ export const GalleryDraggableItem: React.FC<GalleryDraggableItemProps> = ({
)} )}
{/* Upload badge — bottom-right (always visible, even in select mode) */} {/* Upload badge — bottom-right (always visible, even in select mode) */}
{item.isUpload && ( {item.isUpload && (
<div className="pointer-events-none absolute right-2 bottom-2 z-[1] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 backdrop-blur-lg text-white"> <div className="pointer-events-none absolute right-2 bottom-2 z-[1] flex h-5 w-5 items-center justify-center bg-black/50 backdrop-blur-lg text-white">
<UploadIcon className="text-[10px]" /> <UploadIcon className="text-[10px]" />
</div> </div>
)} )}
{/* Conditionally wrap with Tooltip — hidden when selecting or in bulk mode */} {/* Conditionally wrap with Tooltip — hidden when selecting or in bulk mode */}
@@ -67,7 +67,7 @@ export const GalleryFolderChip: React.FC<GalleryFolderChipProps> = ({
}} }}
style={colorCode ? { borderColor: colorCode } : undefined} style={colorCode ? { borderColor: colorCode } : undefined}
className={twMerge( className={twMerge(
"group/chip relative w-full aspect-square overflow-hidden rounded-md border-2 border-ui-controls/40 bg-ui-controls/20 transition-[transform,border-color,background-color,box-shadow] duration-200 ease-out cursor-pointer hover:border-primary/60", "group/chip relative w-full aspect-square overflow-hidden rounded-[3px] border-2 border-ui-controls/40 bg-ui-controls/20 transition-[transform,border-color,background-color,box-shadow] duration-200 ease-out cursor-pointer hover:border-primary/60",
// A dragged item hovering the folder makes it lift and glow — clear, // A dragged item hovering the folder makes it lift and glow — clear,
// tactile "drop here" feedback rather than a flat color swap. // tactile "drop here" feedback rather than a flat color swap.
"[&.folder-drag-over]:scale-[1.04] [&.folder-drag-over]:border-primary [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:ring-2 [&.folder-drag-over]:ring-primary/40", "[&.folder-drag-over]:scale-[1.04] [&.folder-drag-over]:border-primary [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:ring-2 [&.folder-drag-over]:ring-primary/40",
@@ -168,7 +168,7 @@ export const GalleryFolderChip: React.FC<GalleryFolderChipProps> = ({
e.stopPropagation(); e.stopPropagation();
openMenuAt(e.currentTarget as HTMLElement); openMenuAt(e.currentTarget as HTMLElement);
}} }}
className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-full bg-black/40 text-white opacity-0 transition-opacity hover:bg-black/60 group-hover/chip:opacity-100 [@media(pointer:coarse)]:opacity-100" className="absolute right-1.5 top-1.5 flex h-7 w-7 items-center justify-center rounded-[3px] bg-black/40 text-white opacity-0 transition-opacity hover:bg-black/60 group-hover/chip:opacity-100 [@media(pointer:coarse)]:opacity-100"
> >
<EllipsisIcon className="text-sm" /> <EllipsisIcon className="text-sm" />
</span> </span>
@@ -1,5 +1,14 @@
import React, { useState } from "react"; import React, { useState } from "react";
import { ChevronRightIcon, EyeIcon, FolderIcon, FolderMinusIcon, FolderPlusIcon, PencilIcon, PlusIcon, Trash2Icon } from "lucide-react"; import {
ChevronRightIcon,
EyeIcon,
FolderIcon,
FolderMinusIcon,
FolderPlusIcon,
PencilIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { twMerge } from "tailwind-merge"; import { twMerge } from "tailwind-merge";
import { GalleryItem } from "./gallery-modal"; import { GalleryItem } from "./gallery-modal";
import { GalleryFolder } from "./GalleryDraggableItem"; import { GalleryFolder } from "./GalleryDraggableItem";
@@ -28,7 +37,7 @@ export interface GalleryItemMenuItemsProps {
} }
const ROW = const ROW =
"flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm whitespace-nowrap"; "flex w-full items-center gap-2 rounded-[3px] px-2 py-2 hover:bg-ui-controls/60 text-base-fg text-sm whitespace-nowrap";
/** /**
* The gallery-tile menu body (Open / Edit / Add to Folder ▸ / Remove from * The gallery-tile menu body (Open / Edit / Add to Folder ▸ / Remove from
@@ -60,7 +69,7 @@ export const GalleryItemMenuItems: React.FC<GalleryItemMenuItemsProps> = ({
onOpen(); onOpen();
}} }}
> >
<EyeIcon className="text-base-fg w-4" /> <EyeIcon className="text-base-fg w-4" />
<span> <span>
{item.mediaClass === "video" {item.mediaClass === "video"
? "View video" ? "View video"
@@ -85,7 +94,7 @@ export const GalleryItemMenuItems: React.FC<GalleryItemMenuItemsProps> = ({
close(); close();
}} }}
> >
<PencilIcon className="text-base-fg w-4" /> <PencilIcon className="text-base-fg w-4" />
<span>Edit image</span> <span>Edit image</span>
</button> </button>
)} )}
@@ -103,28 +112,28 @@ export const GalleryItemMenuItems: React.FC<GalleryItemMenuItemsProps> = ({
> >
<button <button
type="button" type="button"
className="flex w-full items-center justify-between gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm whitespace-nowrap" className="flex w-full items-center justify-between gap-2 rounded-[3px] px-2 py-2 hover:bg-ui-controls/60 text-base-fg text-sm whitespace-nowrap"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setFolderSubmenuOpen((v) => !v); setFolderSubmenuOpen((v) => !v);
}} }}
> >
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<FolderPlusIcon className="text-base-fg w-4" /> <FolderPlusIcon className="text-base-fg w-4" />
<span>Add to Folder</span> <span>Add to Folder</span>
</div> </div>
<ChevronRightIcon <ChevronRightIcon
className={twMerge( className={twMerge(
"text-[10px] text-base-fg/50 transition-transform", "text-[10px] text-base-fg/50 transition-transform",
COARSE_POINTER && folderSubmenuOpen && "rotate-90", COARSE_POINTER && folderSubmenuOpen && "rotate-90",
)} /> )}
/>
</button> </button>
{folderSubmenuOpen && {folderSubmenuOpen &&
(COARSE_POINTER ? ( (COARSE_POINTER ? (
// Touch: expand inline (accordion) — a fly-out would overflow // Touch: expand inline (accordion) — a fly-out would overflow
// the screen edge on narrow viewports. // the screen edge on narrow viewports.
<div className="mb-1 max-h-48 overflow-y-auto rounded-md bg-ui-controls/20 p-1"> <div className="mb-1 max-h-48 overflow-y-auto bg-ui-controls/20 p-1">
<FolderList <FolderList
item={item} item={item}
folders={folders} folders={folders}
@@ -138,7 +147,7 @@ export const GalleryItemMenuItems: React.FC<GalleryItemMenuItemsProps> = ({
</div> </div>
) : ( ) : (
<div className="absolute left-full top-0 -ml-1 pl-2 z-50"> <div className="absolute left-full top-0 -ml-1 pl-2 z-50">
<div className="max-h-64 overflow-y-auto w-max min-w-36 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"> <div className="max-h-64 overflow-y-auto w-max min-w-36 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1 shadow-xl">
<FolderList <FolderList
item={item} item={item}
folders={folders} folders={folders}
@@ -165,7 +174,7 @@ export const GalleryItemMenuItems: React.FC<GalleryItemMenuItemsProps> = ({
close(); close();
}} }}
> >
<FolderMinusIcon className="text-base-fg w-4" /> <FolderMinusIcon className="text-base-fg w-4" />
<span>Remove from folder</span> <span>Remove from folder</span>
</button> </button>
)} )}
@@ -173,14 +182,14 @@ export const GalleryItemMenuItems: React.FC<GalleryItemMenuItemsProps> = ({
{onDelete && ( {onDelete && (
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm whitespace-nowrap" className="flex w-full items-center gap-2 rounded-[3px] px-2 py-2 hover:bg-ui-controls/60 text-sm whitespace-nowrap"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
close(); close();
onDelete(); onDelete();
}} }}
> >
<Trash2Icon className="text-red w-4" /> <Trash2Icon className="text-red w-4" />
<span className="text-red">Delete</span> <span className="text-red">Delete</span>
</button> </button>
)} )}
@@ -209,7 +218,7 @@ const FolderList = ({
<button <button
key={folder.id} key={folder.id}
type="button" type="button"
className="flex w-full items-center gap-2 px-2 py-1.5 rounded-md hover:bg-ui-controls/60 text-base-fg text-sm" className="flex w-full items-center gap-2 rounded-[3px] px-2 py-1.5 hover:bg-ui-controls/60 text-base-fg text-sm"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onAddToFolder?.([item.id], folder.id); onAddToFolder?.([item.id], folder.id);
@@ -217,9 +226,9 @@ const FolderList = ({
}} }}
> >
<FolderIcon <FolderIcon
className={folder.colorCode ? "text-xs" : "text-primary text-xs"} className={folder.colorCode ? "text-xs" : "text-primary text-xs"}
style={folder.colorCode ? { color: folder.colorCode } : undefined} /> style={folder.colorCode ? { color: folder.colorCode } : undefined}
/>
<span className="truncate">{folder.name}</span> <span className="truncate">{folder.name}</span>
</button> </button>
))} ))}
@@ -228,14 +237,14 @@ const FolderList = ({
)} )}
<button <button
type="button" type="button"
className="flex w-full items-center gap-2 px-2 py-1.5 rounded-md hover:bg-ui-controls/60 text-base-fg/70 text-sm whitespace-nowrap" className="flex w-full items-center gap-2 rounded-[3px] px-2 py-1.5 hover:bg-ui-controls/60 text-base-fg/70 text-sm whitespace-nowrap"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
closeAll(); closeAll();
onCreateFolderFromMenu?.(); onCreateFolderFromMenu?.();
}} }}
> >
<PlusIcon className="text-xs w-4" /> <PlusIcon className="text-xs w-4" />
<span>New Folder</span> <span>New Folder</span>
</button> </button>
</> </>
@@ -135,7 +135,7 @@ const SkeletonGrid = ({ columns }: { columns: number }) => {
<div className="space-y-6 p-4 animate-in fade-in duration-200"> <div className="space-y-6 p-4 animate-in fade-in duration-200">
{/* Fake date heading */} {/* Fake date heading */}
<div> <div>
<div className="h-4 w-24 rounded bg-white/[0.06] mb-3" /> <div className="h-4 w-24 bg-white/[0.06] mb-3" />
<div <div
className={twMerge("grid", gapClass)} className={twMerge("grid", gapClass)}
style={{ style={{
@@ -143,7 +143,7 @@ const SkeletonGrid = ({ columns }: { columns: number }) => {
}} }}
> >
{Array.from({ length: SKELETON_COUNT }).map((_, i) => ( {Array.from({ length: SKELETON_COUNT }).map((_, i) => (
<div key={i} className="aspect-square rounded-md overflow-hidden"> <div key={i} className="aspect-square overflow-hidden">
<div <div
className="h-full w-full bg-white/[0.06]" className="h-full w-full bg-white/[0.06]"
style={{ style={{
@@ -477,7 +477,7 @@ const BulkThumb = ({
const showImage = !!thumbnail && !failed; const showImage = !!thumbnail && !failed;
return ( return (
<div className="h-8 w-8 rounded overflow-hidden border-2 border-ui-panel bg-black/30 flex-shrink-0"> <div className="h-8 w-8 overflow-hidden border-2 border-ui-panel bg-black/30 flex-shrink-0">
{showImage ? ( {showImage ? (
<img <img
src={thumbnail} src={thumbnail}
@@ -523,14 +523,14 @@ const FolderContextMenuItems = ({
}) => ( }) => (
<div <div
className={twMerge( className={twMerge(
"min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl z-[59]", "min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1 shadow-xl z-[59]",
className, className,
)} )}
style={style} style={style}
> >
<button <button
type="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 rounded-[3px] px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => onToggleStar(folderId, !hasStar)} onClick={() => onToggleStar(folderId, !hasStar)}
> >
<StarIcon <StarIcon
@@ -548,7 +548,7 @@ const FolderContextMenuItems = ({
<div className="mx-1.5 my-1 border-t border-ui-panel-border" /> <div className="mx-1.5 my-1 border-t border-ui-panel-border" />
<button <button
type="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 rounded-[3px] px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => onNewSubfolder(folderId)} onClick={() => onNewSubfolder(folderId)}
> >
<FolderPlusIcon className="w-4" /> <FolderPlusIcon className="w-4" />
@@ -556,7 +556,7 @@ const FolderContextMenuItems = ({
</button> </button>
<button <button
type="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 rounded-[3px] px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => onRename(folderId)} onClick={() => onRename(folderId)}
> >
<PencilIcon className="w-4" /> <PencilIcon className="w-4" />
@@ -564,7 +564,7 @@ const FolderContextMenuItems = ({
</button> </button>
<button <button
type="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 rounded-[3px] px-2 py-2 hover:bg-ui-controls/60 text-sm text-red"
onClick={() => onDelete(folderId)} onClick={() => onDelete(folderId)}
> >
<Trash2Icon className="w-4" /> <Trash2Icon className="w-4" />
@@ -2581,7 +2581,7 @@ export const GalleryModal = React.memo(
} }
}} }}
className={twMerge( className={twMerge(
"h-[780px] max-h-[90vh] w-full max-w-5xl rounded-2xl", "h-[780px] max-h-[90vh] w-full max-w-5xl",
mode === "view" && mode === "view" &&
"h-[780px] min-h-[640px] min-w-[66rem] w-[66rem] max-w-none", "h-[780px] min-h-[640px] min-w-[66rem] w-[66rem] max-w-none",
)} )}
@@ -2597,18 +2597,18 @@ export const GalleryModal = React.memo(
</Modal.DragHandle> </Modal.DragHandle>
)} )}
<div className="relative flex h-full flex-col"> <div className="relative flex h-full flex-col">
<div className="border-b border-ui-panel-border p-4 py-3 bg-ui-panel rounded-t-xl"> <div className="border-b border-ui-panel-border p-4 py-3 bg-ui-panel">
<div className="flex flex-wrap justify-between items-center gap-y-2"> <div className="flex flex-wrap justify-between items-center gap-y-2">
<div className="flex items-center gap-3 flex-wrap gap-y-1"> <div className="flex items-center gap-3 flex-wrap gap-y-1">
{(mode === "view" || mode === "select") && ( {(mode === "view" || mode === "select") && (
<div className="flex items-center gap-0.5 rounded-lg bg-ui-controls/40 p-0.5 relative z-[51]"> <div className="flex items-center gap-0.5 rounded-[3px] bg-ui-controls/40 p-0.5 relative z-[51]">
{(["unsorted", "folders"] as const).map((t) => ( {(["unsorted", "folders"] as const).map((t) => (
<button <button
key={t} key={t}
type="button" type="button"
onClick={() => switchGalleryTab(t)} onClick={() => switchGalleryTab(t)}
className={twMerge( className={twMerge(
"rounded-md px-3 py-1 text-sm font-medium transition-colors", "rounded-[3px] px-3 py-1 text-sm font-medium transition-colors",
galleryTab === t galleryTab === t
? "bg-ui-controls text-base-fg" ? "bg-ui-controls text-base-fg"
: "text-base-fg/60 hover:text-base-fg", : "text-base-fg/60 hover:text-base-fg",
@@ -2625,7 +2625,7 @@ export const GalleryModal = React.memo(
<button <button
type="button" type="button"
onClick={() => handleOpenNewFolderModal(null)} onClick={() => handleOpenNewFolderModal(null)}
className="flex items-center gap-2 rounded-lg bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-base-fg hover:bg-ui-controls/90 transition-colors relative z-[51]" className="flex items-center gap-2 rounded-[3px] bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-base-fg hover:bg-ui-controls/90 transition-colors relative z-[51]"
> >
<FolderPlusIcon className="text-xs" /> <FolderPlusIcon className="text-xs" />
New folder New folder
@@ -2654,7 +2654,7 @@ export const GalleryModal = React.memo(
type="button" type="button"
onClick={handleCloseTagView} onClick={handleCloseTagView}
aria-label="Close tag view" aria-label="Close tag view"
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-ui-controls/60 text-base-fg/60 hover:text-base-fg transition-colors" className="flex h-7 w-7 items-center justify-center rounded-[3px] hover:bg-ui-controls/60 text-base-fg/60 hover:text-base-fg transition-colors"
> >
<XIcon className="text-sm" /> <XIcon className="text-sm" />
</button> </button>
@@ -2668,7 +2668,7 @@ export const GalleryModal = React.memo(
type="button" type="button"
onClick={handleCloseTagView} onClick={handleCloseTagView}
aria-label="Close tags" aria-label="Close tags"
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-ui-controls/60 text-base-fg/60 hover:text-base-fg transition-colors" className="flex h-7 w-7 items-center justify-center rounded-[3px] hover:bg-ui-controls/60 text-base-fg/60 hover:text-base-fg transition-colors"
> >
<XIcon className="text-sm" /> <XIcon className="text-sm" />
</button> </button>
@@ -2709,12 +2709,12 @@ export const GalleryModal = React.memo(
if (e.key === "Escape") setRenamingFolderId(null); if (e.key === "Escape") setRenamingFolderId(null);
}} }}
onBlur={handleConfirmRename} onBlur={handleConfirmRename}
className="text-lg font-semibold bg-transparent rounded px-1.5 py-0 text-base-fg outline-none min-w-[6rem] caret-primary" className="text-lg font-semibold bg-transparent px-1.5 py-0 text-base-fg outline-none min-w-[6rem] caret-primary"
autoFocus autoFocus
/> />
) : ( ) : (
<h2 <h2
className="text-lg font-semibold cursor-pointer hover:bg-ui-controls/30 rounded px-1.5 py-0 transition-colors truncate max-w-[20rem]" className="text-lg font-semibold cursor-pointer rounded-[3px] hover:bg-ui-controls/30 px-1.5 py-0 transition-colors truncate max-w-[20rem]"
onClick={() => handleStartRename(activeFolderId!)} onClick={() => handleStartRename(activeFolderId!)}
> >
{activeFolder.name} {activeFolder.name}
@@ -2732,7 +2732,7 @@ export const GalleryModal = React.memo(
<button <button
type="button" type="button"
onClick={() => setFolderMenuOpen((v) => !v)} onClick={() => setFolderMenuOpen((v) => !v)}
className="flex h-7 w-7 items-center justify-center rounded-full hover:bg-ui-controls/60 text-base-fg/60 hover:text-base-fg transition-colors" className="flex h-7 w-7 items-center justify-center rounded-[3px] hover:bg-ui-controls/60 text-base-fg/60 hover:text-base-fg transition-colors"
> >
<EllipsisIcon className="text-sm" /> <EllipsisIcon className="text-sm" />
</button> </button>
@@ -2916,7 +2916,7 @@ export const GalleryModal = React.memo(
} }
}} }}
className={twMerge( className={twMerge(
"flex items-center justify-between gap-2 rounded-md px-2.5 py-1.5 text-sm transition-colors", "flex items-center justify-between gap-2 rounded-[3px] px-2.5 py-1.5 text-sm transition-colors",
isActive && isActive &&
!activeFolderId && !activeFolderId &&
!activeTagToken && !activeTagToken &&
@@ -2949,7 +2949,7 @@ export const GalleryModal = React.memo(
type="button" type="button"
onClick={() => handleOpenNewFolderModal(null)} onClick={() => handleOpenNewFolderModal(null)}
aria-label="New folder" aria-label="New folder"
className="flex h-5 w-5 items-center justify-center rounded text-base-fg/50 hover:bg-ui-controls/60 hover:text-base-fg transition-colors" className="flex h-5 w-5 items-center justify-center rounded-[3px] text-base-fg/50 hover:bg-ui-controls/60 hover:text-base-fg transition-colors"
> >
<PlusIcon className="text-[10px]" /> <PlusIcon className="text-[10px]" />
</button> </button>
@@ -2982,7 +2982,7 @@ export const GalleryModal = React.memo(
: undefined : undefined
} }
className={twMerge( className={twMerge(
"flex w-full flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-base-fg", "flex w-full flex-shrink-0 items-center gap-2.5 rounded-[3px] px-2.5 py-1.5 text-sm transition-colors [&.folder-drag-over]:bg-primary/20 [&.folder-drag-over]:text-base-fg",
activeRootFolderId === folder.id activeRootFolderId === folder.id
? "bg-ui-controls/60 text-base-fg font-medium" ? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg", : "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
@@ -3025,7 +3025,7 @@ export const GalleryModal = React.memo(
handleOpenTagFromSidebar(tag.tag_token) handleOpenTagFromSidebar(tag.tag_token)
} }
className={twMerge( className={twMerge(
"flex w-full flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors", "flex w-full flex-shrink-0 items-center gap-2.5 rounded-[3px] px-2.5 py-1.5 text-sm transition-colors",
activeTagToken === tag.tag_token activeTagToken === tag.tag_token
? "bg-ui-controls/60 text-base-fg font-medium" ? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg", : "text-base-fg/70 hover:bg-ui-controls/30 hover:text-base-fg",
@@ -3042,7 +3042,7 @@ export const GalleryModal = React.memo(
type="button" type="button"
onClick={handleOpenTagBrowser} onClick={handleOpenTagBrowser}
className={twMerge( className={twMerge(
"flex w-full flex-shrink-0 items-center gap-2.5 rounded-md px-2.5 py-1.5 text-sm transition-colors", "flex w-full flex-shrink-0 items-center gap-2.5 rounded-[3px] px-2.5 py-1.5 text-sm transition-colors",
tagBrowserOpen && !activeTagToken tagBrowserOpen && !activeTagToken
? "bg-ui-controls/60 text-base-fg font-medium" ? "bg-ui-controls/60 text-base-fg font-medium"
: "text-base-fg/50 hover:bg-ui-controls/30 hover:text-base-fg", : "text-base-fg/50 hover:bg-ui-controls/30 hover:text-base-fg",
@@ -3079,7 +3079,7 @@ export const GalleryModal = React.memo(
</div> </div>
) : activeTagItems.length === 0 ? ( ) : activeTagItems.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3"> <div className="flex h-full flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-400/10"> <div className="flex h-16 w-16 items-center justify-center bg-violet-400/10">
<TagIcon className="text-violet-400 text-2xl" /> <TagIcon className="text-violet-400 text-2xl" />
</div> </div>
<div className="text-base-fg font-semibold"> <div className="text-base-fg font-semibold">
@@ -3170,7 +3170,7 @@ export const GalleryModal = React.memo(
/* ── All-tags word cloud ── */ /* ── All-tags word cloud ── */
userTags.length === 0 ? ( userTags.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3"> <div className="flex h-full flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-violet-400/10"> <div className="flex h-16 w-16 items-center justify-center bg-violet-400/10">
<TagIcon className="text-violet-400 text-2xl" /> <TagIcon className="text-violet-400 text-2xl" />
</div> </div>
<div className="text-base-fg font-semibold"> <div className="text-base-fg font-semibold">
@@ -3191,7 +3191,7 @@ export const GalleryModal = React.memo(
/* ── Folders tab, root: folder cards only (above) ── */ /* ── Folders tab, root: folder cards only (above) ── */
currentSubfolders.length === 0 ? ( currentSubfolders.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-3"> <div className="flex h-full flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10"> <div className="flex h-16 w-16 items-center justify-center bg-primary/10">
<FolderIcon className="text-primary text-2xl" /> <FolderIcon className="text-primary text-2xl" />
</div> </div>
<div className="text-base-fg font-semibold"> <div className="text-base-fg font-semibold">
@@ -3223,7 +3223,7 @@ export const GalleryModal = React.memo(
) : activeFolderItems.length === 0 ? ( ) : activeFolderItems.length === 0 ? (
currentSubfolders.length > 0 ? null : ( currentSubfolders.length > 0 ? null : (
<div className="flex h-full flex-col items-center justify-center gap-3"> <div className="flex h-full flex-col items-center justify-center gap-3">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-primary/10"> <div className="flex h-16 w-16 items-center justify-center bg-primary/10">
<FolderIcon className="text-primary text-2xl" /> <FolderIcon className="text-primary text-2xl" />
</div> </div>
<div className="text-base-fg font-semibold"> <div className="text-base-fg font-semibold">
@@ -3341,7 +3341,7 @@ export const GalleryModal = React.memo(
</div> </div>
{onLoginClick ? ( {onLoginClick ? (
<button <button
className="rounded-full bg-primary px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50" className="rounded-[3px] bg-primary px-5 py-2 text-sm font-semibold text-white transition-colors hover:bg-primary/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/50"
onClick={() => onLoginClick()} onClick={() => onLoginClick()}
> >
Log in Log in
@@ -3523,7 +3523,7 @@ export const GalleryModal = React.memo(
/> />
{mode === "view" && bulkSelectionMode && ( {mode === "view" && bulkSelectionMode && (
<div className="flex items-center justify-between border-t border-ui-panel-border bg-ui-background p-3 py-2 rounded-b-xl"> <div className="flex items-center justify-between border-t border-ui-panel-border bg-ui-background p-3 py-2">
<div className="flex items-center"> <div className="flex items-center">
{/* Thumbnail previews of selected items */} {/* Thumbnail previews of selected items */}
<div className="flex"> <div className="flex">
@@ -3548,7 +3548,7 @@ export const GalleryModal = React.memo(
})} })}
</div> </div>
{bulkSelectedItems.length > 4 && ( {bulkSelectedItems.length > 4 && (
<div className="h-8 w-8 rounded overflow-hidden border-2 border-ui-panel bg-black/20 flex-shrink-0 flex items-center justify-center"> <div className="h-8 w-8 overflow-hidden border-2 border-ui-panel bg-black/20 flex-shrink-0 flex items-center justify-center">
<span className="text-[11px] font-normal text-white/70"> <span className="text-[11px] font-normal text-white/70">
+{bulkSelectedItems.length - 4} +{bulkSelectedItems.length - 4}
</span> </span>
@@ -3576,7 +3576,7 @@ export const GalleryModal = React.memo(
className="fixed inset-0 z-[59]" className="fixed inset-0 z-[59]"
onClick={() => setBulkFolderPopoverOpen(false)} onClick={() => setBulkFolderPopoverOpen(false)}
/> />
<div className="absolute bottom-full mb-2 right-0 w-56 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl z-[60]"> <div className="absolute bottom-full mb-2 right-0 w-56 rounded-[3px] border border-ui-panel-border bg-ui-panel p-2 shadow-xl z-[60]">
{/* Folders */} {/* Folders */}
<div className="text-[11px] font-semibold uppercase tracking-wider text-base-fg/40 px-2 py-1"> <div className="text-[11px] font-semibold uppercase tracking-wider text-base-fg/40 px-2 py-1">
Folders Folders
@@ -3592,7 +3592,7 @@ export const GalleryModal = React.memo(
<button <button
key={folder.id} key={folder.id}
type="button" type="button"
className="flex w-full items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-ui-controls/50 text-sm text-base-fg transition-colors" className="flex w-full items-center gap-2.5 rounded-[3px] px-2 py-1.5 hover:bg-ui-controls/50 text-sm text-base-fg transition-colors"
onClick={() => { onClick={() => {
const ids = Array.from(bulkSelectedIds); const ids = Array.from(bulkSelectedIds);
requestFolderDrop(ids, folder.id); requestFolderDrop(ids, folder.id);
@@ -3607,7 +3607,7 @@ export const GalleryModal = React.memo(
<div className="mx-1.5 my-1 border-t border-ui-panel-border" /> <div className="mx-1.5 my-1 border-t border-ui-panel-border" />
<button <button
type="button" type="button"
className="flex w-full items-center gap-2.5 px-2 py-1.5 rounded-md hover:bg-ui-controls/50 text-sm text-base-fg/70 transition-colors" className="flex w-full items-center gap-2.5 rounded-[3px] px-2 py-1.5 hover:bg-ui-controls/50 text-sm text-base-fg/70 transition-colors"
onClick={() => { onClick={() => {
const ids = Array.from(bulkSelectedIds); const ids = Array.from(bulkSelectedIds);
setBulkFolderPopoverOpen(false); setBulkFolderPopoverOpen(false);
@@ -3633,7 +3633,7 @@ export const GalleryModal = React.memo(
)} )}
<button <button
onClick={clearBulkSelection} onClick={clearBulkSelection}
className="flex h-8 w-8 items-center justify-center rounded-md bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg transition-colors" className="flex h-8 w-8 items-center justify-center rounded-[3px] bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg transition-colors"
aria-label="Clear selection" aria-label="Clear selection"
> >
<XIcon className="text-base" /> <XIcon className="text-base" />
@@ -3643,7 +3643,7 @@ export const GalleryModal = React.memo(
)} )}
{mode === "select" && ( {mode === "select" && (
<div className="flex items-center justify-between border-t border-ui-panel-border bg-ui-panel p-4 rounded-b-xl"> <div className="flex items-center justify-between border-t border-ui-panel-border bg-ui-panel p-4">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<div className="text-sm font-semibold text-base-fg/80"> <div className="text-sm font-semibold text-base-fg/80">
{selectedItemIds.length}/{maxSelections} selected {selectedItemIds.length}/{maxSelections} selected
@@ -3756,7 +3756,7 @@ export const GalleryModal = React.memo(
createPortal( createPortal(
<div <div
ref={marqueeRef} ref={marqueeRef}
className="pointer-events-none fixed z-[9999] rounded-sm border border-primary/60 bg-primary/10" className="pointer-events-none fixed z-[9999] border border-primary/60 bg-primary/10"
style={{ display: "none" }} style={{ display: "none" }}
/>, />,
document.body, document.body,
@@ -37,7 +37,7 @@ export function CopyPromptButton({
type="button" type="button"
onClick={handleCopy} onClick={handleCopy}
aria-label="Copy prompt" aria-label="Copy prompt"
className="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" className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[3px] text-white/40 transition-colors hover:bg-white/10 hover:text-white"
> >
<DynamicIcon icon={copied ? CheckIcon : CopyIcon} className="text-sm" /> <DynamicIcon icon={copied ? CheckIcon : CopyIcon} className="text-sm" />
</button> </button>
@@ -32,7 +32,7 @@ export const FailedCard = memo(function FailedCard({
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null; const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
return ( return (
<div className="relative aspect-square w-full overflow-hidden rounded-lg bg-red-500/10"> <div className="relative aspect-square w-full overflow-hidden rounded-[3px] bg-red-500/10">
{refImageUrl && ( {refImageUrl && (
<img <img
src={refImageUrl} src={refImageUrl}
@@ -42,9 +42,7 @@ export const FailedCard = memo(function FailedCard({
/> />
)} )}
<div className="relative flex h-full flex-col items-center justify-center gap-2 px-4 sm:px-6"> <div className="relative flex h-full flex-col items-center justify-center gap-2 px-4 sm:px-6">
<CircleAlertIcon <CircleAlertIcon className="text-2xl text-red-400" />
className="text-2xl text-red-400" />
<span className="text-center text-xs font-medium text-red-400"> <span className="text-center text-xs font-medium text-red-400">
{failureReason || "Generation failed"} {failureReason || "Generation failed"}
</span> </span>
@@ -58,7 +56,7 @@ export const FailedCard = memo(function FailedCard({
<button <button
type="button" type="button"
onClick={() => onDismiss(id)} onClick={() => onDismiss(id)}
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/50 transition-colors hover:bg-white/10 hover:text-white/70" className="flex items-center gap-1.5 rounded-[3px] bg-white/5 px-3 py-1.5 text-xs text-white/50 transition-colors hover:bg-white/10 hover:text-white/70"
> >
<XIcon /> <XIcon />
Dismiss Dismiss
@@ -35,9 +35,9 @@ export const FailedRow = memo(function FailedRow({
const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null; const iconPath = modelId ? getCreatorIconPathForModelId(modelId) : null;
return ( return (
<div className="flex items-center gap-3 rounded-lg px-2.5 py-2"> <div className="flex items-center gap-3 px-2.5 py-2">
{/* Error thumbnail — ref image (if any) faded behind the warning icon */} {/* Error thumbnail — ref image (if any) faded behind the warning icon */}
<div className="relative flex size-[100px] shrink-0 items-center justify-center overflow-hidden rounded-md bg-red-500/10 leading-none"> <div className="relative flex size-[100px] shrink-0 items-center justify-center overflow-hidden bg-red-500/10 leading-none">
{refImageUrl && ( {refImageUrl && (
<img <img
src={refImageUrl} src={refImageUrl}
@@ -46,9 +46,7 @@ export const FailedRow = memo(function FailedRow({
className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-10" className="pointer-events-none absolute inset-0 h-full w-full object-cover opacity-10"
/> />
)} )}
<CircleAlertIcon <CircleAlertIcon className="relative text-2xl text-red-400" />
className="relative text-2xl text-red-400" />
</div> </div>
{/* Reason + prompt + model */} {/* Reason + prompt + model */}
@@ -87,7 +85,7 @@ export const FailedRow = memo(function FailedRow({
<button <button
type="button" type="button"
onClick={() => onDismiss(id)} onClick={() => onDismiss(id)}
className="flex shrink-0 items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/50 transition-colors hover:bg-white/10 hover:text-white/70" className="flex shrink-0 items-center gap-1.5 rounded-[3px] bg-white/5 px-3 py-1.5 text-xs text-white/50 transition-colors hover:bg-white/10 hover:text-white/70"
> >
<XIcon /> <XIcon />
Dismiss Dismiss
@@ -24,7 +24,7 @@ export function GalleryAutoplayToggle() {
<div <div
role="group" role="group"
aria-label="Video preview playback" aria-label="Video preview playback"
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5" className="flex items-center gap-0.5 rounded-[3px] border border-white/[0.08] bg-white/[0.04] p-0.5"
> >
{OPTIONS.map(({ autoplay, icon, label }) => { {OPTIONS.map(({ autoplay, icon, label }) => {
const active = autoplayVideos === autoplay; const active = autoplayVideos === autoplay;
@@ -35,7 +35,7 @@ export function GalleryAutoplayToggle() {
aria-label={label} aria-label={label}
aria-pressed={active} aria-pressed={active}
onClick={() => setAutoplayVideos(autoplay)} onClick={() => setAutoplayVideos(autoplay)}
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${ className={`flex h-6 w-6 items-center justify-center rounded-[3px] text-xs transition-colors ${
active active
? "bg-white/10 text-white" ? "bg-white/10 text-white"
: "text-white/45 hover:text-white/80" : "text-white/45 hover:text-white/80"
@@ -1,5 +1,12 @@
import { memo, useCallback, useState, type ReactNode } from "react"; import { memo, useCallback, useState, type ReactNode } from "react";
import { BoxIcon, CheckIcon, EyeIcon, ImageIcon, MusicIcon, VideoIcon } from "lucide-react"; import {
BoxIcon,
CheckIcon,
EyeIcon,
ImageIcon,
MusicIcon,
VideoIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons"; import { DynamicIcon } from "@storyteller/icons";
import { import {
getCreatorIconPathForModelId, getCreatorIconPathForModelId,
@@ -165,7 +172,7 @@ export const GalleryCard = memo(function GalleryCard({
<div <div
role="button" role="button"
tabIndex={0} tabIndex={0}
className={`group relative block w-full rounded-lg bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 cursor-pointer ${isSquare ? "aspect-square" : ""} ${selected ? "ring-2 ring-primary-400" : lastViewed ? "ring-2 ring-primary-400/50" : ""}`} className={`group relative block w-full rounded-[3px] bg-ui-controls/40 leading-none transition-shadow hover:ring-2 hover:ring-primary-400/60 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400 cursor-pointer ${isSquare ? "aspect-square" : ""} ${selected ? "ring-2 ring-primary-400" : lastViewed ? "ring-2 ring-primary-400/50" : ""}`}
style={outerStyle} style={outerStyle}
onClick={handleCardClick} onClick={handleCardClick}
onKeyDown={handleCardKeyDown} onKeyDown={handleCardKeyDown}
@@ -187,9 +194,7 @@ export const GalleryCard = memo(function GalleryCard({
</p> </p>
<div className="flex min-h-0 flex-1 items-center justify-center"> <div className="flex min-h-0 flex-1 items-center justify-center">
<div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15"> <div className="flex h-12 w-12 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15">
<MusicIcon <MusicIcon className="text-xl text-white/70" />
className="text-xl text-white/70" />
</div> </div>
</div> </div>
{item.fullImage && ( {item.fullImage && (
@@ -216,13 +221,13 @@ export const GalleryCard = memo(function GalleryCard({
{/* Selection checkbox chip (select mode only) */} {/* Selection checkbox chip (select mode only) */}
{selectMode && item.fullImage && ( {selectMode && item.fullImage && (
<div <div
className={`pointer-events-none absolute left-2 top-2 z-10 flex h-5 w-5 items-center justify-center rounded-md border transition-colors ${ className={`pointer-events-none absolute left-2 top-2 z-10 flex h-5 w-5 items-center justify-center rounded-[3px] border transition-colors ${
selected selected
? "border-primary-400 bg-primary-400 text-white" ? "border-primary-400 bg-primary-400 text-white"
: "border-white/60 bg-black/40 text-transparent" : "border-white/60 bg-black/40 text-transparent"
}`} }`}
> >
<CheckIcon className="text-[10px]" /> <CheckIcon className="text-[10px]" />
</div> </div>
)} )}
@@ -230,21 +235,21 @@ export const GalleryCard = memo(function GalleryCard({
in the lightbox). Top-right so it never collides with the selection in the lightbox). Top-right so it never collides with the selection
checkbox chip at top-left. */} checkbox chip at top-left. */}
{lastViewed && ( {lastViewed && (
<div className="pointer-events-none absolute right-2 top-2 z-10 flex items-center gap-1 rounded-full bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80"> <div className="pointer-events-none absolute right-2 top-2 z-10 flex items-center gap-1 rounded-[3px] bg-black/70 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-white/80">
<EyeIcon /> <EyeIcon />
Last viewed Last viewed
</div> </div>
)} )}
{/* Hover overlay with media type + model badges and quick actions */} {/* Hover overlay with media type + model badges and quick actions */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t rounded-b-lg from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100"> <div className="pointer-events-none absolute inset-x-0 bottom-0 flex items-end justify-between gap-2 bg-gradient-to-t rounded-b-[3px] from-black/70 to-transparent px-2 pb-2 pt-6 opacity-0 transition-opacity group-hover:opacity-100">
<div className="pointer-events-auto flex min-w-0 flex-wrap items-center gap-1.5"> <div className="pointer-events-auto flex min-w-0 flex-wrap items-center gap-1.5">
<div className="flex items-center gap-1.5 rounded-lg bg-black/60 px-2.5 py-1 text-xs font-medium text-white/90"> <div className="flex items-center gap-1.5 bg-black/60 px-2.5 py-1 text-xs font-medium text-white/90">
<DynamicIcon icon={mediaIcon} className="text-[10px]" /> <DynamicIcon icon={mediaIcon} className="text-[10px]" />
{mediaLabel} {mediaLabel}
</div> </div>
{modelDisplayName && modelIconPath && ( {modelDisplayName && modelIconPath && (
<div className="flex items-center gap-1 rounded-lg bg-black/60 px-2 py-1 text-[10px] text-white/80"> <div className="flex items-center gap-1 bg-black/60 px-2 py-1 text-[10px] text-white/80">
<img <img
src={modelIconPath} src={modelIconPath}
alt="" alt=""
@@ -256,7 +261,7 @@ export const GalleryCard = memo(function GalleryCard({
</div> </div>
{actionsSlot && !selectMode && ( {actionsSlot && !selectMode && (
<div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-lg bg-black/60 p-1 backdrop-blur-sm"> <div className="pointer-events-auto flex shrink-0 items-center gap-0.5 rounded-[3px] bg-black/60 p-1 backdrop-blur-sm">
{actionsSlot} {actionsSlot}
</div> </div>
)} )}
@@ -1,5 +1,13 @@
import { memo, useCallback, type ReactNode } from "react"; import { memo, useCallback, type ReactNode } from "react";
import { BoxIcon, CheckIcon, EyeIcon, ImageIcon, MusicIcon, PlayIcon, VideoIcon } from "lucide-react"; import {
BoxIcon,
CheckIcon,
EyeIcon,
ImageIcon,
MusicIcon,
PlayIcon,
VideoIcon,
} from "lucide-react";
import { import {
getCreatorIconPathForModelId, getCreatorIconPathForModelId,
getModelDisplayName, getModelDisplayName,
@@ -54,7 +62,13 @@ export const GalleryRow = memo(function GalleryRow({
: isAudio : isAudio
? MusicIcon ? MusicIcon
: ImageIcon; : ImageIcon;
const mediaLabel = isVideo ? "Video" : is3D ? "3D" : isAudio ? "Audio" : "Image"; const mediaLabel = isVideo
? "Video"
: is3D
? "3D"
: isAudio
? "Audio"
: "Image";
const effectiveModelId = modelId ?? item.modelId; const effectiveModelId = modelId ?? item.modelId;
const modelDisplayName = effectiveModelId const modelDisplayName = effectiveModelId
@@ -90,23 +104,23 @@ export const GalleryRow = memo(function GalleryRow({
tabIndex={0} tabIndex={0}
onClick={handleRowClick} onClick={handleRowClick}
onKeyDown={handleRowKeyDown} onKeyDown={handleRowKeyDown}
className={`group flex cursor-pointer items-center gap-3 rounded-lg px-2.5 py-2 transition-colors hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400/60 ${selected ? "bg-primary-400/10" : ""}`} className={`group flex cursor-pointer items-center gap-3 rounded-[3px] px-2.5 py-2 transition-colors hover:bg-white/[0.04] focus:outline-none focus-visible:ring-2 focus-visible:ring-primary-400/60 ${selected ? "bg-primary-400/10" : ""}`}
> >
{/* Selection checkbox (select mode only) */} {/* Selection checkbox (select mode only) */}
{selectMode && ( {selectMode && (
<div <div
className={`pointer-events-none flex h-5 w-5 shrink-0 items-center justify-center rounded-md border transition-colors ${ className={`pointer-events-none flex h-5 w-5 shrink-0 items-center justify-center rounded-[3px] border transition-colors ${
selected selected
? "border-primary-400 bg-primary-400 text-white" ? "border-primary-400 bg-primary-400 text-white"
: "border-white/60 bg-black/40 text-transparent" : "border-white/60 bg-black/40 text-transparent"
} ${item.fullImage ? "" : "invisible"}`} } ${item.fullImage ? "" : "invisible"}`}
> >
<CheckIcon className="text-[10px]" /> <CheckIcon className="text-[10px]" />
</div> </div>
)} )}
{/* Thumbnail */} {/* Thumbnail */}
<div className={`relative size-[100px] shrink-0 overflow-hidden rounded-md bg-ui-controls/40 leading-none ${lastViewed ? "ring-2 ring-primary-400/50" : ""}`}> <div className={`relative size-[100px] shrink-0 overflow-hidden bg-ui-controls/40 leading-none ${lastViewed ? "ring-2 ring-primary-400/50" : ""}`}>
<GalleryThumbnail <GalleryThumbnail
thumbnail={item.thumbnail} thumbnail={item.thumbnail}
stillThumbnail={item.stillThumbnail} stillThumbnail={item.stillThumbnail}
@@ -119,9 +133,7 @@ export const GalleryRow = memo(function GalleryRow({
{isVideo && item.thumbnail && ( {isVideo && item.thumbnail && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center"> <div className="pointer-events-none absolute inset-0 flex items-center justify-center">
<span className="flex h-6 w-6 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm"> <span className="flex h-6 w-6 items-center justify-center rounded-full bg-black/55 backdrop-blur-sm">
<PlayIcon <PlayIcon className="ml-0.5 text-[9px] text-white/90" />
className="ml-0.5 text-[9px] text-white/90" />
</span> </span>
</div> </div>
)} )}
@@ -138,7 +150,7 @@ export const GalleryRow = memo(function GalleryRow({
{/* Title + model / media meta */} {/* Title + model / media meta */}
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
{loading ? ( {loading ? (
<div className="relative h-4 w-2/3 max-w-[280px] overflow-hidden rounded bg-white/[0.06]"> <div className="relative h-4 w-2/3 max-w-[280px] overflow-hidden bg-white/[0.06]">
<div className="animate-shimmer absolute inset-0" /> <div className="animate-shimmer absolute inset-0" />
</div> </div>
) : ( ) : (
@@ -155,7 +167,7 @@ export const GalleryRow = memo(function GalleryRow({
</div> </div>
)} )}
{isAudio && item.fullImage && ( {isAudio && item.fullImage && (
<div className="mt-1.5 max-w-md rounded-md bg-white/[0.04]"> <div className="mt-1.5 max-w-md bg-white/[0.04]">
<WaveformAudioPlayer <WaveformAudioPlayer
src={item.fullImage} src={item.fullImage}
durationMillis={item.durationMillis} durationMillis={item.durationMillis}
@@ -11,14 +11,14 @@ export function GallerySelectToggle() {
const label = active ? "Exit selection" : "Select items"; const label = active ? "Exit selection" : "Select items";
return ( return (
<div className="flex items-center rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5"> <div className="flex items-center rounded-[3px] border border-white/[0.08] bg-white/[0.04] p-0.5">
<Tooltip content={label} position="bottom" delay={300}> <Tooltip content={label} position="bottom" delay={300}>
<button <button
type="button" type="button"
aria-label={label} aria-label={label}
aria-pressed={active} aria-pressed={active}
onClick={() => setActive(!active)} onClick={() => setActive(!active)}
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${ className={`flex h-6 w-6 items-center justify-center rounded-[3px] text-xs transition-colors ${
active active
? "bg-white/10 text-white" ? "bg-white/10 text-white"
: "text-white/45 hover:text-white/80" : "text-white/45 hover:text-white/80"
@@ -26,7 +26,7 @@ export function GalleryViewToggle() {
<div <div
role="group" role="group"
aria-label="Gallery layout" aria-label="Gallery layout"
className="flex items-center gap-0.5 rounded-lg border border-white/[0.08] bg-white/[0.04] p-0.5" className="flex items-center gap-0.5 rounded-[3px] border border-white/[0.08] bg-white/[0.04] p-0.5"
> >
{OPTIONS.map(({ mode, icon, label }) => { {OPTIONS.map(({ mode, icon, label }) => {
const active = viewMode === mode; const active = viewMode === mode;
@@ -37,7 +37,7 @@ export function GalleryViewToggle() {
aria-label={label} aria-label={label}
aria-pressed={active} aria-pressed={active}
onClick={() => setViewMode(mode)} onClick={() => setViewMode(mode)}
className={`flex h-6 w-6 items-center justify-center rounded-md text-xs transition-colors ${ className={`flex h-6 w-6 items-center justify-center rounded-[3px] text-xs transition-colors ${
active active
? "bg-white/10 text-white" ? "bg-white/10 text-white"
: "text-white/45 hover:text-white/80" : "text-white/45 hover:text-white/80"
@@ -36,18 +36,16 @@ export const PendingCard = memo(function PendingCard({
const iconPath = getCreatorIconPathForModelId(modelId); const iconPath = getCreatorIconPathForModelId(modelId);
return ( return (
<div className="group relative aspect-square w-full overflow-hidden rounded-lg bg-white/[0.03]"> <div className="group relative aspect-square w-full overflow-hidden rounded-[3px] bg-white/[0.03]">
<div className="animate-shimmer h-full w-full" /> <div className="animate-shimmer h-full w-full" />
{batchCount != null && batchCount > 1 && ( {batchCount != null && batchCount > 1 && (
<div className="absolute left-2 right-2 top-2 z-10 rounded-md bg-black/60 px-2.5 py-1.5 text-center text-[10px] leading-snug text-white/70 backdrop-blur-sm"> <div className="absolute left-2 right-2 top-2 z-10 bg-black/60 px-2.5 py-1.5 text-center text-[10px] leading-snug text-white/70 backdrop-blur-sm">
Generating {batchCount} {batchNoun(mediaClass)} · Results may appear Generating {batchCount} {batchNoun(mediaClass)} · Results may appear
one at a time one at a time
</div> </div>
)} )}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2"> <div className="absolute inset-0 flex flex-col items-center justify-center gap-2">
<LoaderCircleIcon <LoaderCircleIcon className="animate-spin text-2xl text-white/20" />
className="animate-spin text-2xl text-white/20" />
{progressPercent != null && ( {progressPercent != null && (
<span className="text-xs tabular-nums text-white/40"> <span className="text-xs tabular-nums text-white/40">
{progressPercent}% {progressPercent}%
@@ -38,9 +38,9 @@ export const PendingRow = memo(function PendingRow({
const iconPath = getCreatorIconPathForModelId(modelId); const iconPath = getCreatorIconPathForModelId(modelId);
return ( return (
<div className="group flex items-center gap-3 rounded-lg px-2.5 py-2"> <div className="group flex items-center gap-3 px-2.5 py-2">
{/* Thumbnail placeholder */} {/* Thumbnail placeholder */}
<div className="relative size-[100px] shrink-0 overflow-hidden rounded-md bg-white/[0.03] leading-none"> <div className="relative size-[100px] shrink-0 overflow-hidden bg-white/[0.03] leading-none">
<div className="animate-shimmer h-full w-full" /> <div className="animate-shimmer h-full w-full" />
<div className="absolute inset-0 flex items-center justify-center"> <div className="absolute inset-0 flex items-center justify-center">
<LoaderCircleIcon <LoaderCircleIcon
@@ -68,7 +68,7 @@ function Gravatar(props: Props) {
return ( return (
<div <div
className={twMerge( className={twMerge(
"relative aspect-square overflow-hidden rounded-full border border-white", "relative aspect-square overflow-hidden border border-white",
props.className props.className
)} )}
> >
@@ -66,16 +66,16 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
</button> </button>
<div className="article-content"> <div className="article-content">
<h1 className="text-2xl font-bold mb-2">{selectedPost.title}</h1> <h1 className="font-display text-2xl font-bold mb-2">{selectedPost.title}</h1>
{selectedPost.date && ( {selectedPost.date && (
<p className="text-xs text-white/50 mb-4">{selectedPost.date}</p> <p className="font-mono text-[11px] uppercase tracking-[0.12em] text-white/50 mb-4">{selectedPost.date}</p>
)} )}
{selectedPost.thumbnail && ( {selectedPost.thumbnail && (
<img <img
src={selectedPost.thumbnail} src={selectedPost.thumbnail}
alt={selectedPost.title} alt={selectedPost.title}
className="w-full h-48 object-cover rounded-lg mb-4 border border-white/10" className="w-full h-48 object-cover rounded-none mb-4 border border-white/15"
/> />
)} )}
@@ -86,17 +86,17 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
</div> </div>
<style>{` <style>{`
.article-content h1 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0 0.5rem; } .article-content h1 { font-size: 1.5rem; font-weight: 700; margin: 1rem 0 0.5rem; }
.article-content h2 { font-size: 1.25rem; font-weight: 700; margin: 1rem 0 0.5rem; color: #93c5fd; } .article-content h2 { font-size: 1.25rem; font-weight: 700; margin: 1rem 0 0.5rem; color: #ffffff; }
.article-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.75rem 0 0.5rem; } .article-content h3 { font-size: 1.1rem; font-weight: 600; margin: 0.75rem 0 0.5rem; }
.article-content p { margin-bottom: 0.75rem; line-height: 1.6; } .article-content p { margin-bottom: 0.75rem; line-height: 1.6; }
.article-content ul { list-style: disc; padding-left: 1.25rem; margin-bottom: 0.75rem; } .article-content ul { list-style: disc; padding-left: 1.25rem; margin-bottom: 0.75rem; }
.article-content li { margin-bottom: 0.25rem; } .article-content li { margin-bottom: 0.25rem; }
.article-content img { display: block; width: 100%; max-width: 100%; height: auto; border-radius: 0.5rem; border: 1px solid rgba(255,255,255,0.1); margin: 1rem 0; } .article-content img { display: block; width: 100%; max-width: 100%; height: auto; border-radius: 0; border: 1px solid rgba(255,255,255,0.15); margin: 1rem 0; }
.article-content a { color: #3b82f6; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; } .article-content a { color: #74aaff; text-decoration: none; border-bottom: 1px solid transparent; transition: border-color 0.2s; }
.article-content a:hover { border-bottom-color: #3b82f6; } .article-content a:hover { border-bottom-color: #74aaff; }
/* Video embed styles */ /* Video embed styles */
.article-content .video-embed { position: relative; width: 100%; margin: 1.5rem 0; border-radius: 0.5rem; overflow: hidden; border: 1px solid rgba(255,255,255,0.1); background: #000; } .article-content .video-embed { position: relative; width: 100%; margin: 1.5rem 0; border-radius: 0; overflow: hidden; border: 1px solid rgba(255,255,255,0.15); background: #000; }
.article-content .youtube-embed { padding-bottom: 56.25%; /* 16:9 aspect ratio */ height: 0; } .article-content .youtube-embed { padding-bottom: 56.25%; /* 16:9 aspect ratio */ height: 0; }
.article-content .youtube-embed iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; } .article-content .youtube-embed iframe { position: absolute; top: 0; left: 0; width: 100%; height: 100%; }
.article-content .video-embed video { display: block; width: 100%; height: auto; max-height: 50vh; } .article-content .video-embed video { display: block; width: 100%; height: auto; max-height: 50vh; }
@@ -111,18 +111,18 @@ const NewsView = ({ onBack }: { onBack: () => void }) => {
<div <div
key={item.slug} key={item.slug}
onClick={() => setSelectedSlug(item.slug)} onClick={() => setSelectedSlug(item.slug)}
className="bg-white/5 hover:bg-white/10 border border-white/10 p-4 rounded-lg cursor-pointer transition-all group" className="bg-white/5 hover:bg-white/[0.07] border border-white/15 hover:border-white/25 p-4 rounded-none cursor-pointer transition-colors group"
> >
<div className="flex justify-between items-start"> <div className="flex justify-between items-start">
<h3 className="text-lg font-semibold group-hover:text-blue-300 transition-colors flex items-center gap-2"> <h3 className="text-lg font-semibold group-hover:text-white transition-colors flex items-center gap-2">
{item.title} {item.title}
<ArrowRightIcon <ArrowRightIcon
size="xs" size="xs"
className="opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-blue-400" /> className="opacity-0 -translate-x-2 group-hover:opacity-100 group-hover:translate-x-0 transition-all text-white" />
</h3> </h3>
{item.date && ( {item.date && (
<span className="text-xs text-white/40 font-mono whitespace-nowrap ml-2"> <span className="font-mono text-[11px] uppercase tracking-[0.12em] text-white/40 whitespace-nowrap ml-2">
{item.date} {item.date}
</span> </span>
)} )}
@@ -372,7 +372,7 @@ export function HelpMenuButton({
key={item.id} key={item.id}
type="button" type="button"
onClick={() => viewTutorial(item)} onClick={() => viewTutorial(item)}
className="group block overflow-hidden rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 text-left" className="group block overflow-hidden rounded-none border border-white/15 bg-white/5 transition-colors hover:border-white/25 hover:bg-white/[0.07] text-left"
> >
<div className="aspect-video w-full overflow-hidden"> <div className="aspect-video w-full overflow-hidden">
<img <img
@@ -402,12 +402,12 @@ export function HelpMenuButton({
> >
Back Back
</Button> </Button>
<div className="text-lg font-bold text-base-fg"> <div className="font-display text-lg font-bold text-base-fg">
Tutorial: {selected.title} Tutorial: {selected.title}
</div> </div>
</div> </div>
<div className="w-full"> <div className="w-full">
<div className="aspect-video w-full overflow-hidden rounded-lg border border-white/10 bg-black"> <div className="aspect-video w-full overflow-hidden rounded-none border border-white/15 bg-black">
<div ref={playerContainerRef} className="h-full w-full" /> <div ref={playerContainerRef} className="h-full w-full" />
</div> </div>
</div> </div>
@@ -39,10 +39,10 @@ export const InputVector = ({
oldZ.current = z; oldZ.current = z;
const inputCommonClasses = const inputCommonClasses =
"relative h-6 rounded-r-lg bg-black/25 p-2 text-sm text-white transition-all duration-100 ease-in-out outline-none -outline-offset-2 text-end w-full hover:cursor-e-resize hover:bg-black/40"; "relative h-6 rounded-r-[3px] bg-black/25 p-2 text-sm text-white transition-all duration-100 ease-in-out outline-none -outline-offset-2 text-end w-full hover:cursor-e-resize hover:bg-black/40";
const wrapperCommonClasses = const wrapperCommonClasses =
"relative flex items-center before:inline-block before:h-6 before:bg-brand-primary before:text-white before:rounded-l-lg before:w-1.5 before:text-center before:justify-center before:items-center before:font-semibold before:flex before:align-middle before:leading-8 text-xs"; "relative flex items-center before:inline-block before:h-6 before:bg-brand-primary before:text-white before:rounded-l-[3px] before:w-1.5 before:text-center before:justify-center before:items-center before:font-semibold before:flex before:align-middle before:leading-8 text-xs";
const lockedCommonClasses = const lockedCommonClasses =
"hover:cursor-not-allowed hover:bg-brand-secondary"; "hover:cursor-not-allowed hover:bg-brand-secondary";
@@ -51,7 +51,7 @@ export const NumberInput: React.FC<NumberInputProps> = ({
}; };
const incrementButtonStyle = const incrementButtonStyle =
"flex h-[15px] w-6 items-center justify-center text-[10px] text-white/80 bg-ui-controls hover:bg-ui-controls-button outline-none focus:outline-none transition-all active:outline-none"; "flex h-[15px] w-6 items-center justify-center text-[10px] text-white/80 bg-ui-controls hover:bg-white/10 outline-none focus:outline-none transition-colors active:outline-none";
return ( return (
<div className="relative"> <div className="relative">
@@ -60,21 +60,21 @@ export const NumberInput: React.FC<NumberInputProps> = ({
value={inputValue} value={inputValue}
onChange={handleChange} onChange={handleChange}
onBlur={handleBlur} onBlur={handleBlur}
inputClassName="w-[70px] h-[30px] rounded-md text-sm" inputClassName="w-[70px] h-[30px] rounded-[3px] text-sm"
/> />
<div className="absolute right-0 top-0 flex h-[30px] flex-col rounded-r-md border-l border-white/15 bg-gray-800"> <div className="absolute right-0 top-0 flex h-[30px] flex-col rounded-r-[3px] border-l border-white/15 bg-ui-controls">
<button <button
className={twMerge( className={twMerge(
incrementButtonStyle, incrementButtonStyle,
"rounded-tr-md border-b border-white/10", "rounded-tr-[3px] border-b border-white/15",
)} )}
onClick={handleIncrement} onClick={handleIncrement}
> >
<ChevronUpIcon /> <ChevronUpIcon />
</button> </button>
<button <button
className={twMerge(incrementButtonStyle, "rounded-br-md")} className={twMerge(incrementButtonStyle, "rounded-br-[3px]")}
onClick={handleDecrement} onClick={handleDecrement}
> >
<ChevronDownIcon /> <ChevronDownIcon />
@@ -28,7 +28,7 @@ export const Input = React.forwardRef(
errorMessage, errorMessage,
...rest ...rest
}: InputProps, }: InputProps,
ref: React.ForwardedRef<HTMLInputElement> ref: React.ForwardedRef<HTMLInputElement>,
) => { ) => {
return ( return (
<div className={twMerge("flex flex-col", className)}> <div className={twMerge("flex flex-col", className)}>
@@ -45,13 +45,13 @@ export const Input = React.forwardRef(
ref={ref} ref={ref}
id={id ? id : label ? label : undefined} id={id ? id : label ? label : undefined}
className={twMerge( className={twMerge(
"h-10 w-full rounded-lg px-3 py-2.5 outline-none", "h-10 w-full rounded-[3px] px-3 py-2.5 outline-none",
"bg-ui-panel text-base-fg placeholder-base-fg/50", "bg-ui-panel text-base-fg placeholder-base-fg/40",
"border border-ui-panel-border transition-all duration-150 ease-in-out hover:border-primary/60 focus:border-primary focus:!outline-none", "border border-ui-panel-border transition-colors duration-150 ease-in-out hover:border-white/40 focus:border-white focus:!outline-none",
"disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-ui-panel-border", "disabled:cursor-not-allowed disabled:opacity-60 disabled:hover:border-ui-panel-border",
icon && "pl-10", icon && "pl-10",
isError && "outline-red focus:outline-red", isError && "border-red-500 focus:border-red-500",
inputClassName inputClassName,
)} )}
onFocus={(e: React.FocusEvent<HTMLInputElement>) => { onFocus={(e: React.FocusEvent<HTMLInputElement>) => {
if (onFocus) { if (onFocus) {
@@ -66,12 +66,12 @@ export const Input = React.forwardRef(
{...rest} {...rest}
/> />
{errorMessage && ( {errorMessage && (
<h6 className="absolute z-10 text-red">{errorMessage}</h6> <h6 className="absolute z-10 text-red-400">{errorMessage}</h6>
)} )}
</div> </div>
</div> </div>
); );
} },
); );
Input.displayName = "Input"; Input.displayName = "Input";
@@ -57,15 +57,15 @@ export function Cheatsheet({
<div <div
data-keybinds-cheatsheet data-keybinds-cheatsheet
className={twMerge( className={twMerge(
"max-h-[80%] w-[min(680px,90%)] overflow-auto rounded-xl border border-white/15 bg-black/70 p-6 text-white/90 shadow-2xl backdrop-blur-sm", "max-h-[80%] w-[min(680px,90%)] overflow-auto rounded-none border border-white/15 bg-ui-panel/95 p-6 text-white/90",
pinned && "pointer-events-auto", pinned && "pointer-events-auto",
)} )}
> >
<div className="mb-3 flex items-center justify-between"> <div className="mb-3 flex items-center justify-between">
<span className="text-xs uppercase tracking-widest text-white/50"> <span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
Keyboard shortcuts Keyboard shortcuts
</span> </span>
<span className="text-xs capitalize text-white/40"> <span className="font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
{selectedPreset} preset {selectedPreset} preset
</span> </span>
</div> </div>
@@ -75,7 +75,7 @@ export function Cheatsheet({
if (!rows?.length) return null; if (!rows?.length) return null;
return ( return (
<div key={group}> <div key={group}>
<div className="mb-2 text-xs font-semibold uppercase tracking-wider text-white/60"> <div className="mb-2 font-mono text-[11px] font-medium uppercase tracking-[0.13em] text-white/45">
{group} {group}
</div> </div>
<ul className="flex flex-col gap-1.5"> <ul className="flex flex-col gap-1.5">
@@ -94,7 +94,7 @@ export function Cheatsheet({
})} })}
</div> </div>
{pinned && ( {pinned && (
<div className="mt-5 border-t border-white/10 pt-3 text-center text-xs text-white/40"> <div className="mt-5 border-t border-white/15 pt-3 text-center font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
Press Esc or click outside to close Press Esc or click outside to close
</div> </div>
)} )}
@@ -36,7 +36,7 @@ export function KeybindCaptureInput({
onClick={() => setCapturing((v) => !v)} onClick={() => setCapturing((v) => !v)}
aria-label="Change keybinding" aria-label="Change keybinding"
className={twMerge( className={twMerge(
"inline-flex min-h-8 min-w-[7rem] items-center justify-center rounded-md border px-2 py-1 text-sm transition-colors", "inline-flex min-h-8 min-w-[7rem] items-center justify-center rounded-[3px] border px-2 py-1 text-sm transition-colors",
capturing capturing
? "border-primary/80 bg-primary/10 text-primary animate-pulse" ? "border-primary/80 bg-primary/10 text-primary animate-pulse"
: accent : accent
@@ -176,8 +176,8 @@ export function KeybindsSettings({
Deliberately reset-free: destructive actions live with their scope Deliberately reset-free: destructive actions live with their scope
(section headers) or in the danger row at the bottom, never beside (section headers) or in the danger row at the bottom, never beside
the search field where a stray click can nuke every override. */} the search field where a stray click can nuke every override. */}
<div className="sticky top-0 z-10 -mt-2 flex flex-col gap-3 border-b border-white/[0.06] bg-ui-modal/80 py-3 backdrop-blur-xl"> <div className="sticky top-0 z-10 -mt-2 flex flex-col gap-3 border-b border-white/[0.06] bg-ui-modal py-3">
<div className="flex items-center gap-1.5 rounded-2xl bg-white/[0.04] p-1.5 ring-1 ring-white/[0.06]"> <div className="flex items-center gap-1.5 rounded-none bg-white/[0.04] p-1.5 ring-1 ring-white/15">
<ModeToggle mode={searchMode} onChange={switchMode} /> <ModeToggle mode={searchMode} onChange={switchMode} />
{searchMode === "name" ? ( {searchMode === "name" ? (
<NameSearchField value={nameSearch} onChange={setNameSearch} /> <NameSearchField value={nameSearch} onChange={setNameSearch} />
@@ -273,7 +273,7 @@ export function KeybindsSettings({
and gated behind a two-step confirm. Hidden when there is nothing to and gated behind a two-step confirm. Hidden when there is nothing to
destroy. ─────────────────────────────────────────────────────────── */} destroy. ─────────────────────────────────────────────────────────── */}
{overriddenCount > 0 && ( {overriddenCount > 0 && (
<div className="flex items-center justify-between gap-4 rounded-2xl bg-red/[0.04] p-4 ring-1 ring-red/20"> <div className="flex items-center justify-between gap-4 rounded-none bg-red/[0.04] p-4 ring-1 ring-red/20">
<div className="flex min-w-0 flex-col gap-0.5"> <div className="flex min-w-0 flex-col gap-0.5">
<span className="text-[13px] font-medium text-base-fg/90"> <span className="text-[13px] font-medium text-base-fg/90">
Reset all customizations Reset all customizations
@@ -296,7 +296,7 @@ export function KeybindsSettings({
)} )}
{/* ── Video editor lives in its own customizer ───────────────────────── */} {/* ── Video editor lives in its own customizer ───────────────────────── */}
<div className="flex items-start gap-3 rounded-2xl bg-white/[0.02] p-4 text-[13px] leading-relaxed text-base-fg/55 ring-1 ring-white/[0.06]"> <div className="flex items-start gap-3 rounded-none bg-white/[0.02] p-4 text-[13px] leading-relaxed text-base-fg/55 ring-1 ring-white/15">
<InfoIcon className="mt-0.5 h-4 w-4 shrink-0 text-base-fg/35" /> <InfoIcon className="mt-0.5 h-4 w-4 shrink-0 text-base-fg/35" />
<span> <span>
Video editor shortcuts are managed in the video editor itself Video editor shortcuts are managed in the video editor itself
@@ -357,14 +357,14 @@ function PresetDropdown({
aria-haspopup="listbox" aria-haspopup="listbox"
aria-expanded={open} aria-expanded={open}
className={twMerge( className={twMerge(
"flex w-full items-center gap-3 rounded-2xl bg-white/[0.04] p-1.5 text-left ring-1 ring-white/[0.08] transition-all hover:bg-white/[0.06] active:scale-[0.995]", "flex w-full items-center gap-3 rounded-none bg-white/[0.04] p-1.5 text-left ring-1 ring-white/15 transition-all hover:bg-white/[0.06] active:scale-[0.995]",
EASE, EASE,
open && "ring-primary/40", open && "ring-primary/40",
)} )}
> >
<div className="flex min-w-0 grow flex-col gap-0.5 px-3 py-1.5"> <div className="flex min-w-0 grow flex-col gap-0.5 px-3 py-1.5">
<span className="text-sm font-medium text-base-fg">{def.label}</span> <span className="text-sm font-medium text-base-fg">{def.label}</span>
<span className="truncate text-[12px] text-base-fg/50"> <span className="text-[12px] leading-relaxed text-base-fg/60">
{def.description} {def.description}
</span> </span>
</div> </div>
@@ -381,7 +381,7 @@ function PresetDropdown({
role="listbox" role="listbox"
aria-label="Preset" aria-label="Preset"
className={twMerge( className={twMerge(
"absolute left-0 right-0 top-full z-20 mt-2 origin-top rounded-2xl bg-ui-modal shadow-[0_20px_50px_-16px_rgba(0,0,0,0.7)] ring-1 ring-white/10 transition-all duration-200 overflow-hidden", "absolute left-0 right-0 top-full z-20 mt-2 origin-top rounded-none bg-ui-modal shadow-[0_20px_50px_-16px_rgba(0,0,0,0.7)] ring-1 ring-white/10 transition-all duration-200 overflow-hidden",
EASE, EASE,
open open
? "scale-100 opacity-100" ? "scale-100 opacity-100"
@@ -457,7 +457,7 @@ function ModeToggle({
onChange: (mode: SearchMode) => void; onChange: (mode: SearchMode) => void;
}) { }) {
return ( return (
<div className="flex shrink-0 items-center gap-0.5 rounded-[0.625rem] bg-ui-modal/60 p-0.5"> <div className="flex shrink-0 items-center gap-0.5 rounded-[3px] bg-ui-modal/60 p-0.5">
{(["name", "key"] as SearchMode[]).map((m) => ( {(["name", "key"] as SearchMode[]).map((m) => (
<button <button
key={m} key={m}
@@ -465,7 +465,7 @@ function ModeToggle({
onClick={() => onChange(m)} onClick={() => onChange(m)}
aria-pressed={mode === m} aria-pressed={mode === m}
className={twMerge( className={twMerge(
"rounded-lg px-3 py-1.5 text-[12px] font-medium capitalize transition-all", "rounded-[3px] px-3 py-1.5 text-[12px] font-medium capitalize transition-all",
EASE, EASE,
mode === m mode === m
? "bg-white/10 text-base-fg shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]" ? "bg-white/10 text-base-fg shadow-[inset_0_1px_0_rgba(255,255,255,0.06)]"
@@ -489,7 +489,7 @@ function NameSearchField({
return ( return (
<div <div
className={twMerge( className={twMerge(
"group flex grow items-center rounded-[0.625rem] bg-ui-modal/60 ring-1 ring-transparent transition-all", "group flex grow items-center rounded-[3px] bg-ui-modal/60 ring-1 ring-transparent transition-all",
EASE, EASE,
"focus-within:bg-ui-modal/80 focus-within:ring-primary/50", "focus-within:bg-ui-modal/80 focus-within:ring-primary/50",
)} )}
@@ -508,7 +508,7 @@ function NameSearchField({
onClick={() => onChange("")} onClick={() => onChange("")}
aria-label="Clear search" aria-label="Clear search"
className={twMerge( className={twMerge(
"mr-1.5 grid h-7 w-7 shrink-0 place-items-center rounded-full text-base-fg/40 transition-all hover:bg-white/10 hover:text-base-fg active:scale-90", "mr-1.5 grid h-7 w-7 shrink-0 place-items-center rounded-[3px] text-base-fg/40 transition-all hover:bg-white/10 hover:text-base-fg active:scale-90",
EASE, EASE,
)} )}
> >
@@ -533,7 +533,7 @@ function KeySearchField({
return ( return (
<div <div
className={twMerge( className={twMerge(
"flex grow items-center rounded-[0.625rem] bg-ui-modal/60 ring-1 transition-all", "flex grow items-center rounded-[3px] bg-ui-modal/60 ring-1 transition-all",
EASE, EASE,
listening ? "ring-primary/60" : "ring-transparent", listening ? "ring-primary/60" : "ring-transparent",
)} )}
@@ -570,7 +570,7 @@ function KeySearchField({
onClick={onClear} onClick={onClear}
aria-label="Clear key filter" aria-label="Clear key filter"
className={twMerge( className={twMerge(
"mr-1.5 grid h-7 w-7 shrink-0 place-items-center rounded-full text-base-fg/40 transition-all hover:bg-white/10 hover:text-base-fg active:scale-90", "mr-1.5 grid h-7 w-7 shrink-0 place-items-center rounded-[3px] text-base-fg/40 transition-all hover:bg-white/10 hover:text-base-fg active:scale-90",
EASE, EASE,
)} )}
> >
@@ -605,8 +605,8 @@ function CollapsibleSurface({
children: React.ReactNode; children: React.ReactNode;
}) { }) {
return ( return (
<div className="rounded-2xl bg-white/[0.04] ring-1 ring-white/[0.06]"> <div className="rounded-none bg-white/[0.04] ring-1 ring-white/15">
<div className="overflow-hidden rounded-[0.625rem] bg-ui-modal/40"> <div className="overflow-hidden rounded-[3px] bg-ui-modal/40">
{/* The toggle and the (destructive) section reset are sibling buttons — {/* The toggle and the (destructive) section reset are sibling buttons —
a nested button is invalid HTML and a mis-click on "reset" must a nested button is invalid HTML and a mis-click on "reset" must
never also collapse the section. */} never also collapse the section. */}
@@ -647,7 +647,7 @@ function CollapsibleSurface({
onConfirm={onReset} onConfirm={onReset}
/> />
)} )}
<span className="shrink-0 rounded-full bg-white/[0.06] px-2 py-0.5 text-xs tabular-nums text-base-fg/55"> <span className="shrink-0 rounded-[3px] bg-white/[0.06] px-2 py-0.5 text-xs tabular-nums text-base-fg/55">
{count} {count}
</span> </span>
</div> </div>
@@ -738,7 +738,7 @@ function ConfirmResetButton({
title={title} title={title}
aria-label={armed ? `${title} — click again to confirm` : title} aria-label={armed ? `${title} — click again to confirm` : title}
className={twMerge( className={twMerge(
"relative shrink-0 overflow-hidden whitespace-nowrap rounded-full font-medium transition-all active:scale-[0.97]", "relative shrink-0 overflow-hidden whitespace-nowrap rounded-[3px] font-medium transition-all active:scale-[0.97]",
EASE, EASE,
prominent prominent
? "h-9 px-4 text-[13px]" ? "h-9 px-4 text-[13px]"
@@ -746,7 +746,7 @@ function ConfirmResetButton({
armed armed
? ready ? ready
? "bg-red/25 text-white shadow-[0_0_0_3px_rgba(0,0,0,0.15)]" ? "bg-red/25 text-white shadow-[0_0_0_3px_rgba(0,0,0,0.15)]"
: "cursor-wait bg-white/[0.06] text-base-fg/55 ring-1 ring-white/[0.08]" : "cursor-wait bg-white/[0.06] text-base-fg/55 ring-1 ring-white/15"
: prominent : prominent
? "bg-red/10 text-red ring-1 ring-red/30 hover:bg-red/[0.18]" ? "bg-red/10 text-red ring-1 ring-red/30 hover:bg-red/[0.18]"
: "text-base-fg/45 hover:bg-red/10 hover:text-red", : "text-base-fg/45 hover:bg-red/10 hover:text-red",
@@ -827,7 +827,7 @@ function ActionRow({
return ( return (
<div <div
className={twMerge( className={twMerge(
"group/row flex items-center gap-3 rounded-xl px-3 py-2 transition-colors", "group/row flex items-center gap-3 rounded-[3px] px-3 py-2 transition-colors",
EASE, EASE,
"hover:bg-white/[0.03]", "hover:bg-white/[0.03]",
)} )}
@@ -859,7 +859,7 @@ function ActionRow({
title="Revert to preset default" title="Revert to preset default"
aria-label="Revert to preset default" aria-label="Revert to preset default"
className={twMerge( className={twMerge(
"grid h-7 w-7 shrink-0 place-items-center rounded-full text-base-fg/40 transition-all hover:bg-white/10 hover:text-base-fg active:scale-90", "grid h-7 w-7 shrink-0 place-items-center rounded-[3px] text-base-fg/40 transition-all hover:bg-white/10 hover:text-base-fg active:scale-90",
EASE, EASE,
)} )}
> >
@@ -887,7 +887,7 @@ function ConflictAlert({
return ( return (
<div <div
role="alert" role="alert"
className="flex flex-col gap-2.5 rounded-2xl bg-red/[0.08] p-3.5 text-[13px] ring-1 ring-red/30" className="flex flex-col gap-2.5 rounded-none bg-red/[0.08] p-3.5 text-[13px] ring-1 ring-red/30"
> >
<span className="leading-relaxed text-base-fg/80"> <span className="leading-relaxed text-base-fg/80">
<KbdBindings bindings={[pending.binding]} /> is already used by{" "} <KbdBindings bindings={[pending.binding]} /> is already used by{" "}
@@ -901,7 +901,7 @@ function ConflictAlert({
type="button" type="button"
onClick={onConfirm} onClick={onConfirm}
className={twMerge( className={twMerge(
"rounded-full bg-red px-4 py-1.5 text-[13px] font-medium text-white transition-all hover:bg-red/85 active:scale-[0.97]", "rounded-[3px] bg-red px-4 py-1.5 text-[13px] font-medium text-white transition-all hover:bg-red/85 active:scale-[0.97]",
EASE, EASE,
)} )}
> >
@@ -911,7 +911,7 @@ function ConflictAlert({
type="button" type="button"
onClick={onCancel} onClick={onCancel}
className={twMerge( className={twMerge(
"rounded-full bg-white/[0.06] px-4 py-1.5 text-[13px] text-base-fg/80 transition-all hover:bg-white/10 active:scale-[0.97]", "rounded-[3px] bg-white/[0.06] px-4 py-1.5 text-[13px] text-base-fg/80 transition-all hover:bg-white/10 active:scale-[0.97]",
EASE, EASE,
)} )}
> >
@@ -932,7 +932,7 @@ function EmptyState({
binding: Binding | null; binding: Binding | null;
}) { }) {
return ( return (
<div className="flex flex-col items-center gap-3 rounded-2xl bg-white/[0.06] px-6 py-12 text-center ring-1 ring-white/[0.06]"> <div className="flex flex-col items-center gap-3 rounded-none bg-white/[0.06] px-6 py-12 text-center ring-1 ring-white/15">
{mode === "key" ? ( {mode === "key" ? (
<KeyIcon className="h-6 w-6 text-base-fg/25" /> <KeyIcon className="h-6 w-6 text-base-fg/25" />
) : ( ) : (
@@ -955,7 +955,7 @@ function EmptyState({
function Eyebrow({ children }: { children: React.ReactNode }) { function Eyebrow({ children }: { children: React.ReactNode }) {
return ( return (
<span className="text-xs font-medium tracking-wide text-base-fg/50"> <span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
{children} {children}
</span> </span>
); );
@@ -963,7 +963,7 @@ function Eyebrow({ children }: { children: React.ReactNode }) {
function GroupLabel({ children }: { children: React.ReactNode }) { function GroupLabel({ children }: { children: React.ReactNode }) {
return ( return (
<div className="px-3 pb-1 pt-4 text-[11px] font-medium uppercase tracking-[0.13em] text-base-fg/45 first:pt-2"> <div className="px-3 pb-1 pt-4 font-mono text-[11px] font-medium uppercase tracking-[0.13em] text-base-fg/45 first:pt-2">
{children} {children}
</div> </div>
); );
@@ -14,11 +14,14 @@ export const Label = ({
...rest ...rest
}: LabelProps) => ( }: LabelProps) => (
<label <label
className={twMerge("text-base-fg mb-1.5 text-[15px] font-medium", className)} className={twMerge(
"text-base-fg/60 mb-1.5 font-mono text-[10px] font-semibold uppercase tracking-[0.25em]",
className,
)}
{...rest} {...rest}
> >
{children} {children}
{required && <span className="ml-0.5 text-[#ff6467]">*</span>} {required && <span className="ml-0.5 text-red-400">*</span>}
</label> </label>
); );
@@ -2,7 +2,7 @@ import { Modal } from "@storyteller/ui-modal";
import { Tooltip } from "@storyteller/ui-tooltip"; import { Tooltip } from "@storyteller/ui-tooltip";
import { Button } from "@storyteller/ui-button"; import { Button } from "@storyteller/ui-button";
import dayjs from "dayjs"; import dayjs from "dayjs";
import { ArrowDownToLineIcon, BoxIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, GlobeIcon, LinkIcon, LogOutIcon, MusicIcon, PauseIcon, PencilIcon, PlayIcon, RotateCwIcon, SearchIcon, Trash2Icon, UserIcon, VideoIcon, WandSparklesIcon } from "lucide-react"; import { ArrowDownToLineIcon, BoxIcon, CheckIcon, ChevronLeftIcon, ChevronRightIcon, CopyIcon, GlobeIcon, ImageIcon, InfoIcon, LinkIcon, LogOutIcon, MusicIcon, PauseIcon, PencilIcon, PlayIcon, RotateCwIcon, SearchIcon, Trash2Icon, UserIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons"; import { DynamicIcon } from "@storyteller/icons";
import { MediaFileDelete } from "@storyteller/tauri-api"; import { MediaFileDelete } from "@storyteller/tauri-api";
import { LoadingSpinner } from "@storyteller/ui-loading-spinner"; import { LoadingSpinner } from "@storyteller/ui-loading-spinner";
@@ -544,7 +544,7 @@ export function LightboxModal({
<Modal <Modal
isOpen={isOpen} isOpen={isOpen}
onClose={onClose} onClose={onClose}
className="rounded-xl bg-ui-modal h-[760px] w-[1200px] max-w-screen min-w-[1000px] min-h-[600px] p-4" className="rounded-none border-white/15 bg-ui-modal h-[760px] w-[1200px] max-w-screen min-w-[1000px] min-h-[600px] p-4"
draggable draggable
allowBackgroundInteraction={true} allowBackgroundInteraction={true}
showClose={true} showClose={true}
@@ -555,15 +555,15 @@ export function LightboxModal({
> >
{/* Invisible drag handle strip at the very top for moving */} {/* Invisible drag handle strip at the very top for moving */}
<Modal.DragHandle> <Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" /> <div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move" />
</Modal.DragHandle> </Modal.DragHandle>
{/* content grid */} {/* content grid */}
<div className="flex h-full gap-4"> <div className="flex h-full gap-4">
{/* image panel - flexible width */} {/* image panel - flexible width */}
<div className="group/nav relative flex h-full flex-1 items-center justify-center overflow-hidden rounded-l-xl bg-black/30"> <div className="group/nav relative flex h-full flex-1 items-center justify-center overflow-hidden bg-black">
{!selectedImageUrl ? ( {!selectedImageUrl ? (
<div className="flex h-full w-full items-center justify-center bg-black/30"> <div className="flex h-full w-full items-center justify-center bg-black">
<span className="text-base-fg/60">Image not available</span> <span className="text-base-fg/60">Image not available</span>
</div> </div>
) : mediaClass === "dimensional" || ) : mediaClass === "dimensional" ||
@@ -601,8 +601,8 @@ export function LightboxModal({
</video> </video>
) : mediaClass === "audio" ? ( ) : mediaClass === "audio" ? (
<div className="flex h-full w-full items-center justify-center px-4 sm:px-8"> <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="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 rounded-full bg-white/10 ring-1 ring-white/15"> <div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center border border-white/15 bg-white/10">
<MusicIcon <MusicIcon
className="text-2xl text-white/70" /> className="text-2xl text-white/70" />
@@ -625,7 +625,7 @@ export function LightboxModal({
className="embla__slide flex-[0_0_100%]" className="embla__slide flex-[0_0_100%]"
key={`${url}-${idx}`} key={`${url}-${idx}`}
> >
<div className="relative flex h-full items-center justify-center overflow-hidden rounded-lg bg-black/20"> <div className="relative flex h-full items-center justify-center overflow-hidden bg-black">
<img <img
data-lightbox-modal="true" data-lightbox-modal="true"
src={addCorsParam(url) || url} src={addCorsParam(url) || url}
@@ -660,12 +660,12 @@ export function LightboxModal({
</div> </div>
{effectiveImageUrls.length > 1 && ( {effectiveImageUrls.length > 1 && (
<div className="mt-3 px-2"> <div className="mt-3 px-2 pb-2">
<div <div
className="embla-thumbs overflow-hidden" className="embla-thumbs overflow-hidden"
ref={emblaThumbsRef} ref={emblaThumbsRef}
> >
<div className="embla-thumbs__container flex gap-2"> <div className="embla-thumbs__container flex justify-center gap-2">
{effectiveImageUrls.map((url, idx) => { {effectiveImageUrls.map((url, idx) => {
const isSelected = idx === selectedIndex; const isSelected = idx === selectedIndex;
return ( return (
@@ -674,9 +674,9 @@ export function LightboxModal({
type="button" type="button"
onClick={() => onThumbClick(idx)} onClick={() => onThumbClick(idx)}
className={twMerge( className={twMerge(
"embla-thumbs__slide relative h-20 w-20 flex-[0_0_5rem] overflow-hidden rounded-md border-2 transition-all", "embla-thumbs__slide relative h-16 w-16 flex-[0_0_4rem] overflow-hidden border transition-all",
isSelected isSelected
? "border-brand-primary-400 opacity-100" ? "border-white opacity-100"
: "border-transparent opacity-60 hover:border-white/40 hover:opacity-100", : "border-transparent opacity-60 hover:border-white/40 hover:opacity-100",
)} )}
> >
@@ -701,7 +701,7 @@ export function LightboxModal({
mediaClass !== "mesh" && mediaClass !== "mesh" &&
mediaClass !== "splat" && mediaClass !== "splat" &&
mediaClass !== "audio" && ( mediaClass !== "audio" && (
<div className="absolute inset-0 bg-ui-panel flex items-center justify-center"> <div className="absolute inset-0 bg-black flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-base-fg" /> <LoadingSpinner className="h-12 w-12 text-base-fg" />
</div> </div>
)} )}
@@ -713,7 +713,7 @@ export function LightboxModal({
e.stopPropagation(); e.stopPropagation();
onNavigatePrev(); onNavigatePrev();
}} }}
className="absolute left-3 top-1/2 -translate-y-1/2 z-30 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/70 opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white group-hover/nav:opacity-100 focus:outline-none" className="absolute left-3 top-1/2 -translate-y-1/2 z-30 flex h-10 w-10 items-center justify-center bg-black/50 text-white/70 opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white group-hover/nav:opacity-100 focus:outline-none"
aria-label="Previous item" aria-label="Previous item"
> >
<ChevronLeftIcon className="text-lg" /> <ChevronLeftIcon className="text-lg" />
@@ -725,7 +725,7 @@ export function LightboxModal({
e.stopPropagation(); e.stopPropagation();
onNavigateNext(); onNavigateNext();
}} }}
className="absolute right-3 top-1/2 -translate-y-1/2 z-30 flex h-10 w-10 items-center justify-center rounded-full bg-black/50 text-white/70 opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white group-hover/nav:opacity-100 focus:outline-none" className="absolute right-3 top-1/2 -translate-y-1/2 z-30 flex h-10 w-10 items-center justify-center bg-black/50 text-white/70 opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white group-hover/nav:opacity-100 focus:outline-none"
aria-label="Next item" aria-label="Next item"
> >
<ChevronRightIcon className="text-lg" /> <ChevronRightIcon className="text-lg" />
@@ -737,7 +737,7 @@ export function LightboxModal({
<div className="flex h-full w-[280px] shrink-0 flex-col"> <div className="flex h-full w-[280px] shrink-0 flex-col">
<div className="flex-1 overflow-y-auto space-y-5 text-base-fg min-h-0 pb-2"> <div className="flex-1 overflow-y-auto space-y-5 text-base-fg min-h-0 pb-2">
{creator ? ( {creator ? (
<div className="sticky top-0 z-10 flex items-center gap-2.5 bg-ui-modal pb-3 pr-10 border-b border-white/5"> <div className="sticky top-0 z-10 flex items-center gap-3 bg-ui-modal pb-3 pr-20 border-b border-white/10">
{creator.core_info ? ( {creator.core_info ? (
<Gravatar <Gravatar
size={36} size={36}
@@ -747,10 +747,10 @@ export function LightboxModal({
backgroundIndex={ backgroundIndex={
creator.core_info.default_avatar.color_index 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 /> <UserIcon />
</div> </div>
)} )}
@@ -758,27 +758,28 @@ export function LightboxModal({
<span className="text-base-fg text-sm font-semibold leading-none truncate"> <span className="text-base-fg text-sm font-semibold leading-none truncate">
{creator.display_name} {creator.display_name}
</span> </span>
<span className="text-base-fg/60 text-xs font-medium"> <span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Author Author
</span> </span>
</div> </div>
</div> </div>
) : promptLoading ? ( ) : promptLoading ? (
<div className="sticky top-0 z-10 flex items-center gap-3 bg-ui-modal pb-3 pr-10 border-b border-white/5 animate-pulse"> <div className="sticky top-0 z-10 flex items-center gap-3 bg-ui-modal pb-3 pr-20 border-b border-white/10 animate-pulse">
<div className="h-9 w-9 shrink-0 rounded-xl bg-white/10" /> <div className="h-9 w-9 shrink-0 bg-white/10" />
<div className="flex flex-col gap-1.5 min-w-0 flex-1"> <div className="flex flex-col gap-1.5 min-w-0 flex-1">
<div className="h-3.5 w-24 rounded bg-white/10" /> <div className="h-3.5 w-24 bg-white/10" />
<div className="h-3 w-12 rounded bg-white/10" /> <div className="h-3 w-12 bg-white/10" />
</div> </div>
</div> </div>
) : null} ) : null}
{(hasPromptToken || promptLoading) && ( {(hasPromptToken || promptLoading) && (
<> <>
{/* Prompt */} {/* Prompt */}
<div className="space-y-1.5"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm font-medium text-base-fg/90"> <div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Prompt <PencilIcon />
<span>Prompt</span>
</div> </div>
{!promptLoading && prompt && ( {!promptLoading && prompt && (
<button <button
@@ -814,16 +815,13 @@ export function LightboxModal({
)} )}
</div> </div>
<div <div
className="text-sm text-base-fg break-words p-3 rounded-lg leading-relaxed" className="text-sm text-base-fg/90 break-words px-4 py-3 bg-black/20 leading-relaxed border border-white/10"
style={{
background: "rgb(var(--st-controls-rgb) / 0.20)",
}}
> >
{promptLoading ? ( {promptLoading ? (
<div className="animate-pulse space-y-2"> <div className="animate-pulse space-y-2">
<div className="h-3 w-full rounded bg-white/10" /> <div className="h-3 w-full bg-white/10" />
<div className="h-3 w-4/5 rounded bg-white/10" /> <div className="h-3 w-4/5 bg-white/10" />
<div className="h-3 w-3/5 rounded bg-white/10" /> <div className="h-3 w-3/5 bg-white/10" />
</div> </div>
) : ( ) : (
<div <div
@@ -877,30 +875,25 @@ export function LightboxModal({
/* Skeleton placeholders while fetching metadata */ /* Skeleton placeholders while fetching metadata */
<> <>
{/* Reference Images skeleton */} {/* Reference Images skeleton */}
<div className="space-y-1.5 animate-pulse"> <div className="space-y-2 animate-pulse">
<div className="h-3.5 w-28 rounded bg-white/10" /> <div className="h-3.5 w-28 bg-white/10" />
<div className="flex gap-1.5"> <div className="flex gap-1.5">
<div className="h-12 w-12 rounded-lg bg-white/10" /> <div className="h-12 w-12 bg-white/10" />
<div className="h-12 w-12 rounded-lg bg-white/10" /> <div className="h-12 w-12 bg-white/10" />
<div className="h-12 w-12 rounded-lg bg-white/10" /> <div className="h-12 w-12 bg-white/10" />
</div> </div>
</div> </div>
{/* Information skeleton */} {/* Information skeleton */}
<div className="space-y-1.5 animate-pulse"> <div className="space-y-2 animate-pulse">
<div className="h-3.5 w-24 rounded bg-white/10" /> <div className="h-3.5 w-24 bg-white/10" />
<div <div className="flex flex-col bg-black/20 border border-white/10 overflow-hidden">
className="flex flex-col rounded-lg border border-ui-panel-border overflow-hidden"
style={{
background: "rgb(var(--st-controls-rgb) / 0.20)",
}}
>
{Array.from({ length: 5 }).map((_, i) => ( {Array.from({ length: 5 }).map((_, i) => (
<div <div
key={i} key={i}
className="flex items-center justify-between py-2.5 px-3 border-b border-white/5 last:border-0" className="flex items-center justify-between py-2.5 px-3 border-b border-white/10 last:border-0"
> >
<div className="h-3.5 w-16 rounded bg-white/10" /> <div className="h-3.5 w-16 bg-white/10" />
<div className="h-3.5 w-24 rounded bg-white/10" /> <div className="h-3.5 w-24 bg-white/10" />
</div> </div>
))} ))}
</div> </div>
@@ -909,9 +902,10 @@ export function LightboxModal({
) : ( ) : (
<> <>
{contextImages && contextImages.length > 0 && ( {contextImages && contextImages.length > 0 && (
<div className="space-y-1.5"> <div className="space-y-2">
<div className="text-sm font-medium text-base-fg/90"> <div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
Reference Media <ImageIcon />
<span>Reference Media</span>
</div> </div>
<div className="grid grid-cols-5 gap-1.5 w-fit"> <div className="grid grid-cols-5 gap-1.5 w-fit">
{contextImages.map((contextImage, index) => { {contextImages.map((contextImage, index) => {
@@ -936,7 +930,7 @@ export function LightboxModal({
<div className="flex flex-col gap-1.5 min-w-[100px]"> <div className="flex flex-col gap-1.5 min-w-[100px]">
{!isAudio && !isVideoRef && ( {!isAudio && !isVideoRef && (
<button <button
className="text-xs text-left text-base-fg/80 hover:text-base-fg transition-colors py-1 px-1 rounded hover:bg-white/5" className="text-xs text-left text-base-fg/80 hover:text-base-fg transition-colors py-1 px-1 rounded-[3px] hover:bg-white/5"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
setRefPreviewUrl(fullSize); setRefPreviewUrl(fullSize);
@@ -950,7 +944,7 @@ export function LightboxModal({
)} )}
{onNavigateToMedia && ( {onNavigateToMedia && (
<button <button
className="text-xs text-left text-base-fg/80 hover:text-base-fg transition-colors py-1 px-1 rounded hover:bg-white/5" className="text-xs text-left text-base-fg/80 hover:text-base-fg transition-colors py-1 px-1 rounded-[3px] hover:bg-white/5"
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();
onNavigateToMedia( onNavigateToMedia(
@@ -968,7 +962,7 @@ export function LightboxModal({
} }
> >
<div <div
className="glass relative aspect-square overflow-hidden rounded-lg w-12 border-2 border-white/30 hover:border-white/80 transition-all group cursor-pointer" className="relative aspect-square w-12 overflow-hidden border border-white/15 bg-white/5 transition-colors hover:border-white/40 group cursor-pointer"
onClick={() => { onClick={() => {
if (isAudio) { if (isAudio) {
handleAudioToggle(contextImage.media_token, contextImage.media_links.cdn_url); handleAudioToggle(contextImage.media_token, contextImage.media_links.cdn_url);
@@ -1045,7 +1039,7 @@ export function LightboxModal({
{/* buttons with spacing */} {/* buttons with spacing */}
{actionUrl && ( {actionUrl && (
<div className="mt-4 grid grid-cols-2 gap-1.5"> <div className="mt-4 grid grid-cols-2 gap-2 border-t border-white/10 pt-4">
{onRecreateClicked && {onRecreateClicked &&
hasPromptToken && hasPromptToken &&
promptData && promptData &&
@@ -1241,7 +1235,7 @@ export function LightboxModal({
{selectedMediaToken && onDeleteClicked && ( {selectedMediaToken && onDeleteClicked && (
<Button <Button
icon={Trash2Icon} icon={Trash2Icon}
className="w-full py-1.5 text-[13px]" className="w-full border border-red-500/20 bg-red-500/10 py-1.5 text-[13px] text-red-500 hover:bg-red-500/20"
variant="destructive" variant="destructive"
onClick={async (e) => { onClick={async (e) => {
e.stopPropagation(); e.stopPropagation();
@@ -1262,7 +1256,7 @@ export function LightboxModal({
<Modal <Modal
isOpen={true} isOpen={true}
onClose={() => setRefPreviewUrl(null)} onClose={() => setRefPreviewUrl(null)}
className="rounded-xl bg-ui-modal w-auto h-auto max-w-[75vw] max-h-[75vh] p-4" className="rounded-none border-white/15 bg-ui-modal w-auto h-auto max-w-[75vw] max-h-[75vh] p-4"
draggable draggable
allowBackgroundInteraction={true} allowBackgroundInteraction={true}
showClose={true} showClose={true}
@@ -1272,9 +1266,9 @@ export function LightboxModal({
expandable={false} expandable={false}
> >
<Modal.DragHandle> <Modal.DragHandle>
<div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move rounded-t-xl" /> <div className="absolute left-0 top-0 z-20 h-12 w-full cursor-move" />
</Modal.DragHandle> </Modal.DragHandle>
<div className="relative flex items-center justify-center overflow-hidden rounded-xl bg-black/30"> <div className="relative flex items-center justify-center overflow-hidden bg-black">
<img <img
src={addCorsParam(refPreviewUrl) || refPreviewUrl} src={addCorsParam(refPreviewUrl) || refPreviewUrl}
alt="Reference preview" alt="Reference preview"
@@ -1323,12 +1317,12 @@ function InfoSection({
if (!hasAnyInfo) return null; if (!hasAnyInfo) return null;
return ( return (
<div className="space-y-1.5"> <div className="space-y-2">
<div className="text-sm font-medium text-base-fg/90">Information</div> <div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<div <InfoIcon />
className="flex flex-col rounded-lg border border-ui-panel-border overflow-hidden" <span>Information</span>
style={{ background: "rgb(var(--st-controls-rgb) / 0.20)" }} </div>
> <div className="flex flex-col bg-black/20 border border-white/10 overflow-hidden">
{modelType && ( {modelType && (
<InfoRow <InfoRow
label="Model" label="Model"
@@ -1386,9 +1380,11 @@ function InfoSection({
function InfoRow({ label, value }: { label: string; value: ReactNode }) { function InfoRow({ label, value }: { label: string; value: ReactNode }) {
return ( return (
<div className="flex items-center justify-between py-2 px-3 border-b border-white/5 last:border-0"> <div className="flex items-start justify-between gap-4 px-3 py-2.5 border-b border-white/10 last:border-0">
<span className="text-sm text-base-fg/70 font-medium">{label}</span> <span className="shrink-0 pt-0.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<span className="text-sm text-base-fg flex items-center gap-2"> {label}
</span>
<span className="min-w-0 text-right text-sm text-base-fg font-medium flex items-center justify-end gap-2">
{value} {value}
</span> </span>
</div> </div>
@@ -134,14 +134,14 @@ export function TagChipInput({
}; };
const chipClass = (value: string) => const chipClass = (value: string) =>
`flex items-center gap-1 rounded-full bg-white/10 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-all ${ `flex items-center gap-1 break-all text-left rounded-[3px] border border-white/15 bg-white/5 px-2.5 py-1 text-[11px] font-medium text-base-fg/80 transition-all ${
flashValue === value ? "ring-2 ring-primary" : "" flashValue === value ? "ring-2 ring-primary" : ""
}`; }`;
return ( return (
<div className="relative"> <div className="relative">
<div <div
className={`flex flex-wrap items-center gap-1.5 rounded-xl bg-black/20 border border-white/5 px-3 py-2.5 ${ className={`flex flex-wrap items-center gap-1.5 rounded-[3px] bg-ui-panel border border-ui-panel-border focus-within:border-primary/60 px-3 py-2.5 ${
disabled ? "" : "cursor-text" disabled ? "" : "cursor-text"
}`} }`}
onClick={() => inputRef.current?.focus()} onClick={() => inputRef.current?.focus()}
@@ -183,6 +183,7 @@ export function TagChipInput({
setFocused(false); setFocused(false);
if (draft.trim()) commit(draft); if (draft.trim()) commit(draft);
}} }}
aria-label="Tags"
placeholder={chips.length === 0 ? "Add tags (comma separated)" : "Add tag"} placeholder={chips.length === 0 ? "Add tags (comma separated)" : "Add tag"}
className="min-w-[7rem] flex-1 bg-transparent py-0.5 text-sm text-base-fg placeholder:text-base-fg/40 focus:outline-none" className="min-w-[7rem] flex-1 bg-transparent py-0.5 text-sm text-base-fg placeholder:text-base-fg/40 focus:outline-none"
/> />
@@ -191,7 +192,7 @@ export function TagChipInput({
{dropdownOpen && ( {dropdownOpen && (
<div <div
className={`absolute left-0 right-0 z-30 max-h-48 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl ${ className={`absolute left-0 right-0 z-30 max-h-48 overflow-y-auto rounded-none border border-ui-panel-border bg-ui-panel p-1 shadow-xl ${
dropUp ? "bottom-full mb-1" : "top-full mt-1" dropUp ? "bottom-full mb-1" : "top-full mt-1"
}`} }`}
> >
@@ -203,7 +204,7 @@ export function TagChipInput({
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={() => pickSuggestion(suggestion.value)} onClick={() => pickSuggestion(suggestion.value)}
onMouseEnter={() => setHighlightIndex(index)} onMouseEnter={() => setHighlightIndex(index)}
className={`flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-sm text-base-fg transition-colors ${ className={`flex w-full items-center gap-2 rounded-[3px] px-2 py-1.5 text-sm text-base-fg transition-colors ${
highlight === index ? "bg-ui-controls/60" : "hover:bg-ui-controls/40" highlight === index ? "bg-ui-controls/60" : "hover:bg-ui-controls/40"
}`} }`}
> >
@@ -236,7 +236,7 @@ export function TagsSection({
return ( return (
<div className="space-y-2"> <div className="space-y-2">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-medium text-base-fg/60"> <div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60">
<TagIcon /> <TagIcon />
<span>Tags</span> <span>Tags</span>
</div> </div>
@@ -16,7 +16,7 @@ export const ListDropdown = ({ list, onSelect }: ListDropdownProps) => {
return ( return (
<Listbox value={selected} onChange={setSelected}> <Listbox value={selected} onChange={setSelected}>
<div className="relative mt-1"> <div className="relative mt-1">
<Listbox.Button className="relative h-10 w-full cursor-pointer rounded-md bg-brand-secondary py-2 pl-3 pr-10 text-left outline-none outline-offset-0 transition-all duration-150 ease-in-out focus:outline-brand-primary sm:text-sm"> <Listbox.Button className="relative h-10 w-full cursor-pointer rounded-[3px] border border-ui-controls-border bg-ui-controls py-2 pl-3 pr-10 text-left text-base-fg outline-none outline-offset-0 transition-colors duration-150 ease-in-out hover:border-white/40 focus:!outline-none sm:text-sm">
<span className="block truncate">{Object.keys(selected)[0]}</span> <span className="block truncate">{Object.keys(selected)[0]}</span>
<span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2"> <span className="pointer-events-none absolute inset-y-0 right-0 flex items-center pr-2">
<ChevronDownIcon aria-hidden="true" /> <ChevronDownIcon aria-hidden="true" />
@@ -28,12 +28,17 @@ export const ListDropdown = ({ list, onSelect }: ListDropdownProps) => {
leaveFrom="opacity-100" leaveFrom="opacity-100"
leaveTo="opacity-0" leaveTo="opacity-0"
> >
<Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-md bg-brand-secondary py-1 text-base shadow-lg focus:outline-none sm:text-sm"> <Listbox.Options className="absolute z-10 mt-1 max-h-60 w-full overflow-auto rounded-[3px] border border-ui-panel-border bg-ui-panel text-base focus:outline-none sm:text-sm">
{list.map((item, itemIdx) => ( {list.map((item, itemIdx) => (
<Listbox.Option <Listbox.Option
key={itemIdx} key={itemIdx}
className={({ active }) => className={({ active, selected }) =>
`relative cursor-pointer select-none py-2 pl-10 pr-4 text-white ${active ? "text-white" : "text-gray-400" `relative cursor-pointer select-none py-2 pl-10 pr-4 transition-colors duration-150 ${
active
? "bg-white text-black"
: selected
? "bg-white/10 text-base-fg"
: "text-base-fg/90"
}` }`
} }
value={item} value={item}
@@ -49,11 +49,11 @@ export const LoadingBar = ({
propsInnerWrapperClassName, propsInnerWrapperClassName,
); );
const progressBackgroundClassName = twMerge( const progressBackgroundClassName = twMerge(
"w-full bg-brand-secondary-800 rounded-full h-2.5", "w-full bg-brand-secondary-800 rounded-none h-2.5",
propsProgressBackgroundClassName, propsProgressBackgroundClassName,
); );
const progressClassName = twMerge( const progressClassName = twMerge(
"h-2.5 rounded-full transition-all duration-1000", "h-2.5 rounded-none transition-all duration-1000",
getVariantClassNames(variant), getVariantClassNames(variant),
propsProgressClassName, propsProgressClassName,
); );
@@ -112,21 +112,21 @@ export function DesktopLoginBridge({ onSuccess, onStart, onActiveChange }: { onS
}; };
const remaining = challenge ? Math.max(0, Math.ceil((Date.parse(challenge.expires_at) - now) / 1000)) : 0; const remaining = challenge ? Math.max(0, Math.ceil((Date.parse(challenge.expires_at) - now) / 1000)) : 0;
return <section aria-label="Website login" className="mb-6 rounded-xl border border-white/15 p-4 text-center"> return <section aria-label="Website login" className="mb-6 border border-white/15 bg-white/[0.03] p-4 text-center">
{isActive && <div className="mb-4 text-left"> {isActive && <div className="mb-4 text-left">
<button type="button" className="inline-flex items-center gap-2 text-sm text-white/70 hover:text-white" onClick={back}> <button type="button" className="inline-flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 transition-colors hover:text-white" onClick={back}>
<ArrowLeftIcon size={16} aria-hidden="true" /> Back <ArrowLeftIcon size={16} aria-hidden="true" /> Back
</button> </button>
</div>} </div>}
{challenge ? <> {challenge ? <>
<p className="mb-3 text-sm text-white/70">Scan with your phone or approve in your browser.</p> <p className="mb-3 text-sm text-white/70">Scan with your phone or approve in your browser.</p>
<QRCodeSVG value={challenge.verification_url} size={192} marginSize={4} title="Scan to approve desktop login" className="mx-auto rounded-lg" /> <QRCodeSVG value={challenge.verification_url} size={192} marginSize={4} title="Scan to approve desktop login" className="mx-auto" />
<p className="my-3 font-mono text-2xl tracking-widest">{challenge.confirmation_code.slice(0, 4)}-{challenge.confirmation_code.slice(4)}</p> <p className="my-3 font-mono text-2xl tracking-[0.2em]">{challenge.confirmation_code.slice(0, 4)}-{challenge.confirmation_code.slice(4)}</p>
<p className="mb-3 text-xs text-white/60">Verify this code on the website. Expires in {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, "0")}</p> <p className="mb-4 font-mono text-[11px] uppercase tracking-[0.12em] text-white/50">Verify this code on the website. Expires in {Math.floor(remaining / 60)}:{String(remaining % 60).padStart(2, "0")}</p>
<button type="button" className="rounded-lg border border-white/30 px-4 py-2" onClick={() => OpenUrl(challenge.verification_url).catch(() => setMessage("Unable to open the browser. Scan the QR code instead."))}>Open website</button> <button type="button" className="h-10 rounded-[3px] border border-white/15 bg-white/5 px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white transition-colors hover:bg-white/10" onClick={() => OpenUrl(challenge.verification_url).catch(() => setMessage("Unable to open the browser. Scan the QR code instead."))}>Open website</button>
</> : busy ? <p className="text-sm text-white/70" role="status">Preparing login…</p> : <div className="flex flex-col gap-2"> </> : busy ? <p className="font-mono text-[11px] uppercase tracking-[0.12em] text-white/60" role="status">Preparing login…</p> : <div className="flex flex-col gap-2">
<button type="button" className="rounded-lg bg-white px-4 py-3 font-medium text-black" onClick={() => start(true)}>Login with Website</button> <button type="button" className="h-10 rounded-[3px] bg-white px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors hover:bg-white/90" onClick={() => start(true)}>Login with Website</button>
<button type="button" className="rounded-lg border border-white/30 px-4 py-2" onClick={() => start(false)}>Scan to Login</button> <button type="button" className="h-10 rounded-[3px] border border-white/15 bg-white/5 px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white transition-colors hover:bg-white/10" onClick={() => start(false)}>Scan to Login</button>
<p className="text-xs text-white/50">Use Google or any account already signed in on the website.</p> <p className="text-xs text-white/50">Use Google or any account already signed in on the website.</p>
</div>} </div>}
{message && <p className="mt-3 text-sm text-white/70" role="status">{message}</p>} {message && <p className="mt-3 text-sm text-white/70" role="status">{message}</p>}
@@ -1,6 +1,5 @@
.panel { .panel {
position: relative; position: relative;
isolation: isolate;
display: flex; display: flex;
width: 100%; width: 100%;
flex-direction: column; flex-direction: column;
@@ -8,60 +7,44 @@
overflow: hidden; overflow: hidden;
padding: 56px 32px; padding: 56px 32px;
text-align: center; text-align: center;
background: radial-gradient(ellipse at 50% 0%, rgb(94 234 212 / 9%), transparent 70%);
animation: arrive 450ms ease-out both; animation: arrive 450ms ease-out both;
} }
.glow {
position: absolute;
z-index: -1;
top: -52px;
left: calc(50% - 144px);
width: 288px;
height: 288px;
border-radius: 50%;
background: radial-gradient(circle, rgb(94 234 212 / 24%), rgb(129 140 248 / 12%) 45%, transparent 70%);
filter: blur(24px);
pointer-events: none;
animation: glow 2400ms ease-in-out infinite;
}
.badge { .badge {
display: flex; display: flex;
width: 76px; width: 64px;
height: 76px; height: 64px;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
margin-bottom: 28px; margin-bottom: 28px;
border: 1px solid rgb(153 246 228 / 35%); border: 1px solid rgb(255 255 255 / 20%);
border-radius: 50%; color: #ffffff;
color: #b4f7de; background: rgb(255 255 255 / 5%);
background: linear-gradient(145deg, rgb(153 246 228 / 16%), rgb(94 234 212 / 4%));
box-shadow: 0 0 0 8px rgb(94 234 212 / 4%), 0 0 44px rgb(94 234 212 / 18%), inset 0 1px 0 rgb(255 255 255 / 12%);
} }
.heading { .heading {
margin: 0; margin: 0;
max-width: 100%; max-width: 100%;
color: rgb(255 255 255 / 80%); color: rgb(255 255 255 / 60%);
font-size: 18px; font-family: "Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-weight: 500; font-size: 11px;
font-weight: 600;
letter-spacing: 0.12em;
line-height: 1.5; line-height: 1.5;
text-transform: uppercase;
} }
.username { .username {
display: block; display: block;
margin-top: 4px; margin-top: 10px;
overflow-wrap: anywhere; overflow-wrap: anywhere;
color: #b4f7de; color: #ffffff;
background: linear-gradient(100deg, #d4ffec, #99f6e4 55%, #c7d2fe); font-family: "Archivo", ui-sans-serif, system-ui, sans-serif;
background-clip: text;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 30px; font-size: 30px;
font-weight: 600; font-weight: 500;
letter-spacing: -0.025em; letter-spacing: -0.025em;
line-height: 1.25; line-height: 1.1;
text-transform: none;
} }
.caption { .caption {
@@ -72,15 +55,10 @@
} }
@keyframes arrive { @keyframes arrive {
from { opacity: 0; transform: translateY(8px) scale(0.98); } from { opacity: 0; transform: translateY(8px); }
to { opacity: 1; transform: translateY(0) scale(1); } to { opacity: 1; transform: translateY(0); }
}
@keyframes glow {
0%, 100% { opacity: 0.7; transform: scale(0.95); }
50% { opacity: 1; transform: scale(1.06); }
} }
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.panel, .glow { animation: none; } .panel { animation: none; }
} }
@@ -4,9 +4,8 @@ import styles from "./LoginSuccess.module.css";
export function LoginSuccess({ username }: { username: string }) { export function LoginSuccess({ username }: { username: string }) {
return ( return (
<div className={styles.panel} role="status" aria-live="polite" aria-atomic="true"> <div className={styles.panel} role="status" aria-live="polite" aria-atomic="true">
<div className={styles.glow} aria-hidden="true" />
<div className={styles.badge} aria-hidden="true"> <div className={styles.badge} aria-hidden="true">
<CheckIcon size={34} strokeWidth={2} /> <CheckIcon size={28} strokeWidth={2} />
</div> </div>
<h2 className={styles.heading}> <h2 className={styles.heading}>
Logged in as{" "} Logged in as{" "}
@@ -18,9 +18,10 @@ interface ArtCraftSignUpProps {
isLoading?: boolean; isLoading?: boolean;
} }
const FIELD_LABEL = "text-xs font-semibold text-white/70 ml-1"; const FIELD_LABEL =
"block font-mono text-[11px] font-semibold leading-4 uppercase tracking-[0.12em] text-white/70";
const FIELD_INPUT = const FIELD_INPUT =
"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"; "h-10 w-full rounded-[3px] border border-white/15 bg-white/5 px-4 py-2 text-sm font-normal leading-5 text-white placeholder-white/20 outline-none transition-colors focus:border-white/40";
export const ArtCraftSignUp = ({ export const ArtCraftSignUp = ({
onSubmit, onSubmit,
@@ -74,12 +75,12 @@ export const ArtCraftSignUp = ({
return ( return (
<form <form
className="flex w-full flex-col gap-2" className="flex w-full flex-col gap-4"
onSubmit={handleSubmit} onSubmit={handleSubmit}
ref={formRef} ref={formRef}
> >
{localError && ( {localError && (
<div className="flex items-center justify-center gap-2 rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500"> <div className="flex items-center justify-center gap-2 border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
<TriangleAlertIcon /> <TriangleAlertIcon />
{localError} {localError}
</div> </div>
@@ -87,7 +88,7 @@ export const ArtCraftSignUp = ({
{isSignUp ? ( {isSignUp ? (
<> <>
<div className="space-y-1"> <div className="space-y-2">
<label className={FIELD_LABEL}>Username</label> <label className={FIELD_LABEL}>Username</label>
<Input <Input
name="username" name="username"
@@ -97,7 +98,7 @@ export const ArtCraftSignUp = ({
inputClassName={FIELD_INPUT} inputClassName={FIELD_INPUT}
/> />
</div> </div>
<div className="space-y-1"> <div className="space-y-2">
<label className={FIELD_LABEL}>Email</label> <label className={FIELD_LABEL}>Email</label>
<Input <Input
name="email" name="email"
@@ -110,7 +111,7 @@ export const ArtCraftSignUp = ({
</div> </div>
</> </>
) : ( ) : (
<div className="space-y-1"> <div className="space-y-2">
<label className={FIELD_LABEL}>Email or Username</label> <label className={FIELD_LABEL}>Email or Username</label>
<Input <Input
name="usernameOrEmail" name="usernameOrEmail"
@@ -122,7 +123,7 @@ export const ArtCraftSignUp = ({
</div> </div>
)} )}
<div className="space-y-1"> <div className="space-y-2">
<label className={FIELD_LABEL}>Password</label> <label className={FIELD_LABEL}>Password</label>
<div className="relative"> <div className="relative">
<Input <Input
@@ -145,7 +146,7 @@ export const ArtCraftSignUp = ({
</div> </div>
{isSignUp && ( {isSignUp && (
<div className="space-y-1"> <div className="space-y-2">
<label className={FIELD_LABEL}>Confirm Password</label> <label className={FIELD_LABEL}>Confirm Password</label>
<div className="relative"> <div className="relative">
<Input <Input
@@ -168,14 +169,14 @@ export const ArtCraftSignUp = ({
</div> </div>
)} )}
<div className="pt-4"> <div className="pt-2">
<Button <Button
type="submit" type="submit"
disabled={isLoading} disabled={isLoading}
className="h-10 w-full justify-center rounded-full border-none bg-primary font-bold text-white hover:bg-primary-600" className="h-10 w-full justify-center rounded-[3px] border-none bg-white font-mono text-xs font-semibold uppercase tracking-[0.12em] text-black shadow-none hover:bg-white/90"
> >
{isLoading ? ( {isLoading ? (
<LoaderCircleIcon className="animate-spin" /> <LoaderCircleIcon className="animate-spin" />
) : isSignUp ? ( ) : isSignUp ? (
"Sign up" "Sign up"
) : ( ) : (
@@ -184,7 +185,7 @@ export const ArtCraftSignUp = ({
</Button> </Button>
</div> </div>
<div className="mt-2 text-center text-sm text-white/60"> <div className="mt-4 text-center text-sm text-white/60">
{isSignUp ? "Already have an account?" : "Don't have an account?"}{" "} {isSignUp ? "Already have an account?" : "Don't have an account?"}{" "}
<button <button
type="button" type="button"
@@ -14,6 +14,12 @@ import { useLoginModalStore } from "./useLoginModalStore";
const DEFAULT_SHOWCASE_VIDEO = const DEFAULT_SHOWCASE_VIDEO =
"https://frontend-cdn.fakeyou.com/videos/knight-video.mp4"; "https://frontend-cdn.fakeyou.com/videos/knight-video.mp4";
const LOGIN_SUCCESS_DURATION_MS = 3000; const LOGIN_SUCCESS_DURATION_MS = 3000;
const FRAME_CORNERS = [
"top-0 left-0",
"top-0 right-0",
"bottom-0 left-0",
"bottom-0 right-0",
];
interface LoginModalProps { interface LoginModalProps {
onClose?: () => void; onClose?: () => void;
@@ -145,10 +151,10 @@ export function LoginModal({
if (showSuccess) { if (showSuccess) {
return ( return (
<div className="flex flex-1 flex-col items-center justify-center px-8 py-16 text-center"> <div className="flex flex-1 flex-col items-center justify-center px-8 py-16 text-center">
<h2 className="mb-2 text-3xl font-bold text-white"> <h2 className="mb-3 font-display text-3xl leading-[1.05] tracking-tight text-white sm:text-4xl">
Thank you for signing in! Thank you for signing in!
</h2> </h2>
<p className="mb-6 text-white/70"> <p className="mb-8 text-sm leading-relaxed text-white/60">
You're all set to start creating amazing content. You're all set to start creating amazing content.
</p> </p>
<Button <Button
@@ -156,7 +162,7 @@ export function LoginModal({
onClick={handleClose} onClick={handleClose}
icon={ArrowRightIcon} icon={ArrowRightIcon}
iconFlip={true} iconFlip={true}
className="text-md" className="h-10 rounded-[3px] bg-white px-5 font-mono text-xs font-semibold uppercase tracking-[0.12em] text-black shadow-none hover:bg-white/90"
> >
Get Started Get Started
</Button> </Button>
@@ -167,16 +173,17 @@ export function LoginModal({
// Discord // Discord
return ( return (
<div className="flex flex-1 flex-col items-center justify-center px-8 py-16 text-center"> <div className="flex flex-1 flex-col items-center justify-center px-8 py-16 text-center">
<h2 className="mb-2 text-3xl font-bold text-white"> <h2 className="mb-3 font-display text-3xl leading-[1.05] tracking-tight text-white sm:text-4xl">
Join Our Community Join our <span className="font-serif-italic">community.</span>
</h2> </h2>
<p className="mb-6 max-w-md text-white/70"> <p className="mb-8 max-w-md text-sm leading-relaxed text-white/60">
Connect with other creators, share your work, and get the latest Connect with other creators, share your work, and get the latest
updates in our Discord community. updates in our Discord community.
</p> </p>
<div className="flex gap-4"> <div className="flex gap-3">
<Button <Button
variant="secondary" variant="secondary"
className="h-10 rounded-[3px] border border-white/15 bg-white/5 px-4 font-mono text-xs font-semibold uppercase tracking-[0.12em] text-white shadow-none hover:bg-white/10"
onClick={() => { onClick={() => {
setShowDiscord(false); setShowDiscord(false);
setShowSuccess(true); setShowSuccess(true);
@@ -188,7 +195,7 @@ export function LoginModal({
variant="primary" variant="primary"
onClick={handleDiscordJoin} onClick={handleDiscordJoin}
icon={DiscordIcon} icon={DiscordIcon}
className="text-md bg-[#5865F2] hover:bg-[#6a76ff]" className="h-10 rounded-[3px] bg-[#5865F2] px-4 font-mono text-xs font-semibold uppercase tracking-[0.12em] text-white shadow-none hover:bg-[#6a76ff]"
> >
Join Discord Join Discord
</Button> </Button>
@@ -222,59 +229,84 @@ export function LoginModal({
leaveTo="opacity-0 scale-95" leaveTo="opacity-0 scale-95"
> >
<div <div
className={`relative flex w-full overflow-hidden rounded-3xl border bg-[#1C1C20] text-white shadow-2xl ${loggedInUsername !== null ? "max-w-md border-emerald-200/20" : "max-w-5xl border-white/[4%] lg:min-h-[640px]"}`} className={`relative w-full ${loggedInUsername !== null ? "max-w-md" : "max-w-lg lg:max-w-5xl"}`}
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}
> >
{loggedInUsername !== null ? ( <div
<LoginSuccess username={loggedInUsername} /> className={`relative flex w-full overflow-hidden border border-white/20 bg-[#1e1f22] text-white ${loggedInUsername !== null ? "" : "lg:min-h-[560px]"}`}
) : inOnboarding ? ( >
renderOnboarding() {loggedInUsername !== null ? (
) : ( <LoginSuccess username={loggedInUsername} />
<> ) : inOnboarding ? (
{/* ── Form pane ── (no dismiss control — login is required) */} renderOnboarding()
<div className="relative flex w-full flex-col lg:w-1/2"> ) : (
<div className="flex flex-1 flex-col justify-center px-8 py-10 sm:px-10"> <>
<div className="mx-auto w-full max-w-sm"> {/* ── Form pane ── (no dismiss control — login is required) */}
<div className="mb-8 text-center"> <div className="relative flex w-full flex-col lg:w-1/2">
<img <div className="flex flex-1 flex-col justify-center px-6 py-10 sm:px-10 sm:py-12">
src="/resources/logo/artcraft-icon.png" <div className="w-full">
alt="ArtCraft" <div className="mb-8 text-left">
className="pointer-events-none mx-auto mb-6 h-12 w-auto select-none" <img
draggable={false} src="/resources/logo/artcraft-icon.png"
/> alt="ArtCraft"
<h1 className="mb-2 text-2xl font-semibold"> className="pointer-events-none mb-8 h-8 w-auto select-none"
{isSignUp ? "Create your account" : "Welcome back"} draggable={false}
</h1> />
<p className="text-sm text-white/60"> <h1 className="mb-3 text-balance font-display text-3xl leading-[1.05] tracking-tight sm:text-4xl">
{isSignUp {isSignUp ? (
? "Sign up to start creating with ArtCraft" "Create your account"
: "Log in to your account"} ) : (
</p> <>
</div> Welcome{" "}
<span className="font-serif-italic">back.</span>
</>
)}
</h1>
<p className="text-sm leading-relaxed text-white/60">
{isSignUp
? "Sign up to start creating with ArtCraft"
: "Log in to your creative workspace."}
</p>
</div>
{!isSignUp && <DesktopLoginBridge onActiveChange={setIsChallengeActive} onStart={() => { authGeneration.current += 1; }} onSuccess={handleLoginSuccess} />} {!isSignUp && <DesktopLoginBridge onActiveChange={setIsChallengeActive} onStart={() => { authGeneration.current += 1; }} onSuccess={handleLoginSuccess} />}
{!isChallengeActive && <ArtCraftSignUp {!isSignUp && !isChallengeActive && (
onSubmit={handleAuthSubmit} <div className="mb-6 flex items-center gap-4 before:h-px before:flex-1 before:bg-white/15 after:h-px after:flex-1 after:bg-white/15">
isSignUp={isSignUp} <span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
onToggleMode={() => setIsSignUp((prev) => !prev)} or
errorMessage={errorMessage} </span>
isLoading={isLoading} </div>
/>} )}
{!isChallengeActive && <ArtCraftSignUp
onSubmit={handleAuthSubmit}
isSignUp={isSignUp}
onToggleMode={() => setIsSignUp((prev) => !prev)}
errorMessage={errorMessage}
isLoading={isLoading}
/>}
</div>
</div>
<div className="px-6 pb-6 text-center font-mono text-[10px] uppercase tracking-[0.12em] text-white/25 sm:px-10">
&copy; {new Date().getFullYear()} ArtCraft. All rights
reserved.
</div> </div>
</div> </div>
<div className="px-8 pb-8 text-center text-xs text-white/20"> {/* ── Showcase pane (desktop only) ── */}
&copy; {new Date().getFullYear()} ArtCraft. All rights <div className="relative hidden border-l border-white/15 lg:block lg:w-1/2">
reserved. <LoginShowcase videoUrl={videoUrl} />
</div> </div>
</div> </>
)}
{/* ── Showcase pane (desktop only) ── */} </div>
<div className="relative hidden lg:block lg:w-1/2"> {FRAME_CORNERS.map((corner) => (
<LoginShowcase videoUrl={videoUrl} /> <span
</div> key={corner}
</> aria-hidden="true"
)} className={`frame-corner-mark ${corner}`}
/>
))}
</div> </div>
</TransitionChild> </TransitionChild>
</div> </div>
@@ -287,7 +319,7 @@ export function LoginModal({
// with a legibility gradient + caption. Mirrors the webapp auth-showcase. // with a legibility gradient + caption. Mirrors the webapp auth-showcase.
function LoginShowcase({ videoUrl }: { videoUrl: string }) { function LoginShowcase({ videoUrl }: { videoUrl: string }) {
return ( return (
<div className="absolute inset-2 overflow-hidden rounded-2xl bg-black"> <div className="absolute inset-0 overflow-hidden bg-[#1e1f22]">
<video <video
src={videoUrl} src={videoUrl}
autoPlay autoPlay
@@ -304,7 +336,7 @@ function LoginShowcase({ videoUrl }: { videoUrl: string }) {
/> />
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-8"> <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 One of the cheapest
</p> </p>
<h2 className="text-2xl font-bold leading-tight"> <h2 className="text-2xl font-bold leading-tight">
@@ -87,14 +87,14 @@ export const MenuIconSelector: React.FC<MenuIconSelectorProps> = ({
> >
<div <div
ref={containerRef} ref={containerRef}
className="relative flex gap-1 rounded-full px-1 py-0.5 min-w-max bg-ui-panel border border-ui-panel-border" className="relative flex gap-1 rounded-[3px] px-1 py-0.5 min-w-max bg-ui-panel border border-ui-panel-border"
style={{ minWidth: 0 }} style={{ minWidth: 0 }}
onMouseLeave={() => setHoveredIndex(-1)} onMouseLeave={() => setHoveredIndex(-1)}
> >
{/* Active tab background */} {/* Active tab background */}
{selectedIndex >= 0 && ( {selectedIndex >= 0 && (
<div <div
className="absolute z-10 rounded-full bg-primary/25 border-2 border-primary shadow-md transition-all duration-150 ease-in-out -ml-[1px]" className="absolute z-10 rounded-[2px] bg-base-fg/[0.08] transition-all duration-150 ease-in-out after:absolute after:inset-x-1.5 after:-bottom-1 after:h-0.5 after:bg-base-fg"
style={{ style={{
left: activeStyle.left, left: activeStyle.left,
width: activeStyle.width, width: activeStyle.width,
@@ -106,7 +106,7 @@ export const MenuIconSelector: React.FC<MenuIconSelectorProps> = ({
{/* Hover background (only if hovering a different tab) */} {/* Hover background (only if hovering a different tab) */}
{hoveredIndex !== -1 && hoveredIndex !== selectedIndex && ( {hoveredIndex !== -1 && hoveredIndex !== selectedIndex && (
<div <div
className="absolute z-20 rounded-full bg-primary/10 transition-all duration-150 ease-in-out pointer-events-none -ml-[1px]" className="absolute z-20 rounded-[2px] bg-base-fg/[0.08] transition-all duration-150 ease-in-out pointer-events-none"
style={{ style={{
left: hoverStyle.left, left: hoverStyle.left,
width: hoverStyle.width, width: hoverStyle.width,
@@ -136,10 +136,7 @@ export const MenuIconSelector: React.FC<MenuIconSelectorProps> = ({
closeOnClick={true} closeOnClick={true}
interactive={interactive} interactive={interactive}
disabled={disabled} disabled={disabled}
className={twMerge( className="font-sans text-xs font-medium normal-case tracking-normal"
"text-sm font-semibold",
item.large && "text-md",
)}
imageSrc={imageSrc || undefined} imageSrc={imageSrc || undefined}
description={description || undefined} description={description || undefined}
> >
@@ -151,7 +148,10 @@ export const MenuIconSelector: React.FC<MenuIconSelectorProps> = ({
onClick={() => !disabled && onMenuChange(item.id)} onClick={() => !disabled && onMenuChange(item.id)}
onMouseEnter={() => setHoveredIndex(idx)} onMouseEnter={() => setHoveredIndex(idx)}
className={twMerge( className={twMerge(
"relative z-30 flex flex-col items-center justify-center px-3 py-2 rounded-full transition-all duration-150 text-base-fg", "relative z-30 flex flex-col items-center justify-center px-3 py-2 rounded-[2px] transition-colors duration-150",
idx === selectedIndex
? "text-base-fg"
: "text-base-fg/70 hover:text-base-fg",
disabled ? "cursor-not-allowed opacity-60" : "", disabled ? "cursor-not-allowed opacity-60" : "",
)} )}
tabIndex={0} tabIndex={0}
@@ -153,8 +153,7 @@ const AnimatedBackdrop = ({
style={{ style={{
opacity: styles.opacity, opacity: styles.opacity,
pointerEvents: "none", pointerEvents: "none",
background: background: "rgba(0, 0, 0, 0.7)",
"radial-gradient(800px 400px at 10% -10%, rgba(45,129,255,0.10), transparent), radial-gradient(600px 320px at 110% 110%, rgba(28,182,190,0.10), transparent), rgba(0,0,0,0.60)",
}} }}
/> />
</Dialog.Overlay> </Dialog.Overlay>
@@ -194,19 +193,13 @@ const ExpandButton = ({ className, size = "md" }: ExpandButtonProps) => {
aria-label={expanded ? "Restore modal size" : "Expand modal"} aria-label={expanded ? "Restore modal size" : "Expand modal"}
onClick={toggleExpanded} onClick={toggleExpanded}
className={twMerge( className={twMerge(
"flex items-center justify-center rounded-full bg-black/40 text-white/60 transition-all hover:bg-black/70 hover:text-white", "flex items-center justify-center rounded-[3px] bg-black/40 text-white/60 transition-colors hover:bg-white hover:text-black",
sizeClasses[size], sizeClasses[size],
"relative z-[70]", "relative z-[70]",
className, className,
)} )}
> >
<DynamicIcon <DynamicIcon icon={expanded ? Minimize2Icon : Maximize2Icon} />
icon={
expanded
? Minimize2Icon
: Maximize2Icon
}
/>
</button> </button>
); );
}; };
@@ -1083,7 +1076,7 @@ export const Modal = ({
> >
<animated.div <animated.div
className={twMerge( className={twMerge(
"w-full max-w-lg rounded-2xl relative border border-ui-panel-border bg-ui-modal text-left align-middle shadow-2xl z-[70]", "w-full max-w-lg rounded-none relative border border-ui-panel-border bg-ui-modal text-left align-middle z-[70]",
childPadding && !expanded ? "p-4" : "", childPadding && !expanded ? "p-4" : "",
className, className,
"!transition-none", // Always disable CSS transitions for spring animations "!transition-none", // Always disable CSS transitions for spring animations
@@ -17,7 +17,7 @@ import {
getCreatorIcon, getCreatorIcon,
getModelFamilyName, getModelFamilyName,
} from "@storyteller/model-list"; } from "@storyteller/model-list";
import { ChevronUpIcon, CircleCheckIcon } from "lucide-react"; import { ChevronUpIcon, CheckIcon } from "lucide-react";
import { GenerationProvider } from "@storyteller/api-enums"; import { GenerationProvider } from "@storyteller/api-enums";
import { Tooltip } from "@storyteller/ui-tooltip"; import { Tooltip } from "@storyteller/ui-tooltip";
import { defaultModelForPage } from "./defaultModelForPage"; import { defaultModelForPage } from "./defaultModelForPage";
@@ -107,7 +107,7 @@ function ProviderTooltipContent({
onFinished?.(); onFinished?.();
}} }}
type="button" type="button"
className={`group flex cursor-pointer items-center justify-between rounded-lg px-2 py-2 transition-all ${ className={`group flex cursor-pointer items-center justify-between rounded-[3px] px-2 py-2 transition-all ${
selectedProvider === p selectedProvider === p
? "bg-ui-controls/70 border-l-4 border-primary" ? "bg-ui-controls/70 border-l-4 border-primary"
: "hover:bg-ui-controls/50" : "hover:bg-ui-controls/50"
@@ -118,8 +118,8 @@ function ProviderTooltipContent({
{getProviderDisplayName(p)} {getProviderDisplayName(p)}
</span> </span>
{selectedProvider === p && ( {selectedProvider === p && (
<span className="text-primary text-xl font-bold bg-white rounded-full p-0 h-4 w-4 flex items-center justify-center"> <span className="ml-2 flex h-5 w-5 shrink-0 items-center justify-center text-white">
<CircleCheckIcon /> <CheckIcon aria-hidden="true" className="h-4 w-4" strokeWidth={2.5} />
</span> </span>
)} )}
</button> </button>
@@ -229,7 +229,7 @@ export function ClassyModelSelector({
: undefined; : undefined;
const iconProvider = prov ?? allowedProviders[0]; const iconProvider = prov ?? allowedProviders[0];
return iconProvider ? ( return iconProvider ? (
<div className="mr-1 rounded-md p-1.5 bg-ui-controls/60 group-hover:bg-ui-controls/80 transition-colors"> <div className="mr-1 p-1.5 bg-ui-controls/60 group-hover:bg-ui-controls/80 transition-colors">
<span className="text-base-fg/70 group-hover:text-base-fg/90 text-lg"> <span className="text-base-fg/70 group-hover:text-base-fg/90 text-lg">
{getProviderIcon(iconProvider)} {getProviderIcon(iconProvider)}
</span> </span>
@@ -241,7 +241,7 @@ export function ClassyModelSelector({
isSameModel(item.model as Model | undefined, selectedModel) && isSameModel(item.model as Model | undefined, selectedModel) &&
selectedProvider && selectedProvider &&
hasMultipleProviders ? ( hasMultipleProviders ? (
<div className="mr-1 rounded-md p-1.5 bg-primary/60 group-hover:bg-primary/80 transition-colors"> <div className="mr-1 p-1.5 bg-primary/60 group-hover:bg-primary/80 transition-colors">
<span className="text-base-fg/70 group-hover:text-base-fg/90 text-lg"> <span className="text-base-fg/70 group-hover:text-base-fg/90 text-lg">
{getProviderIcon(selectedProvider)} {getProviderIcon(selectedProvider)}
</span> </span>
@@ -313,7 +313,7 @@ export function ClassyModelSelector({
mode="hoverSelect" mode="hoverSelect"
maxListHeight={maxListHeight} maxListHeight={maxListHeight}
{...popoverProps} {...popoverProps}
buttonClassName="rounded-xl bg-ui-controls/90 hover:bg-ui-controls text-left shadow-sm px-3 py-1 gap-3 border border-ui-controls-border" buttonClassName="rounded-[3px] bg-ui-controls text-left shadow-sm px-3 py-1 gap-3 border border-ui-controls-border"
renderTrigger={(selectedItem) => { renderTrigger={(selectedItem) => {
const modelTitle = const modelTitle =
selectedItem?.label ?? selectedModel?.selectorName ?? ""; selectedItem?.label ?? selectedModel?.selectorName ?? "";
@@ -340,9 +340,7 @@ export function ClassyModelSelector({
</span> </span>
</div> </div>
</div> </div>
<ChevronUpIcon <ChevronUpIcon className="text-base text-base-fg/70 self-center" />
className="text-base text-base-fg/70 self-center" />
</div> </div>
); );
}} }}
@@ -81,9 +81,11 @@ export const BoardGrid = ({
const [viewportH, setViewportH] = useState(0); const [viewportH, setViewportH] = useState(0);
const [marquee, setMarquee] = useState<Marquee | null>(null); const [marquee, setMarquee] = useState<Marquee | null>(null);
const [focusedId, setFocusedId] = useState<string | null>(null); const [focusedId, setFocusedId] = useState<string | null>(null);
const marqueeRef = useRef<{ active: boolean; moved: boolean; additive: boolean }>( const marqueeRef = useRef<{
{ active: false, moved: false, additive: false }, active: boolean;
); moved: boolean;
additive: boolean;
}>({ active: false, moved: false, additive: false });
useLayoutEffect(() => { useLayoutEffect(() => {
const el = scrollRef.current; const el = scrollRef.current;
@@ -103,7 +105,10 @@ export const BoardGrid = ({
const groups = useMemo<SectionGroup[] | null>(() => { const groups = useMemo<SectionGroup[] | null>(() => {
if (!sections || sections.length === 0) return null; if (!sections || sections.length === 0) return null;
const known = new Set(sections.map((s) => s.id)); const known = new Set(sections.map((s) => s.id));
const bySection = new Map<string | null, Array<{ id: string; aspect: number }>>(); const bySection = new Map<
string | null,
Array<{ id: string; aspect: number }>
>();
for (const it of items) { for (const it of items) {
const key = it.sectionId && known.has(it.sectionId) ? it.sectionId : null; const key = it.sectionId && known.has(it.sectionId) ? it.sectionId : null;
const entry = { id: it.id, aspect: it.aspect }; const entry = { id: it.id, aspect: it.aspect };
@@ -119,7 +124,10 @@ export const BoardGrid = ({
})); }));
const ungrouped = bySection.get(null) ?? []; const ungrouped = bySection.get(null) ?? [];
return ungrouped.length > 0 return ungrouped.length > 0
? [{ id: null, name: "Ungrouped", collapsed: false, items: ungrouped }, ...named] ? [
{ id: null, name: "Ungrouped", collapsed: false, items: ungrouped },
...named,
]
: named; : named;
}, [items, sections]); }, [items, sections]);
@@ -313,7 +321,7 @@ export const BoardGrid = ({
})} })}
{marquee && marqueeRef.current.moved && ( {marquee && marqueeRef.current.moved && (
<div <div
className="pointer-events-none absolute rounded-[4px] border border-primary bg-primary/10" className="pointer-events-none absolute rounded-[3px] border border-primary bg-primary/10"
style={{ style={{
left: Math.min(marquee.x0, marquee.x1), left: Math.min(marquee.x0, marquee.x1),
top: Math.min(marquee.y0, marquee.y1), top: Math.min(marquee.y0, marquee.y1),
@@ -488,20 +488,20 @@ export const Stage3DBody = ({
className="absolute left-0 top-0 w-full" className="absolute left-0 top-0 w-full"
onClick={handleOverlayClick} onClick={handleOverlayClick}
> >
<div className="grid grid-cols-3 gap-4"> <div className="grid grid-cols-2 gap-x-4 gap-y-1 2xl:grid-cols-[minmax(0,1fr)_auto_minmax(0,1fr)]">
<div className="flex flex-col items-start gap-2"> <div className="min-w-0 flex flex-col items-start gap-2">
{!isRecord && <ControlsTopButtons />} {!isRecord && <ControlsTopButtons />}
{!isRecord && topBarStartSlot && ( {!isRecord && topBarStartSlot && (
<div className="pl-3">{topBarStartSlot}</div> <div className="pl-3">{topBarStartSlot}</div>
)} )}
</div> </div>
<div className="flex flex-col items-center"> <div className="col-span-2 row-start-2 flex flex-col items-center 2xl:col-span-1 2xl:col-start-2 2xl:row-start-1">
<SceneModePill /> <SceneModePill />
{!isRecord && ( {!isRecord && (
<Controls3D showImageTo3DButton={showImageTo3DButton} /> <Controls3D showImageTo3DButton={showImageTo3DButton} />
)} )}
</div> </div>
<div className="flex items-start justify-end gap-2 pr-3 pt-3"> <div className="col-start-2 row-start-1 flex min-w-0 flex-wrap items-start justify-end gap-2 pr-3 pt-3 2xl:col-start-3">
{/* Camera cluster: which camera drives the viewport + {/* Camera cluster: which camera drives the viewport +
the render-frame aspect ratio. */} the render-frame aspect ratio. */}
{!isRecord && <CameraStatusPill />} {!isRecord && <CameraStatusPill />}
@@ -21,7 +21,7 @@ export const AnonHintChip = () => {
<button <button
type="button" type="button"
onClick={handleClick} onClick={handleClick}
className="glass flex items-center gap-2 rounded-full border border-white/10 px-3 py-1.5 text-xs font-medium text-white/80 shadow-md transition-colors hover:bg-ui-controls/100 hover:text-white" className="flex shrink-0 items-center gap-2 whitespace-nowrap rounded-[3px] border border-ui-panel-border bg-ui-controls px-3 py-1.5 text-xs font-medium text-white/80 transition-colors hover:bg-white/10 hover:text-white"
> >
<LogInIcon className="opacity-70" /> <LogInIcon className="opacity-70" />
Sign up to save Sign up to save
@@ -6,6 +6,7 @@ import { CameraAspectRatio } from "../../enums";
import { setCameraAspect } from "../../actions"; import { setCameraAspect } from "../../actions";
import { usePageSceneStore } from "../../PageSceneStore"; import { usePageSceneStore } from "../../PageSceneStore";
import { EngineContext } from "../../contexts/EngineContext"; import { EngineContext } from "../../contexts/EngineContext";
import { TOOLBAR_BUTTON_CLASS_NAME } from "../toolbarStyles";
// Render-frame aspect ratio picker. Same popover pattern as the prompt // Render-frame aspect ratio picker. Same popover pattern as the prompt
// boxes' AspectRatioPicker, but driving the 3D editor's camera letterbox // boxes' AspectRatioPicker, but driving the 3D editor's camera letterbox
@@ -52,7 +53,7 @@ export const AspectRatioMenu = () => {
position="bottom" position="bottom"
panelTitle="Aspect Ratio" panelTitle="Aspect Ratio"
showIconsInList showIconsInList
buttonClassName="glass glass-no-hover h-[34px] rounded-full px-3 text-xs font-medium shadow-xl" buttonClassName={TOOLBAR_BUTTON_CLASS_NAME}
triggerIcon={<AspectRatioIcon ratio={current.proportions} />} triggerIcon={<AspectRatioIcon ratio={current.proportions} />}
/> />
</Tooltip> </Tooltip>
@@ -209,7 +209,7 @@ export const AnimationsModal = () => {
<Modal <Modal
isOpen={animationsModalVisible} isOpen={animationsModalVisible}
onClose={handleClose} onClose={handleClose}
className="relative h-[640px] max-w-4xl" className="relative h-[640px] max-w-4xl bg-ui-controls"
childPadding={false} childPadding={false}
showClose={false} showClose={false}
backdropClassName="bg-transparent" backdropClassName="bg-transparent"
@@ -228,8 +228,8 @@ export const AnimationsModal = () => {
</Modal.DragHandle> </Modal.DragHandle>
<div className="grid h-full grid-cols-12 gap-3"> <div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-3 flex h-full flex-col p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:bg-white/10"> <div className="relative col-span-3 flex h-full flex-col p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:bg-white/10">
<div className="flex items-center justify-between gap-2.5 py-0.5"> <div className="flex h-10 items-center justify-between gap-2.5">
<h2 className="text-[18px] font-semibold opacity-80"> <h2 className="font-display text-lg tracking-tight text-white">
Animations Animations
</h2> </h2>
</div> </div>
@@ -240,9 +240,9 @@ export const AnimationsModal = () => {
key={tab.id} key={tab.id}
variant={activeTab === tab.id ? "primary" : "secondary"} variant={activeTab === tab.id ? "primary" : "secondary"}
className={twMerge( className={twMerge(
"w-full justify-start rounded-xl border border-white/[2%] bg-white/[4%] px-3.5 py-2.5 text-left hover:bg-white/15", "w-full justify-start rounded-[3px] border border-transparent bg-transparent px-3.5 py-2.5 text-left hover:bg-white/15",
activeTab === tab.id && activeTab === tab.id &&
"border-primary bg-primary/10 hover:bg-primary/10", "border-white/15 bg-white/10 text-white hover:bg-white/10",
)} )}
onClick={() => setActiveTab(tab.id)} onClick={() => setActiveTab(tab.id)}
> >
@@ -259,7 +259,7 @@ export const AnimationsModal = () => {
variant="secondary" variant="secondary"
icon={ArrowUpFromLineIcon} icon={ArrowUpFromLineIcon}
iconClassName="opacity-70" iconClassName="opacity-70"
className="w-full justify-center rounded-xl border border-white/10 bg-white/[6%] px-3.5 py-2.5 hover:bg-white/15" className="w-full justify-center rounded-[3px] border border-white/10 bg-white/[6%] px-3.5 py-2.5 hover:bg-white/15"
onClick={handleOpenUpload} onClick={handleOpenUpload}
> >
Upload animation Upload animation
@@ -281,13 +281,13 @@ export const AnimationsModal = () => {
ref={searchInputRef} ref={searchInputRef}
placeholder="Search animations" placeholder="Search animations"
className="relative z-[51] grow" className="relative z-[51] grow"
inputClassName="pr-2.5" inputClassName="bg-white/5 pr-10 placeholder-white/40"
icon={SearchIcon} icon={SearchIcon}
value={searchTerm} value={searchTerm}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange={(e: ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value) setSearchTerm(e.target.value)
} }
iconClassName="text-white/60" iconClassName="pointer-events-none left-3 top-1/2 h-4 w-4 -translate-y-1/2 p-0 text-white/50"
/> />
{searchTerm && ( {searchTerm && (
<CloseButton <CloseButton
@@ -56,12 +56,12 @@ const AllTabSection = ({
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h3 className="text-md ml-2 font-semibold opacity-90">{label}</h3> <h3 className="text-md ml-2 font-semibold opacity-90">{label}</h3>
<Button <Button
variant="secondary" variant="ghost"
className="mr-3 flex items-center gap-1 px-2 py-1 text-xs" className="mr-3 h-6 gap-1 px-1.5 py-0 text-[10px] tracking-[0.08em] leading-none"
onClick={onViewAll} onClick={onViewAll}
> >
View all View all
<ChevronRightIcon className="text-xs opacity-70" /> <ChevronRightIcon className="h-3 w-3 shrink-0 opacity-70" />
</Button> </Button>
</div> </div>
<div className="h-[170px]"> <div className="h-[170px]">
@@ -487,7 +487,7 @@ export const AssetModal = () => {
<Modal <Modal
isOpen={assetModalVisible} isOpen={assetModalVisible}
onClose={handleClose} onClose={handleClose}
className="relative h-[640px] max-w-4xl" className="relative h-[640px] max-w-4xl bg-ui-controls"
childPadding={false} childPadding={false}
showClose={false} showClose={false}
backdropClassName="bg-transparent" backdropClassName="bg-transparent"
@@ -507,8 +507,8 @@ export const AssetModal = () => {
</Modal.DragHandle> </Modal.DragHandle>
<div className="grid h-full grid-cols-12 gap-3"> <div className="grid h-full grid-cols-12 gap-3">
<div className="relative col-span-3 flex h-full flex-col p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:bg-white/10"> <div className="relative col-span-3 flex h-full flex-col p-3 pt-2 after:absolute after:right-0 after:top-0 after:h-full after:w-px after:bg-gray-200 after:bg-white/10">
<div className="flex items-center justify-between gap-2.5 py-0.5"> <div className="flex h-10 items-center justify-between gap-2.5">
<h2 className="text-[18px] font-semibold opacity-80"> <h2 className="font-display text-lg tracking-tight text-white">
ArtCraft Presets ArtCraft Presets
</h2> </h2>
</div> </div>
@@ -519,9 +519,9 @@ export const AssetModal = () => {
key={tab.id} key={tab.id}
variant={activeAssetTab === tab.id ? "primary" : "secondary"} variant={activeAssetTab === tab.id ? "primary" : "secondary"}
className={twMerge( className={twMerge(
"w-full justify-start rounded-xl border border-white/[2%] bg-white/[4%] px-3.5 py-2.5 text-left hover:bg-white/15", "w-full justify-start rounded-[3px] border border-transparent bg-transparent px-3.5 py-2.5 text-left hover:bg-white/15",
activeAssetTab === tab.id && activeAssetTab === tab.id &&
"border-primary bg-primary/10 hover:bg-primary/10", "border-white/15 bg-white/10 text-white hover:bg-white/10",
)} )}
onClick={() => setActiveAssetTab(tab.id)} onClick={() => setActiveAssetTab(tab.id)}
> >
@@ -551,13 +551,13 @@ export const AssetModal = () => {
ref={searchInputRef} ref={searchInputRef}
placeholder="Search" placeholder="Search"
className="relative z-[51] grow" className="relative z-[51] grow"
inputClassName="pr-2.5" inputClassName="bg-white/5 pr-10 placeholder-white/40"
icon={SearchIcon} icon={SearchIcon}
value={searchTerm} value={searchTerm}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange={(e: ChangeEvent<HTMLInputElement>) =>
setSearchTerm(e.target.value) setSearchTerm(e.target.value)
} }
iconClassName="text-white/60" iconClassName="pointer-events-none left-3 top-1/2 h-4 w-4 -translate-y-1/2 p-0 text-white/50"
/> />
{searchTerm && ( {searchTerm && (
<CloseButton <CloseButton
@@ -110,7 +110,7 @@ export const ItemElement = ({ item }: Props) => {
)} )}
<div <div
className="pointer-events-none relative aspect-[16/12] w-full select-none overflow-hidden rounded-xl border-[3px] border-white/5 bg-brand-secondary-600 object-cover object-center transition-all group-hover:border-brand-primary" className="pointer-events-none relative aspect-[16/12] w-full select-none overflow-hidden rounded-none border border-white/15 bg-brand-secondary-600 object-cover object-center transition-all group-hover:border-brand-primary"
onPointerDown={(event) => dragAndDrop.onPointerDown(event, item, editor)} onPointerDown={(event) => dragAndDrop.onPointerDown(event, item, editor)}
onClick={handleClick} onClick={handleClick}
style={{ cursor: "grab", pointerEvents: "auto" }} style={{ cursor: "grab", pointerEvents: "auto" }}

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