Compare commits

..
Author SHA1 Message Date
Brandon Thomas df1f3cfc09 readme update 2026-09-12 11:44:24 -04:00
Tanish Manku f9a5ad9294 Merge pull request #5 from storytold/design/proofing
Redesign: Major ongoing website redesign
2026-09-11 13:47:20 -07:00
Tanish Manku c832215296 hero: reload on back/forward-cache restore — dead canvas in Firefox
Returning to the page via browser Back (e.g. from the /download
redirect's external target) restored it from the bfcache in Firefox:
the JS heap thaws but the WebGL context is discarded, so the galaxy
canvas came back blank and the rAF-driven instruments looked
unmounted. Chrome restores GPU state, which is why it never showed.

The pre-paint head script now attaches a pageshow listener that
reloads on e.persisted — the listener lives inside the frozen page,
so it fires on the restore itself. The intro is seen-flagged by
then and the page is statically prerendered, so the reload is cheap.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 13:10:07 -07:00
Tanish Manku c6a8dc5692 hero: fix starfish ring alignment — arm desync in slot units, golden default
The per-arm conveyor offset was in absolute cycle units, so it
resonated whenever it landed near a multiple of the slot spacing:
at 51 cards (9 slots/arm, spacing .111) the .13 offset was one slot
+ 17%, radially aligning every arm's ladder into six-fold rings —
the starfish. 64 cards (spacing .0909) just happened to dodge it.

The stagger is now measured in slot gaps (divided by slotsPerArm), so
it can never ring-align at any card count or viewport scale, and the
default moves to 0.38 — the golden-ratio conjugate — which lands the
six arms at low-discrepancy phases within the gap.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 12:58:50 -07:00
Tanish Manku 9b68d7e105 hero: default card count 64 -> 51, three cards per showcase clip
With 17 clips on the CDN, 51 cards lands the round-robin deal at
exactly three cards per clip (down from ~4.6 repeats at 64). The
neighbor-gap collision sizing grows the cards to keep the same screen
fill, and smaller viewports still scale the count down from the same
Tuned @ Mpx reference.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 12:56:30 -07:00
Tanish Manku 509c5188d6 hero: register showcase clips 015-017
The CDN holds 17 clips but the wall only dealt the first 14. Verified
015-017 return 200 (018+ 404). At the tuned 64-card count this drops
simultaneous repeats per clip from ~4.6 to ~3.8; the least-recently-
shown rebirth reassignment spreads what remains.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 12:44:59 -07:00
Tanish Manku 053dbaff65 hero: drop the content rails over the poster viewport
The max-width content container carried the site's border-x rails, so
two vertical hairlines cut through the immersive spiral/wordmark stage.
The poster now runs edge to edge; the structural rail-and-tick language
begins at the announce strip (the fold line), which keeps its border-x.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 12:34:07 -07:00
Tanish Manku ed575e764d webapp: fix phantom spin prop on lucide spinners + unused import
The #1916 restyling wrote `<LoaderCircleIcon spin>` in five spots, but
raw lucide-react icons have no `spin` prop — the attribute fell through
to the SVG (React DOM warning) and the spinner never animated. The two
spots inside ImagePromptRow were corrected during the services/main
merge resolution; this covers the remaining three files with the same
animate-spin class main uses. Also drops the unused DynamicIcon import
in create-video (pre-existing on both lineages).

`tsc --noEmit -p apps/artcraft-webapp/tsconfig.app.json` is now clean.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 12:27:38 -07:00
Tanish Manku 46c0bcb019 Merge services/main: catch the design lineage up to the split repo
The design branch forked from the old monorepo main at f3442f3146;
main then advanced through #1910 (last-viewed badge), #1911 (iOS
upload fix — uploading queue stores previewUrl, not File), #1912,
#1699 (keybinds), the auth-page video update (976f2ae757, Vimeo
iframe → muted <video>), and the modal freeze fix (ed57c6de47,
auto-merged) before the artcraft/artcraft-services split.

Seven files conflicted where main's functional work crossed the
branch's #1913/#1916 webapp restyling. Resolution rule: main's
functionality, the design branch's styling —

- auth-showcase: main's <video> element, squared corners kept
- ImagePromptRow (webapp + libs): main's previewUrl refactor; kept
  square corners/hairline borders. The branch's `spin` prop was a
  latent bug (raw lucide icons have no such prop — no animation and
  a React DOM warning), so main's animate-spin class wins.
- GalleryCard/GalleryRow: main's lastViewed ring + badge woven into
  the branch's squared-corner classes (badge pill squared to match)
- old-site app.tsx: union of both new routes (landingp0 + creator)
- TimelineClipRow: main's enableClipTransition undo-step flow
  superseded the branch's setClipTransitionEasing(DEFAULT_EASING)

artcraft-website-next is untouched by the incoming side. Webapp
typechecks clean after the follow-up spinner fixes.

Claude-Session: https://claude.ai/code/session_01KpbGFYxEXuaYU9J75vTK9j
2026-09-11 12:27:13 -07:00
Tanish Manku 57763f2823 deploy: netlify config, legacy-route redirects, pinned project root
Proofing-pass deployment plumbing. The app referenced seven marketing
routes it doesn't serve yet (/download, /pricing, /tutorials, /news,
/faq, /press-kit, /support) — every one now 307s to the shipping Vite
site, which this app already depends on staying live (MEDIA_BASE
feature footage). Drop a route from LEGACY_ROUTES as its page migrates.

netlify.toml follows the sibling apps' discovery pattern with base
frontend/apps/artcraft-website-next (standalone lockfile, not the Nx
workspace) and the Next runtime plugin, which also carries the
/cdn-media and /ext-media proxy rewrites into production.

turbopack.root and outputFileTracingRoot are pinned to the app dir —
Next was guessing the Nx workspace's lockfile as the project root,
which breaks output file tracing on serverless deploys.

Claude-Session: https://claude.ai/code/session_01YBoaBEt3gynzTwogBFtn4B
2026-09-11 12:07:59 -07:00
Brandon Thomas daa9c07650 Merge pull request #4 from storytold/bt-netlify-reconfigure
reconfigure netlify builds
2026-09-11 11:33:57 -04:00
Brandon Thomas 5427b2f03e reconfigure netlify builds 2026-09-11 11:33:35 -04:00
Brandon Thomas f44d79c200 Merge pull request #3 from storytold/bt-rename-image
rename image
2026-09-11 09:53:57 -04:00
Brandon Thomas 1cd3974621 rename image 2026-09-11 09:53:24 -04:00
Brandon Thomas b4dc3f3232 Merge pull request #2 from storytold/enable-build-on-action
enable workflow dispatch from the UI
2026-09-11 09:27:44 -04:00
Brandon Thomas a1637c9e32 enable workflow dispatch from the UI 2026-09-11 09:26:55 -04:00
Brandon Thomas c2cf889bd2 Merge pull request #1 from storytold/remove-artcraft-desktop-workflows
remove artcraft desktop workflows
2026-09-11 09:16:44 -04:00
Tanish Manku d4becf015d Redesign: Implement a spiral hero graphic component (#1943)
Note: It's really awesome
2026-09-11 00:31:20 -07:00
BFlatandTanish Manku 5fa95ccb06 Idea ruler (#1939)
Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
2026-09-10 23:12:08 +07:00
BFlat 60bcfffc8d Webapp stylings (#1916) 2026-09-01 10:25:21 +07:00
BFlat d2ad5820e0 Core UI kit for artcraft-website-next (#1914) 2026-08-21 13:05:39 +07:00
BFlat 14fde3bf30 Core UI styling - brutalist system (#1913)
- Square all core primitives (button, modal, input, select, checkbox,
switch, badge, tooltip, popover, tab-selector, close-button): zero
radius, white/15 hairline borders, flat shadows, mono uppercase labels
- Primary actions now solid white/black invert (navbar CTA style); white
accent replaces blue for selection/focus states
- Fonts: Bricolage Grotesque headings, Inter body, Geist Mono UI labels
- Retoken website ui-* surfaces to #0b0b0c + white/15 hairlines
- Remove 419 rounded-* overrides across website + shared modal libs
(archived landing pages and circular spinners kept as-is)
2026-08-20 13:46:53 +07:00
Tanish Manku 6fdc833d3e website: live dev tuner + noisy settled-pile wordmark formation
Tuner (see TUNER.md): a draggable, collapsible dev panel rendering every
group registered via defineTunables() as slider + number-input rows.
- Live: physics/light/material/halo values are read per frame; formation
  values trigger a debounced re-sample
- Values persist to localStorage; modified labels tint accent; Copy puts a
  JSON snapshot on the clipboard for baking back into defaults; Reset clears
- Shown in dev builds or with ?tuner=1; drag position and section collapse
  state persist
- Policy (documented): every new tweakable constant registers with the tuner
  by default
- Hero groups: Formation, Physics, Cursor & Light, Material, Halo

Formation: the hex lattice read too uniform. Balls are now placed by
multi-pass dart throwing over the glyph mask — big balls claim spots first,
smaller ones settle into leftover gaps, random depth within the slab, 3D
overlap rejection via spatial hash — a faked settled-pile impression, not a
physics solve. Stroke-margin test keeps big balls inside the letterforms so
the type stays crisp.
2026-08-19 23:17:50 -07:00
Tanish Manku 93232c4d73 website: hero wordmark lighting — organic sizes, cursor halo, eclipse dark mode
- Ball sizes now vary organically: smooth spatial value noise drifts size
  clusters across the word, white noise roughens per ball; the neighbor cap
  keeps every draw collision-safe
- Radial halo billboard tracks the cursor BEHIND the text plane, so balls
  silhouette against the glow (additive in dark, soft accent wash in light)
- Cursor point light switched to physical inverse-square falloff (decay 2):
  balls near the cursor are genuinely brighter; the light dims to 30% when
  the pointer leaves so the resting state stays moody
- Dark mode is an eclipse rig: near-black lacquer balls (clearcoat physical
  material), almost no front light, cool key + counter rim from behind, and
  no environment map — scene.environment floods instanced materials at full
  strength regardless of envMapIntensity, which was washing the whole word
  to gray; light mode keeps the procedural room reflections
2026-08-17 15:01:06 -07:00
Tanish Manku 9e0211462b website: ball-field hero wordmark — 'artcraft' as pushable 3D spheres
The hero brand mark is now the signature interactive moment: 'artcraft'
rasterized per-glyph (manual advances so no ligature shaping), sampled onto a
jittered close-packed lattice 2-3 layers deep, and rendered as instanced 3D
spheres under a perspective camera fitted so world units equal CSS pixels at
the text plane.

- Cursor is both a point light and a push force (radial + swirl, quadratic
  falloff, toward-viewer pop); balls scatter, then rejoin on home springs
- Per-ball radii capped at half the nearest-neighbor gap, plus a 3D
  spatial-hash collision pass, so spheres never clip through each other
- Mount plays an assemble: balls fly in from a scattered shell
- Idle: light wanders a slow Lissajous path; balls breathe subtly
- Fallbacks: static display-type wordmark for SSR/crawlers, reduced motion,
  and WebGL failure; theme-aware colors via shared token watcher (extracted
  to lib/theme-colors, now also used by the blocking/render comparator)
- Tagline 'Controllable AI for artists.' remains the crawlable h1
2026-08-17 14:44:20 -07:00
Tanish Manku dbac21c44c website: Next.js premium marketing app — landing v1 (SSR, light/dark, 3D hero)
Standalone Next 16 app at frontend/apps/artcraft-website-next (excluded from
the npm workspace like artcraft-dashboard; React 19 + R3F 9 + GSAP + Lenis +
Tailwind 4). Branched from design/prototype0; the Marathon-style navbar is
ported to next/link with theme-aware tokens plus a light/dark toggle.

- Design system: hairline-grid 'instrument panel' language; light (blueprint
  paper) + dark (viewport terminal) themes via CSS tokens, system-pref default
  with persisted override applied pre-paint
- Landing: hero, capability ticker, feature grid (real product footage),
  ownership, made-with (click-to-load YouTube), CTA, footer — all crawlable
  SSR HTML; GSAP reveals are progressive enhancement only
- 3D hero: interactive blocking↔render comparator — wireframe R3F set clipped
  over the product reel by a cursor-driven scan line; degrades to plain video
  on reduced-motion/coarse-pointer/WebGL failure
- SEO: metadata + OG, sitemap.ts, robots.ts, SoftwareApplication JSON-LD
2026-08-17 14:07:12 -07:00
bflatastic 9444d347af navbar and page for landing prototype 0 2026-08-13 20:37:16 +07:00
275 changed files with 14765 additions and 2308 deletions

No files matched your search

+3 -3
View File
@@ -4,6 +4,7 @@
# https://github.com/orgs/storytold/packages/container/docker-base-images-rust-ssl/settings/actions_access
name: "[CPU] Build GHCR Docker artifact"
on:
workflow_dispatch:
push:
branches:
# NB: Default-branch doesn't work, despite Github's documentation.
@@ -25,7 +26,7 @@ on:
- 'includes/**'
- 'test_data/**'
env:
IMAGE_NAME: storyteller-rust
IMAGE_NAME: artcraft-services
jobs:
# Push image to GitHub Packages.
@@ -37,7 +38,6 @@ jobs:
#runs-on: ubuntu-22.04 # runs-on: ubuntu-latest
#runs-on: github-ubuntu-16core # NB: Premium GitHub runner with more cores
runs-on: github-ubuntu-64core # NB: Premium GitHub runner with more cores
if: github.event_name == 'push'
permissions:
packages: write
contents: read
@@ -82,7 +82,7 @@ jobs:
name: Compute Docker tags
uses: docker/metadata-action@v5
with:
images: ghcr.io/storytold/storyteller-rust
images: ghcr.io/storytold/artcraft-services
tags: |
type=raw,value=latest
type=raw,value=${{ github.sha }}
+196 -64
View File
@@ -1,82 +1,214 @@
<p align="center">
<video src="https://github.com/user-attachments/assets/b4e24c27-d87d-4fd1-8599-dc0d0b8af48d" width="100%" autoplay="true" loop controls>
</p>
# ArtCraft Services
<p align="center">The IDE for artists.</p>
<p align="center">
<a href="https://discord.gg/artcraft"><img alt="Discord" src="https://img.shields.io/discord/1359579021108842617?style=for-the-badge&label=discord&color=ffffff&logo=discord&logoColor=ffffff" /></a>
<a href="https://www.youtube.com/@OfficialArtCraftStudios"><img alt="YouTube" src="https://img.shields.io/youtube/channel/subscribers/UCdjY4VG0ntoGwFsKZO4sVWA?style=for-the-badge&logo=YouTube" /></a>
<a href="https://x.com/intent/follow?screen_name=get_artcraft"><img alt="X" src="https://img.shields.io/twitter/follow/get_artcraft?style=for-the-badge&label=follow&logo=x&logoColor=ffffff&color=ffffff" /></a>
<a href="https://www.linkedin.com/company/artcraft-ai"><img alt="LinkedIn" src="https://img.shields.io/badge/linkedin--0A66C2?style=for-the-badge"></a>
</p>
This repository contains ArtCraft's backend, HTTP API, background workers, and web
frontends. It is a Rust and TypeScript monorepo with shared API definitions,
provider clients, database queries, and development tooling. The checkout also
includes the Tauri desktop application and its shared frontend libraries.
---
For the product overview, feature demos, and desktop downloads, see
[storytold/artcraft](https://github.com/storytold/artcraft).
ArtCraft
========
ArtCraft is the IDE for interactive AI image and video creation.
We turn prompting into *crafting*, so your ideas become a form of tangible expression and computing.
This is Adobe Photoshop for everyone, and we're giving away the source code!
## Backend architecture
## Show, Don't Tell: Advanced Crafting Features
[`storyteller-web`](./crates/service/web/storyteller_web) is the main HTTP service,
built with Rust, Actix Web, and Tokio. It handles authentication, accounts,
media uploads and libraries, generation requests, job status, credits, and Stripe
billing. The service retains the `storyteller-web` name, and its hosted API uses
`https://api.storyteller.ai`.
Text-to-image is great, but artists *need control*. It's important to know what your image will look like before you generate it, and it's vitally important to achieve consistency and repeatability.
Generation spans the HTTP service, provider integrations, and asynchronous workers:
| Feature | Demo + Explanation
|-----------------------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Image to Location** | ![Video](https://github.com/user-attachments/assets/21f103e3-cc19-4882-a630-9caa1b76ae31) Placing virtual actors into physical environments establishes single-location consistency. You can film multiple shots within a room without having things disappear. |
| **3D Image Compositing** | ![Video](https://github.com/user-attachments/assets/f93a616f-571d-474e-bcc0-53736de7303d) Use images (backdrops, foreground elements, props, etc.) in scenes with depth and blend them naturally together. Just a couple of images usually leads to great compositions. |
| **2D Image Compositing** | ![Video](https://github.com/user-attachments/assets/d6f99391-e496-4c62-9e37-29734ba5f899) Use images, background removal, layers, and simple drawing tools to precisely compose a scene. |
| **Image to 3D Mesh** | ![Video](https://github.com/user-attachments/assets/600a405c-e360-48c1-9b42-6e657ae6243b) It's almost impossible to lay out complicated objects or block complicated scenes; turning images into 3D helps position elements exactingly and intentionally. |
| **Character Posing** | ![Video](https://raw.githubusercontent.com/storytold/github-media/22a27373707b13f48fc56405f1f89ca143c43d5d/character-posing.webp) You can dynamically pose your characters to achieve the precise character, scene, and camera blocking before calling "action". |
| **Scene Blocking w/ Kit Bashing** | ![Video](https://github.com/user-attachments/assets/eef025ac-0346-4a46-a023-d48e23629eb5) Use 3D asset kits to precisely block out your scene: get the correct angles, object positions, and rich depth layering you can't with text prompting. |
| **Character Identity Transfer** | ![Video](https://github.com/user-attachments/assets/629119ee-8c76-4a83-9827-8c6c995a3ec1) Use mannequins as simple 3D ControlNets for posing any character. |
| **Background Removal** | ![Video](https://github.com/user-attachments/assets/90c65057-5531-404f-83af-b34e66e24ec1) Remove backgrounds from images to make them useful in 2D or 3D compositing. They can be props, layers, or backdrops. |
| **Mixed Asset Crafting** | ![Video](https://raw.githubusercontent.com/storytold/github-media/main/ship-editing.gif) You can use image cutouts, worlds, and simple 3D meshes all together to precisely and intentionally lay out your scenes. |
| **Scene Blocking** | (preview coming soon) |
| **Canvas Editing** | (preview coming soon) |
| **Scene Relighting** | (preview coming soon) |
```mermaid
flowchart LR
Clients[Web, desktop, and API clients] --> API[storyteller-web]
API --> Router[artcraft_router]
Router --> Providers[Generation providers]
Providers -->|Webhooks| API
Workers[Background workers] -->|Poll jobs| Providers
API --> DB[(MySQL)]
Workers --> DB
API --> Storage[(Object storage)]
Workers --> Storage
```
Note: all of the above videos were generated for free with Grok Video; the cost to build this README was negligible.
A typical generation request follows this path:
## Quick and Easy Prompting
We haven't abandoned text-to-asset generation for quick prototyping and ideation. We support every popular workflow in a first class fashion.
1. The API authenticates the caller, validates the request and input media, and
checks the user's access and credits.
2. The generation pipeline calculates the cost, bills the wallet, and uses
[`artcraft_router`](./crates/api_clients/artcraft/artcraft_router) to build and
submit a provider-specific request. The API records inference jobs in MySQL
and returns job tokens to the client.
3. Completion is handled through provider webhooks or polling workers, depending
on the integration. Results are downloaded into object storage, registered as
media files, and associated with the completed jobs. Separate workers handle
follow-up processing such as video thumbnails.
4. Clients poll job-status endpoints and load the resulting media through CDN URLs.
| Feature | Demo + Explanation
|-----------------------|-------------------------------------------------------------------------------------------------------------------------------------------------------|
| **Text to Image** | ![Video](https://github.com/user-attachments/assets/9cc289cd-faf4-4eaf-aed2-21134cce127c) Text prompt over a dozen different image models. |
| **Image Editing** | ![Video](https://github.com/user-attachments/assets/a06fa6ad-936c-42d0-8767-48fdbb8ff141) Edit with Nano Banana Pro and GPT Image 1.5. |
| **Image Editing** | ![Video](https://github.com/user-attachments/assets/f036e08a-f3a6-417a-98ee-ec7f04b2b5ff) Use inpainting, drawing, masking, etc. to edit images. |
| **Image to Video** | ![Video](https://github.com/user-attachments/assets/2bc6c592-511e-4fba-b40f-03c96699b7f7) Image to video with lots of different options and controls. |
| **Image Inpainting** | (preview coming soon) |
| **Image Ingredients** | (preview coming soon) |
Storage responsibilities are split across these components:
## Models and Providers Supported within Artcraft
| Component | Role |
|----------------------------|--------------------------------------------------------------|
| MySQL + SQLx | Accounts, media metadata, inference jobs, wallets, and bills |
| Redis | Caching, rate limiting, and job progress |
| Elasticsearch | Search indexes and queries |
| S3-compatible storage / R2 | Uploaded media, generated assets, and derived files |
| SQLite + SQLx | Local desktop task persistence |
| Provider | Features |
|------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------|
| Artcraft | Nano Banana, Nano Banana Pro, GPT-Image-1 / 1.5, Seedance 1.0 Lite / 1.5 Pro / 2.0, Seedream 4 / 4.5, Flux 1.1 / Kontext, Veo 2 / 3 / 3.1, Kling 1.6 / 2.1 / 2.5 / 2.6, Sora 2 / Pro, Hunyuan 3d 2 / 3 |
| Grok | Grok Imagine, Grok Video |
| Midjourney | Image Gen (all versions) |
| Sora | Sora 1, Sora 2, GPT-Image-1 |
| WorldLabs | Marble (Gaussian Splat World Generation) |
HTTP routes and handlers live in
[`storyteller_web/src/http_server`](./crates/service/web/storyteller_web/src/http_server).
MySQL queries belong in the shared
[`mysql_queries`](./crates/schema/database/mysql_queries) crate so that handlers,
workers, and CLI tools use the same data access layer. Reusable billing components
live under [`crates/service/plugins`](./crates/service/plugins), and background
services live under [`crates/service/job`](./crates/service/job).
We're going to be adding the following providers soon: Kling (via Kling website accounts), Google (via API keys),
Runway (via website account), Luma (via website account).
## HTTP API
We're potentially interested in adding other aggregators for those who already have subscriptions and credits at
those providers, for example: OpenArt, FreePik, etc.
The API exposes two generation interfaces:
## Downloads
- **Application endpoints:** `/v1/omni_gen/generate/*` uses user sessions for image,
video, audio, mesh, and splat generation. `/v1/omni_gen/models/*` and
`/v1/omni_gen/cost/*` expose model discovery and cost estimation without requiring
a user session. Application job status is available under `/v1/jobs`.
- **Programmatic endpoints:** `/v1/omni_api` uses an API key in the `Authorization`
header. It provides image and video generation, image/video/audio uploads, and
job-status polling. Use `Authorization: Bearer <api-key>`; API access must be
enabled for the account.
- [Visit our website for the stable Windows and MacOS releases](https://getartcraft.com/)
- Or you can grab a [more recent Windows and MacOS build directly](https://github.com/storytold/artcraft/releases)
- Linux requires building from source for now
For example, a programmatic video request goes to
`POST /v1/omni_api/generate/video`. The response contains an
`inference_job_token`, which the caller polls with
`GET /v1/omni_api/job_status/job/{token}`. The
[Omni API guide](./_docs/omni_api/artcraft_omni_api.md) covers authentication,
request and response bodies, URL inputs, and runnable examples.
## Documentation
Start with these sources when adding or tracing an endpoint:
- [developer documentation](./_docs)
- [tools, scripts, misc](./script)
- [license](./LICENSE.md)
- [roadmap](./ROADMAP.md)
- [Route registration](./crates/service/web/storyteller_web/src/http_server/routes/application_routes)
maps URLs and HTTP methods to handlers for generation, media, users, billing,
API keys, and other service areas.
- [API definitions](./crates/api_clients/artcraft/artcraft_api_defs) contain shared
Rust request, response, and error types.
- [Rust API client](./crates/api_clients/artcraft/artcraft_client) and
[TypeScript API library](./frontend/libs/api) provide client implementations.
- [Provider clients](./crates/api_clients) implement upstream HTTP integrations;
[the generation router](./crates/api_clients/artcraft/artcraft_router) adapts
generation requests and cost estimates across providers.
## Frontend
[`frontend`](./frontend) contains the Nx workspace for React and TypeScript apps
and shared libraries. The main web and desktop frontends use Vite, with Zustand
and signals for state, Three.js for 3D scenes, and shared UI and generation tools.
| Path | Purpose |
|----------------------------------|-----------------------------------------------|
| `frontend/apps/artcraft-webapp` | Browser application at `app.getartcraft.com` |
| `frontend/apps/artcraft-website` | Product website at `getartcraft.com` |
| `frontend/apps/artcraft` | Frontend for the Tauri desktop application |
| `frontend/libs/api` | HTTP clients, API host selection, and models |
| `frontend/libs/omni-gen` | Shared generation logic |
| `frontend/libs/components` | Reusable UI, editors, and generation controls |
| `frontend/libs/tauri-api` | Frontend bindings for native desktop commands |
| `frontend/libs/tauri-events` | Desktop event integration |
The web apps call the backend through the shared API library, which handles JSON
and multipart requests and session credentials. The desktop frontend also invokes
Rust commands and receives events through Tauri; the native application lives in
[`crates/desktop/artcraft`](./crates/desktop/artcraft).
## Repository layout
```text
artcraft-services/
├── crates/
│ ├── service/web/ # HTTP services, including storyteller_web
│ ├── service/job/ # Provider workers, media processing, analytics
│ ├── service/plugins/ # Shared billing and service components
│ ├── api_clients/ # ArtCraft API types, clients, router, provider clients
│ ├── schema/ # Database access, public tokens/enums, bucket paths
│ ├── lib/ # Shared Rust utilities
│ ├── cli/ # Development and operations tools
│ └── desktop/artcraft/ # Tauri desktop application
├── frontend/
│ ├── apps/ # Web and desktop frontends
│ └── libs/ # Shared TypeScript libraries
├── _database/ # SQL migrations, materialized schemas, search schemas
├── _docs/ # Setup guides and technical documentation
├── _tools/postman/ # HTTP request collections
├── build/ # Service Dockerfiles
├── script/ # Development, build, and database tooling
└── Cargo.toml # Rust workspace
```
## Local development
Use Rust/Cargo for backend work and Node.js/npm for the main frontend workspace.
See the [development setup guide](./_docs/dev_setup.md) for toolchain setup and
[frontend README](./frontend/README.md) for dependency installation and Nx usage.
### Backend
The server needs a migrated MySQL database, Redis, Elasticsearch configuration,
object storage, and credentials for the integrations being exercised. The
[server setup guide](./_docs/dev_setup_server.md) covers local MySQL and Redis;
the remaining configuration is defined in the
[server config directory](./crates/service/web/storyteller_web/config) and
[startup code](./crates/service/web/storyteller_web/src/startup).
In development, the server loads `storyteller-web.common.env`,
`storyteller-web.development.env`, and `storyteller-web.development-secrets.env`
from its configuration search paths: the repository root, `./config`, and the
server's config directory. Its bootstrap skips the root `.env` file.
With the toolchain and service configuration in place, run from the repository root:
```bash
SQLX_OFFLINE=true cargo check -p storyteller-web
SQLX_OFFLINE=true cargo run -p storyteller-web
```
The default bind address is `0.0.0.0:12345`, configurable through `BIND_ADDRESS`.
`GET /_status` exposes the service health check. Provider polling and thumbnail
processing require their corresponding worker processes and configuration.
`SQLX_OFFLINE=true` uses the checked-in `.sqlx` query metadata during compilation;
the running server still needs its databases. When changing SQLx queries, use
[`script/rust/sqlx_codegen_database.sh`](./script/rust/sqlx_codegen_database.sh)
to regenerate metadata against migrated development databases.
### Web frontend
To run the browser app against a local backend:
```bash
cd frontend
npm install
VITE_USE_LOCAL_API=true npx nx dev artcraft-webapp
```
The browser app runs at `http://localhost:4201`. `VITE_USE_LOCAL_API=true` selects
`http://localhost:12345`; without that override, its Vite development proxy targets
the hosted API. API host selection lives in
[`StorytellerApiHostStore`](./frontend/libs/api/src/lib/config/StorytellerApiHostStore.ts).
From `frontend`, build the web app or run the product website with:
```bash
npx nx build artcraft-webapp
npx nx dev artcraft-website
```
The website runs at `http://localhost:4200`. Repository-root launchers are in
[`script/website`](./script/website), and desktop launchers are in
[`script/artcraft`](./script/artcraft).
## Further reading
- [Technical documentation](./_docs)
- [Code conventions](./_docs/code_conventions.md)
- [Database schemas and migrations](./_database)
- [Postman collections](./_tools/postman)
- [Build and development scripts](./script)
- [License](./LICENSE.md)
@@ -1,8 +1,8 @@
import * as React from "react"
import { CheckIcon, MinusIcon } from "lucide-react"
import { Checkbox as CheckboxPrimitive } from "radix-ui"
import * as React from "react";
import { CheckIcon, MinusIcon } from "lucide-react";
import { Checkbox as CheckboxPrimitive } from "radix-ui";
import { cn } from "@/lib/utils"
import { cn } from "@/lib/utils";
function Checkbox({
className,
@@ -14,8 +14,8 @@ function Checkbox({
data-slot="checkbox"
checked={checked}
className={cn(
"peer size-4 shrink-0 rounded-[4px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary",
className
"peer size-4 shrink-0 rounded-[3px] border border-input shadow-xs transition-shadow outline-none focus-visible:border-ring focus-visible:ring-[3px] focus-visible:ring-ring/50 disabled:cursor-not-allowed disabled:opacity-50 aria-invalid:border-destructive aria-invalid:ring-destructive/20 data-[state=checked]:border-primary data-[state=checked]:bg-primary data-[state=checked]:text-primary-foreground data-[state=indeterminate]:border-primary data-[state=indeterminate]:bg-primary data-[state=indeterminate]:text-primary-foreground dark:bg-input/30 dark:aria-invalid:ring-destructive/40 dark:data-[state=checked]:bg-primary dark:data-[state=indeterminate]:bg-primary",
className,
)}
{...props}
>
@@ -30,7 +30,7 @@ function Checkbox({
)}
</CheckboxPrimitive.Indicator>
</CheckboxPrimitive.Root>
)
);
}
export { Checkbox }
export { Checkbox };
@@ -59,7 +59,9 @@ export function UserSignups() {
if (cancelledRef.current) return;
if (resp.success && resp.data) {
setUsers((prev) => (append ? [...prev, ...resp.data!.users] : resp.data!.users));
setUsers((prev) =>
append ? [...prev, ...resp.data!.users] : resp.data!.users,
);
setNextCursor(resp.data.next_cursor);
} else {
setError(resp.errorMessage || "Failed to load users");
@@ -169,7 +171,9 @@ export function UserSignups() {
onClick={handleRefresh}
disabled={isLoading}
>
<IconRefresh className={`size-4 ${isLoading ? "animate-spin" : ""}`} />
<IconRefresh
className={`size-4 ${isLoading ? "animate-spin" : ""}`}
/>
Refresh
</Button>
</div>
@@ -188,7 +192,8 @@ export function UserSignups() {
All Users
{!isLoading && (
<span className="text-sm font-normal text-muted-foreground ml-1">
({users.length}{nextCursor != null ? "+" : ""})
({users.length}
{nextCursor != null ? "+" : ""})
</span>
)}
</h3>
@@ -234,15 +239,33 @@ export function UserSignups() {
<TableBody>
{Array.from({ length: 10 }).map((_, i) => (
<TableRow key={i}>
<TableCell><Skeleton className="size-4 rounded-[4px]" /></TableCell>
<TableCell><Skeleton className="h-4 w-24" /></TableCell>
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
<TableCell><Skeleton className="h-4 w-36" /></TableCell>
<TableCell><Skeleton className="h-4 w-28" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-4 w-16" /></TableCell>
<TableCell><Skeleton className="h-5 w-20 rounded-full" /></TableCell>
<TableCell><Skeleton className="h-6 w-20 rounded" /></TableCell>
<TableCell>
<Skeleton className="size-4 rounded-[3px]" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-24" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-36" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-28" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-4 w-16" />
</TableCell>
<TableCell>
<Skeleton className="h-5 w-20 rounded-full" />
</TableCell>
<TableCell>
<Skeleton className="h-6 w-20 rounded" />
</TableCell>
</TableRow>
))}
</TableBody>
@@ -305,12 +328,21 @@ export function UserSignups() {
</Link>
</TableCell>
<TableCell className="text-sm text-muted-foreground">
{user.display_name || <span className="text-muted-foreground/30">&mdash;</span>}
{user.display_name || (
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
</TableCell>
<TableCell className="text-sm">
<span className="text-muted-foreground">{user.email_address}</span>
<span className="text-muted-foreground">
{user.email_address}
</span>
{!user.email_confirmed && (
<Badge variant="outline" className="ml-2 text-[10px] text-amber-400 border-amber-400/30">
<Badge
variant="outline"
className="ml-2 text-[10px] text-amber-400 border-amber-400/30"
>
Unconfirmed
</Badge>
)}
@@ -330,40 +362,65 @@ export function UserSignups() {
{user.maybe_source}
</Badge>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
</TableCell>
<TableCell className="text-xs">
{user.maybe_signup_method ? (
<Badge variant="outline" className="text-[10px] text-muted-foreground">
<Badge
variant="outline"
className="text-[10px] text-muted-foreground"
>
{user.maybe_signup_method}
</Badge>
) : (
<span className="text-muted-foreground/30">&mdash;</span>
<span className="text-muted-foreground/30">
&mdash;
</span>
)}
</TableCell>
<TableCell className="text-xs">
<div className="flex items-center gap-1.5">
{user.is_temporary && (
<Badge variant="secondary" className="text-[10px] bg-amber-500/10 text-amber-400 border-transparent">
<Badge
variant="secondary"
className="text-[10px] bg-amber-500/10 text-amber-400 border-transparent"
>
Temp
</Badge>
)}
{user.is_without_password && (
<Badge variant="secondary" className="text-[10px] bg-orange-500/10 text-orange-400 border-transparent">
<Badge
variant="secondary"
className="text-[10px] bg-orange-500/10 text-orange-400 border-transparent"
>
No Password
</Badge>
)}
{!user.is_temporary && !user.is_without_password && (
<Badge variant="secondary" className="text-[10px] bg-emerald-500/10 text-emerald-400 border-transparent">
<Badge
variant="secondary"
className="text-[10px] bg-emerald-500/10 text-emerald-400 border-transparent"
>
Active
</Badge>
)}
</div>
</TableCell>
<TableCell className="text-xs">
<Button variant="ghost" size="sm" asChild className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground">
<a href={`/user/profile/${user.username}`} target="_blank" rel="noopener noreferrer">
<Button
variant="ghost"
size="sm"
asChild
className="h-7 px-2 text-xs text-muted-foreground hover:text-foreground"
>
<a
href={`/user/profile/${user.username}`}
target="_blank"
rel="noopener noreferrer"
>
<IconExternalLink className="size-3.5" />
View Profile
</a>
@@ -380,7 +437,9 @@ export function UserSignups() {
Loading more...
</div>
) : (
<span className="text-muted-foreground/50">Scroll for more</span>
<span className="text-muted-foreground/50">
Scroll for more
</span>
)}
</TableCell>
</TableRow>
+1 -1
View File
@@ -65,7 +65,7 @@
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=Outfit:wght@100..900&family=Source+Sans+3:ital,wght@0,200..900;1,200..900&family=Instrument+Serif:ital@0;1&display=swap"
href="https://fonts.googleapis.com/css2?family=Archivo:wdth,wght@62..125,100..900&family=Instrument+Serif:ital@0;1&family=Geist+Mono:wght@300..800&display=swap"
rel="stylesheet"
/>
</head>
@@ -1,3 +1,9 @@
# Netlify settings used to discover this file:
# Base directory: frontend
# Package directory: apps/artcraft-webapp (relative to the base directory)
# Manage the build command and publish directory here; leave those UI fields empty.
# Paths below are relative to the frontend workspace.
[build]
command = "bash ./apps/artcraft-webapp/script/netlify_build.sh"
publish = "apps/artcraft-webapp/dist/"
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 KiB

@@ -62,10 +62,10 @@ function ScrollToTop() {
function AuthCheckSpinner() {
return (
<div className="flex h-screen items-center justify-center bg-[#101014]">
<div className="flex h-screen items-center justify-center bg-ui-background">
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
className="animate-spin text-4xl text-white/70" />
</div>
);
}
@@ -115,7 +115,7 @@ function ProtectedContent() {
style={{ "--ac-sidebar-offset": sidebarOffset } as React.CSSProperties}
>
{!hideTopBar && <TopBar />}
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-[#121212]">
<SidebarInset className="flex-1 min-h-0 overflow-y-auto bg-ui-background">
<PageTransition>
<Outlet />
</PageTransition>
@@ -60,15 +60,15 @@ export function GoogleLoginButton({
className={`group relative w-full h-10 transition-transform duration-150 active:scale-95 ${className}`}
>
{/* Custom visual button - shown behind */}
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 bg-white text-black rounded-full font-medium border border-gray-200 group-hover:border-white/80 group-hover:bg-white/80 transition-colors">
<div className="absolute inset-0 w-full h-full flex items-center justify-center gap-3 rounded-[3px] bg-white text-black font-medium group-hover:bg-white/80 transition-colors">
{isLoading ? (
<LoaderCircleIcon
className="animate-spin text-lg text-gray-600" />
<LoaderCircleIcon className="animate-spin text-lg text-black/60" />
) : (
<>
<GoogleIcon />
<span className="text-sm font-semibold">Continue with Google</span>
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Continue with Google
</span>
</>
)}
</div>
@@ -31,7 +31,7 @@ export const AuthLayout = () => {
}
return (
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-[#101014] p-4 text-white">
<div className="relative flex min-h-screen items-center justify-center overflow-hidden bg-ui-background p-4 text-white">
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
@@ -48,16 +48,7 @@ export const AuthLayout = () => {
/>
</div>
<div
aria-hidden
className="pointer-events-none absolute inset-0 z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden rounded-3xl border border-white/[4%] bg-[#1C1C20] shadow-2xl lg:min-h-[640px]">
<div className="relative z-10 flex w-full max-w-5xl overflow-hidden border border-white/15 bg-[#101014] lg:min-h-[640px]">
{/* Form pane — only the inner content swaps per route (via Outlet), so
this pane and the showcase beside it stay mounted across the
login/signup toggle. */}
@@ -66,7 +57,7 @@ export const AuthLayout = () => {
type="button"
onClick={handleBack}
aria-label="Go back"
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center rounded-full text-white/60 transition-colors hover:bg-white/10 hover:text-white"
className="absolute left-5 top-5 z-20 flex h-9 w-9 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white"
>
<ArrowLeftIcon />
</button>
@@ -3,7 +3,7 @@ const FEATURED_VIDEO_URL =
export const AuthShowcase = () => {
return (
<div className="absolute inset-2 overflow-hidden bg-black rounded-2xl">
<div className="absolute inset-2 overflow-hidden bg-black">
{/* Muted background video (no controls) cropped to cover the pane */}
<video
src={FEATURED_VIDEO_URL}
@@ -23,7 +23,7 @@ export const AuthShowcase = () => {
{/* Caption */}
<div className="pointer-events-none absolute inset-x-0 bottom-0 p-8">
<p className="mb-3 text-xs font-semibold uppercase tracking-widest text-primary-300">
<p className="mb-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
One of the cheapest
</p>
<h2 className="text-2xl font-bold leading-tight">
@@ -107,7 +107,7 @@ export const SignupForm = ({
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
@@ -123,7 +123,7 @@ export const SignupForm = ({
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
Email
</label>
<Input
@@ -132,13 +132,13 @@ export const SignupForm = ({
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
autoFocus={autoFocus}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</Reveal>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
<div className="flex justify-between items-center">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
Password
</label>
</div>
@@ -148,7 +148,7 @@ export const SignupForm = ({
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -163,12 +163,12 @@ export const SignupForm = ({
<Reveal className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Create account"
)}
@@ -190,7 +190,7 @@ export const SignupForm = ({
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
<span className="relative bg-[#101014] px-4 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
or
</span>
</Reveal>
@@ -51,7 +51,7 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-2xl w-full max-w-2xl max-h-[100vh] overflow-y-auto overflow-x-hidden border border-white/5 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
className="w-full max-w-2xl max-h-[100vh] overflow-y-auto overflow-x-hidden border border-white/15 bg-ui-modal p-0"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
@@ -60,16 +60,6 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
backdropClassName="bg-black/80"
>
<div className="relative overflow-hidden">
{/* Off-center ambient glow, feels designed, not generic */}
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-11 sm:pb-9">
<h2 className="font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
Buy <span className="text-primary">credits</span>
@@ -89,23 +79,21 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
onClick={() => handlePurchase(pack)}
disabled={purchasingId !== null}
className={twMerge(
"group relative flex flex-col gap-6 rounded-2xl border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
"group relative flex flex-col gap-6 border p-6 text-left transition-all disabled:cursor-not-allowed disabled:opacity-60",
isPopular
? "border-primary/50 bg-primary/[0.07] hover:border-primary"
: "border-white/10 bg-white/[0.02] hover:border-white/25 hover:bg-white/[0.04]",
? "border-white/60 bg-white/[0.06] hover:border-white"
: "border-white/15 bg-white/[0.02] hover:border-white/30 hover:bg-white/[0.04]",
)}
>
{pack.badge && (
<span className="absolute -top-2.5 right-3 rounded-full bg-primary px-2.5 py-0.5 text-xs font-bold uppercase tracking-wide text-white shadow-lg">
<span className="absolute -top-2.5 right-3 bg-white px-2.5 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-black">
{pack.badge}
</span>
)}
<div className="flex items-center gap-3">
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-primary/15">
<CoinsIcon
className="text-primary text-lg" />
<span className="flex h-12 w-12 shrink-0 items-center justify-center bg-white/10">
<CoinsIcon className="text-white text-lg" />
</span>
<div className="min-w-0">
<div className="text-4xl font-bold leading-none tracking-tight text-white">
@@ -119,17 +107,13 @@ export function CreditsModal({ isOpen, onClose }: CreditsModalProps) {
<span className="text-2xl font-semibold text-white/80">
${pack.priceUsd}
</span>
<span className="flex items-center gap-1.5 text-base font-semibold text-primary-400">
<span className="flex items-center gap-1.5 text-base font-semibold text-white">
{isLoading ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
<>
Buy
<ArrowRightIcon
className="text-xs transition-transform group-hover:translate-x-0.5" />
<ArrowRightIcon className="text-xs transition-transform group-hover:translate-x-0.5" />
</>
)}
</span>
@@ -80,10 +80,10 @@ export function CreateMediaPageShell({
if (!authChecked) {
return (
<div className="flex h-full items-center justify-center bg-[#101014]">
<div className="flex h-full items-center justify-center bg-ui-background">
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
className="animate-spin text-4xl text-white/60" />
</div>
);
}
@@ -99,7 +99,7 @@ export function CreateMediaPageShell({
goToHistory: () => setMobileTab("history"),
}}
>
<div className="flex h-full w-full flex-col bg-[#101014] text-white">
<div className="flex h-full w-full flex-col bg-ui-background text-white">
<Seo title={title} description={description} />
{/* Flex (not absolute) keeps the toggle cluster from overlapping
@@ -150,27 +150,20 @@ export function CreateMediaPageShell({
}
return (
<div className="flex h-full w-full bg-[#101014] text-white">
<div className="flex h-full w-full bg-ui-background text-white">
<Seo title={title} description={description} />
{/* Decorative background, empty state only. Not rendered on mobile
devices: the glow orbs use filter: blur(120px) and the truchet uses
mask-image, both of which are full-screen GPU layers that iOS Safari
must re-rasterize when an overlay (mobile menu, modal) composites over
them, causing multi-second hangs. (This is distinct from
backdrop-filter.) We gate on the actual device (react-device-detect's
user-agent check) rather than a viewport breakpoint so the layers
never mount on a real phone/tablet, and a narrow desktop window still
gets the decoration. */}
devices: the truchet uses mask-image, a full-screen GPU layer that
iOS Safari must re-rasterize when an overlay (mobile menu, modal)
composites over it, causing multi-second hangs. (This is distinct
from backdrop-filter.) We gate on the actual device
(react-device-detect's user-agent check) rather than a viewport
breakpoint so the layers never mount on a real phone/tablet, and a
narrow desktop window still gets the decoration. */}
{!hasContent && !isMobile && (
<>
{glowOrbs ?? (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
<div className="absolute bottom-[-15%] right-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-purple-600 via-blue-500 to-[#00AABA] opacity-[0.08] blur-[120px] transform-gpu" />
<div className="absolute bottom-[20%] left-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
</div>
)}
{glowOrbs}
<div
aria-hidden
className="pointer-events-none fixed inset-0 z-0"
@@ -193,7 +186,7 @@ export function CreateMediaPageShell({
<div className="flex h-full w-full flex-col">
{!hasContent && (
<div className="flex flex-1 items-center justify-center">
<div className="animate-fade-in-up relative z-20 mb-32 flex flex-col items-center justify-center text-center drop-shadow-xl">
<div className="animate-fade-in-up relative z-20 mb-32 flex flex-col items-center justify-center text-center">
<h1 className="text-5xl font-semibold text-white md:text-7xl">
{emptyStateTitle}
</h1>
@@ -219,7 +212,7 @@ export function CreateMediaPageShell({
{hasContent && (
<div
aria-hidden
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-[#101014] to-transparent"
className="pointer-events-none fixed bottom-0 right-0 z-20 h-48 bg-gradient-to-t from-ui-background to-transparent"
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
/>
)}
@@ -83,7 +83,7 @@ export function GallerySelectionDownloadBar({
type="button"
onClick={handleDownload}
disabled={isDownloading || selectedItems.length === 0}
className="flex items-center gap-2 rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-primary disabled:opacity-60"
className="flex items-center gap-2 border border-white/15 bg-ui-controls/60 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white transition-colors hover:bg-ui-controls/90 focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60 disabled:opacity-60"
>
<DynamicIcon
icon={isDownloading ? LoaderCircleIcon : ArrowDownToLineIcon}
@@ -1,4 +1,11 @@
import { ArrowDownToLineIcon, CheckIcon, LinkIcon, LoaderCircleIcon, RotateCwIcon, VideoIcon } from "lucide-react";
import {
ArrowDownToLineIcon,
CheckIcon,
LinkIcon,
LoaderCircleIcon,
RotateCwIcon,
VideoIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Tooltip } from "@storyteller/ui-tooltip";
import {
@@ -89,7 +96,7 @@ function CardRecreateButton({
type="button"
onClick={handleRecreate}
disabled={isRecreating}
className="flex items-center gap-1.5 rounded-md bg-white/5 px-3 py-1.5 text-xs text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
className="flex items-center gap-1.5 border border-white/15 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
>
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
@@ -107,7 +114,7 @@ function CardRecreateButton({
onClick={handleRecreate}
disabled={isRecreating}
aria-label="Recreate"
className="flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
className="flex h-7 w-7 shrink-0 items-center justify-center text-white/70 opacity-0 transition hover:bg-white/15 hover:text-white focus-visible:opacity-100 group-hover:opacity-100 disabled:opacity-60"
>
<DynamicIcon
icon={isRecreating ? LoaderCircleIcon : RotateCwIcon}
@@ -140,7 +147,7 @@ function CardActions({
} = useGalleryItemActions(item, { enableMakeVideo });
const buttonClass =
"flex h-7 w-7 items-center justify-center rounded-md text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
"flex h-7 w-7 items-center justify-center text-white/85 transition-colors hover:bg-white/15 hover:text-white disabled:opacity-60";
return (
<>
@@ -168,7 +175,7 @@ function CardActions({
onClick={handleMakeVideo}
aria-label="Make Video"
>
<VideoIcon className="text-sm" />
<VideoIcon className="text-sm" />
</button>
</Tooltip>
)}
@@ -107,8 +107,8 @@ function RowRecreateButton({
aria-label="Recreate"
className={
kind === "pending"
? "flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
: "flex h-7 w-7 shrink-0 items-center justify-center rounded-md text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
? "flex h-7 w-7 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
: "flex h-7 w-7 shrink-0 items-center justify-center text-white/40 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60"
}
>
<DynamicIcon
@@ -156,7 +156,7 @@ function GalleryRowActions({
} = actions;
const buttonClass =
"flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
"flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white disabled:opacity-60";
return (
<>
@@ -128,7 +128,7 @@ export function InsufficientCreditsModal() {
<Modal
isOpen={isOpen}
onClose={close}
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/5 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
className="w-full max-w-md overflow-hidden border border-white/15 bg-ui-modal p-0"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
@@ -137,16 +137,6 @@ export function InsufficientCreditsModal() {
backdropClassName="bg-black/80"
>
<div className="relative overflow-hidden">
{/* Off-center ambient glow, feels designed, not generic */}
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
{copy.heading}
@@ -161,10 +151,8 @@ export function InsufficientCreditsModal() {
key={perk}
className="flex items-start gap-3 text-[14px] text-white/75"
>
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/15">
<CheckIcon
className="text-[9px] text-primary" />
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center bg-white/10">
<CheckIcon className="text-[9px] text-white" />
</span>
<span>{perk}</span>
</li>
@@ -176,10 +164,7 @@ export function InsufficientCreditsModal() {
<>
{!isOnBiggestPlan && (
<Link to="/pricing" onClick={close} className="block">
<Button
variant="primary"
className="w-full h-10 text-sm font-semibold rounded-full"
>
<Button variant="primary" className="w-full h-10">
Upgrade plan
</Button>
</Link>
@@ -187,17 +172,14 @@ export function InsufficientCreditsModal() {
<Button
variant={isOnBiggestPlan ? "primary" : "secondary"}
onClick={handleBuyCredits}
className="w-full h-10 text-sm font-semibold rounded-full"
className="w-full h-10"
>
Buy more credits
</Button>
</>
) : (
<Link to="/pricing" onClick={close} className="block">
<Button
variant="primary"
className="w-full h-10 text-sm font-semibold rounded-full"
>
<Button variant="primary" className="w-full h-10">
Upgrade plan
</Button>
</Link>
@@ -4,12 +4,26 @@ import { Button } from "@storyteller/ui-button";
import { toast } from "../toast/toast";
import { Gravatar } from "@storyteller/ui-gravatar";
import type { UserInfo } from "@storyteller/api";
import { ArrowDownToLineIcon, CheckIcon, CopyIcon, ImageIcon, ImagesIcon, InfoIcon, LinkIcon, LoaderCircleIcon, PauseIcon, PencilIcon, PlayIcon, RotateCwIcon, Trash2Icon, UserIcon, VideoIcon, XIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import {
getContextImageThumbnail,
THUMBNAIL_SIZES,
} from "@storyteller/common";
ArrowDownToLineIcon,
CheckIcon,
CopyIcon,
ImageIcon,
ImagesIcon,
InfoIcon,
LinkIcon,
LoaderCircleIcon,
PauseIcon,
PencilIcon,
PlayIcon,
RotateCwIcon,
Trash2Icon,
UserIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { getContextImageThumbnail, THUMBNAIL_SIZES } from "@storyteller/common";
import {
getCreatorIconPathForModelId,
getModelDisplayName,
@@ -29,16 +43,12 @@ import {
// ── InfoRow (private to this file; other consumers do not need it) ─────────
function InfoRow({
label,
value,
}: {
label: string;
value: React.ReactNode;
}) {
function InfoRow({ label, value }: { label: string; value: React.ReactNode }) {
return (
<div className="flex items-center justify-between px-4 py-3 border-b border-white/5 last:border-0">
<span className="text-sm text-white/60 font-medium">{label}</span>
<div className="flex items-center justify-between px-4 py-3 border-b border-white/10 last:border-0">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
{label}
</span>
<span className="text-sm text-white font-medium flex items-center gap-2">
{value}
</span>
@@ -84,7 +94,9 @@ export function LightboxDetails({
const promptCopy = useCopyFeedback();
const shareCopy = useCopyFeedback();
const [isDownloading, setIsDownloading] = useState(false);
const [playingAudioToken, setPlayingAudioToken] = useState<string | null>(null);
const [playingAudioToken, setPlayingAudioToken] = useState<string | null>(
null,
);
const audioPlayerRef = useRef<HTMLAudioElement | null>(null);
const stopAudio = useCallback(() => {
@@ -174,14 +186,14 @@ export function LightboxDetails({
!!createdAt;
return (
<div className="relative flex w-full sm:w-[320px] shrink-0 flex-col bg-ui-panel rounded-b-xl sm:rounded-b-none sm:rounded-r-xl min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
<div className="relative flex w-full sm:w-[320px] shrink-0 flex-col bg-ui-panel min-h-0 flex-1 sm:flex-none sm:h-full overflow-hidden">
{onClose && (
<button
onClick={onClose}
className="absolute top-3 right-3 z-20 flex h-8 w-8 items-center justify-center rounded-full bg-black/40 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
className="absolute top-3 right-3 z-20 flex h-8 w-8 items-center justify-center bg-black/40 text-white/70 transition-colors hover:bg-black/60 hover:text-white"
aria-label="Close"
>
<XIcon className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</button>
)}
@@ -189,20 +201,20 @@ export function LightboxDetails({
{promptData.loading ? (
<div className="space-y-6 animate-pulse">
<div className="space-y-2">
<div className="h-4 w-20 bg-white/10 rounded" />
<div className="h-20 w-full bg-white/10 rounded-lg" />
<div className="h-4 w-20 bg-white/10" />
<div className="h-20 w-full bg-white/10" />
</div>
<div className="space-y-2">
<div className="h-4 w-32 bg-white/10 rounded" />
<div className="h-10 w-full bg-white/10 rounded-lg" />
<div className="h-10 w-full bg-white/10 rounded-lg" />
<div className="h-4 w-32 bg-white/10" />
<div className="h-10 w-full bg-white/10" />
<div className="h-10 w-full bg-white/10" />
</div>
</div>
) : (
<>
{creator && (
<div
className={`sticky -top-4 z-10 -mx-4 -mt-4 mb-1 flex items-center gap-3 bg-ui-panel px-4 pt-4 pb-3 border-b border-white/5 ${onClose ? "pr-10" : ""}`}
className={`sticky -top-4 z-10 -mx-4 -mt-4 mb-1 flex items-center gap-3 bg-ui-panel px-4 pt-4 pb-3 border-b border-white/10 ${onClose ? "pr-10" : ""}`}
>
{creator.core_info ? (
<Gravatar
@@ -213,10 +225,10 @@ export function LightboxDetails({
backgroundIndex={
creator.core_info.default_avatar.color_index
}
className="rounded-xl border-white/10"
className="rounded-none border-white/15"
/>
) : (
<div className="h-9 w-9 shrink-0 flex items-center justify-center rounded-xl bg-white/10 text-white/50 border border-white/5">
<div className="h-9 w-9 shrink-0 flex items-center justify-center bg-white/10 text-white/50 border border-white/15">
<UserIcon />
</div>
)}
@@ -224,7 +236,7 @@ export function LightboxDetails({
<span className="text-white text-sm font-semibold leading-none">
{creator.display_name}
</span>
<span className="text-white/60 text-xs font-medium">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
Author
</span>
</div>
@@ -234,7 +246,7 @@ export function LightboxDetails({
{promptData.hasToken && (
<div className="space-y-2">
<div className="flex items-center justify-between">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
<PencilIcon />
<span>Prompt</span>
</div>
@@ -252,7 +264,7 @@ export function LightboxDetails({
)}
</div>
<div className="text-sm text-white/90 break-words px-4 py-3 rounded-xl bg-black/20 leading-relaxed border border-white/5">
<div className="text-sm text-white/90 break-words px-4 py-3 bg-black/20 leading-relaxed border border-white/10">
<div
ref={promptRef}
className={!isPromptExpanded ? "line-clamp-4" : ""}
@@ -274,73 +286,78 @@ export function LightboxDetails({
</div>
)}
{promptData.contextImages && promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<ImageIcon />
<span>Reference Media</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map((contextImage, index) => {
const isAudio = contextImage.semantic === "audioref";
const isVideoRef = contextImage.semantic === "vid_ref";
const { thumbnail } = getContextImageThumbnail(
contextImage,
{ size: THUMBNAIL_SIZES.SMALL },
);
const isPlayingThis = playingAudioToken === contextImage.media_token;
if (isAudio) {
return (
<button
key={contextImage.media_token}
type="button"
onClick={() => handleAudioToggle(contextImage.media_token, contextImage.media_links.cdn_url)}
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors flex items-center justify-center bg-white/5"
>
<DynamicIcon
icon={isPlayingThis ? PauseIcon : PlayIcon}
className="text-white/70 text-lg"
/>
</button>
{promptData.contextImages &&
promptData.contextImages.length > 0 && (
<div className="space-y-2">
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
<ImageIcon />
<span>Reference Media</span>
</div>
<div className="grid grid-cols-5 gap-2">
{promptData.contextImages.map((contextImage, index) => {
const isAudio = contextImage.semantic === "audioref";
const isVideoRef = contextImage.semantic === "vid_ref";
const { thumbnail } = getContextImageThumbnail(
contextImage,
{ size: THUMBNAIL_SIZES.SMALL },
);
}
const isPlayingThis =
playingAudioToken === contextImage.media_token;
return (
<a
key={contextImage.media_token}
href={`${SHARE_URL_BASE}${contextImage.media_token}`}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square overflow-hidden rounded-lg border border-white/10 hover:border-white/40 transition-colors block"
>
<img
src={thumbnail}
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
{isVideoRef && (
<div className="absolute inset-0 flex items-center justify-center">
<VideoIcon
className="text-white/80 text-sm drop-shadow-lg" />
</div>
)}
</a>
);
})}
if (isAudio) {
return (
<button
key={contextImage.media_token}
type="button"
onClick={() =>
handleAudioToggle(
contextImage.media_token,
contextImage.media_links.cdn_url,
)
}
className="relative aspect-square overflow-hidden border border-white/15 hover:border-white/40 transition-colors flex items-center justify-center bg-white/5"
>
<DynamicIcon
icon={isPlayingThis ? PauseIcon : PlayIcon}
className="text-white/70 text-lg"
/>
</button>
);
}
return (
<a
key={contextImage.media_token}
href={`${SHARE_URL_BASE}${contextImage.media_token}`}
target="_blank"
rel="noopener noreferrer"
className="relative aspect-square overflow-hidden border border-white/15 hover:border-white/40 transition-colors block"
>
<img
src={thumbnail}
alt={`Reference ${index + 1}`}
className="h-full w-full object-cover"
/>
{isVideoRef && (
<div className="absolute inset-0 flex items-center justify-center">
<VideoIcon className="text-white/80 text-sm drop-shadow-lg" />
</div>
)}
</a>
);
})}
</div>
</div>
</div>
)}
)}
{hasInfoSection && (
<div className="space-y-2">
<div className="flex items-center gap-2 text-xs font-medium text-white/60">
<div className="flex items-center gap-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
<InfoIcon />
<span>Information</span>
</div>
<div className="flex flex-col rounded-xl bg-black/20 border border-white/5 overflow-hidden">
<div className="flex flex-col bg-black/20 border border-white/10 overflow-hidden">
{promptData.modelType && (
<InfoRow
label="Model"
@@ -421,7 +438,7 @@ export function LightboxDetails({
)}
</div>
<div className="p-4 space-y-2 border-t border-white/5">
<div className="p-4 space-y-2 border-t border-white/10">
<div className="grid grid-cols-2 gap-2">
<Button
className="w-full border border-ui-panel-border bg-ui-controls/40 hover:bg-ui-controls/60 text-white"
@@ -446,7 +463,7 @@ export function LightboxDetails({
setIsDownloading(false);
}
}}
className={`w-full inline-flex items-center justify-center gap-2 rounded-md px-3 py-2 text-sm font-medium transition-colors border border-ui-panel-border ${
className={`w-full inline-flex items-center justify-center gap-2 px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors border border-ui-panel-border ${
mediaUrl
? "bg-ui-controls/40 hover:bg-ui-controls/60 text-white disabled:opacity-60"
: "bg-ui-controls/20 text-white/60 cursor-not-allowed"
@@ -462,7 +479,7 @@ export function LightboxDetails({
{onMakeVideo && (
<Button
icon={VideoIcon}
className="w-full shadow-lg shadow-brand-primary/20"
className="w-full"
variant="primary"
onClick={onMakeVideo}
>
@@ -512,7 +529,7 @@ export function LightboxDetails({
{showDownloadAppCta && (
<Button
icon={ArrowDownToLineIcon}
className="w-full shadow-lg shadow-brand-primary/20"
className="w-full"
variant="primary"
onClick={() => {
window.location.href = "/download";
@@ -357,13 +357,13 @@ export function Lightbox({
<Modal
isOpen={isOpen}
onClose={onClose}
className="rounded-xl h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/5 shadow-2xl"
className="h-[90vh] sm:h-[680px] w-full sm:w-[1100px] max-w-[95vw] max-h-[90vh] p-0 border-white/15"
backdropClassName="!bg-black/80"
showClose={false}
>
<div className="flex flex-col sm:flex-row h-full">
{/* Media preview panel */}
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden rounded-t-xl sm:rounded-l-xl sm:rounded-tr-none bg-black">
<div className="group/nav relative flex h-[45vh] sm:h-full flex-1 items-center justify-center overflow-hidden bg-black">
{!selectedImageUrl ? (
<div className="flex h-full w-full items-center justify-center">
<span className="text-base-fg/60">Media not available</span>
@@ -419,8 +419,8 @@ export function Lightbox({
</video>
) : isAudio ? (
<div className="flex h-full w-full items-center justify-center px-4 sm:px-8">
<div className="w-full max-w-xl rounded-2xl border border-white/10 bg-gradient-to-br from-white/[0.08] via-white/[0.04] to-white/[0.02] px-4 py-10 sm:px-6">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center rounded-full bg-white/10 ring-1 ring-white/15">
<div className="w-full max-w-xl border border-white/15 bg-ui-controls px-4 py-10 sm:px-6">
<div className="mx-auto mb-6 flex h-16 w-16 items-center justify-center border border-white/15 bg-white/10">
<MusicIcon
className="text-2xl text-white/70" />
@@ -485,9 +485,9 @@ export function Lightbox({
key={`${url}-thumb-${idx}`}
type="button"
onClick={() => onThumbClick(idx)}
className={`relative h-16 w-16 flex-[0_0_4rem] overflow-hidden rounded-md border-2 transition-all ${
className={`relative h-16 w-16 flex-[0_0_4rem] overflow-hidden border transition-all ${
idx === selectedIndex
? "border-primary-400 opacity-100"
? "border-white opacity-100"
: "border-transparent opacity-60 hover:border-white/40 hover:opacity-100"
}`}
>
@@ -518,7 +518,7 @@ export function Lightbox({
e.stopPropagation();
onNavigatePrev();
}}
className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
className="absolute left-2 sm:left-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
aria-label="Previous item"
>
<ChevronLeftIcon className="text-lg" />
@@ -530,7 +530,7 @@ export function Lightbox({
e.stopPropagation();
onNavigateNext();
}}
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center rounded-full bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
className="absolute right-2 sm:right-3 top-1/2 -translate-y-1/2 z-30 flex h-9 w-9 sm:h-10 sm:w-10 items-center justify-center bg-black/50 text-white/70 sm:opacity-0 transition-opacity duration-200 hover:bg-black/70 hover:text-white sm:group-hover/nav:opacity-100"
aria-label="Next item"
>
<ChevronRightIcon className="text-lg" />
@@ -1,16 +1,25 @@
import { useState, useRef, useCallback, useEffect } from "react";
import { Modal } from "@storyteller/ui-modal";
import { ArrowLeftIcon, EyeIcon, LoaderCircleIcon, PenIcon, PlusIcon, Trash2Icon, UploadIcon, UsersIcon, XIcon } from "lucide-react";
import { twMerge } from "tailwind-merge";
import {
CharactersApi,
Character,
MediaUploadApi,
} from "@storyteller/api";
ArrowLeftIcon,
EyeIcon,
LoaderCircleIcon,
PenIcon,
PlusIcon,
Trash2Icon,
UploadIcon,
UsersIcon,
XIcon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { CharactersApi, Character, MediaUploadApi } from "@storyteller/api";
import { toast } from "../toast/toast";
import { v4 as uuidv4 } from "uuid";
import { Button } from "@storyteller/ui-button";
import { useCharactersStore, DeckPreviewModal } from "@storyteller/ui-promptbox";
import {
useCharactersStore,
DeckPreviewModal,
} from "@storyteller/ui-promptbox";
import { useIsMobile } from "../ui/use-mobile";
import { SettingsDrawer } from "./mobile/SettingsDrawer";
@@ -324,7 +333,7 @@ const CharacterListView = ({
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
className="flex flex-col overflow-hidden border border-transparent bg-white/[0.05]"
>
<div className="aspect-square w-full overflow-hidden">
<div
@@ -336,7 +345,7 @@ const CharacterListView = ({
</div>
<div className="px-2 py-1.5 flex justify-center bg-white/[0.04]">
<div
className="h-3 w-2/3 rounded bg-white/[0.08]"
className="h-3 w-2/3 bg-white/[0.08]"
style={{
animation: `charPulse 1.8s ease-in-out ${i * 0.07 + 0.1}s infinite`,
}}
@@ -356,10 +365,10 @@ const CharacterListView = ({
{/* Create New card */}
<button
onClick={onCreateClick}
className="flex flex-col items-center justify-center gap-2 overflow-hidden rounded-lg border-2 border-dashed border-white/10 bg-white/[0.05] text-white/60 transition-colors hover:border-white/25 hover:text-white/80"
className="flex flex-col items-center justify-center gap-2 overflow-hidden border-2 border-dashed border-white/15 bg-white/[0.05] text-white/60 transition-colors hover:border-white/30 hover:text-white/80"
>
<div className="flex aspect-square w-full flex-col items-center justify-center gap-2">
<PlusIcon className="text-lg" />
<PlusIcon className="text-lg" />
<span className="text-sm font-medium">Create New</span>
</div>
</button>
@@ -368,7 +377,7 @@ const CharacterListView = ({
{pendingCharacters.map((pending) => (
<div
key={`pending-${pending.name}`}
className="relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05]"
className="relative flex flex-col overflow-hidden border border-transparent bg-white/[0.05]"
>
<div className="aspect-square w-full overflow-hidden bg-white/[0.05]">
{pending.previewUrl ? (
@@ -379,13 +388,11 @@ const CharacterListView = ({
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<UsersIcon className="text-2xl" />
<UsersIcon className="text-2xl" />
</div>
)}
<div className="absolute inset-0 flex flex-col items-center justify-center gap-2 bg-black/40">
<LoaderCircleIcon
className="text-lg text-white/80 animate-spin" />
<LoaderCircleIcon className="text-lg text-white/80 animate-spin" />
<span className="text-xs font-medium text-white/80">
Creating...
</span>
@@ -405,7 +412,7 @@ const CharacterListView = ({
return (
<div
key={character.token}
className="group relative flex flex-col overflow-hidden rounded-lg border border-transparent bg-white/[0.05] transition-colors hover:border-white/25 hover:bg-white/10"
className="group relative flex flex-col overflow-hidden border border-transparent bg-white/[0.05] transition-colors hover:border-white/30 hover:bg-white/10"
>
<button
onClick={() => onSelectCharacter?.(character)}
@@ -421,9 +428,7 @@ const CharacterListView = ({
/>
) : (
<div className="flex h-full w-full items-center justify-center text-white/20">
<UsersIcon
className="text-2xl" />
<UsersIcon className="text-2xl" />
</div>
)}
</div>
@@ -445,9 +450,9 @@ const CharacterListView = ({
setPreviewCharacter(character);
}}
title="View full size"
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<EyeIcon className="text-[10px]" />
<EyeIcon className="text-[10px]" />
</button>
)}
{isUserCreated && (
@@ -457,22 +462,18 @@ const CharacterListView = ({
e.stopPropagation();
onEditCharacter(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-black/80"
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-black/80"
>
<PenIcon
className="text-[10px]" />
<PenIcon className="text-[10px]" />
</button>
<button
onClick={(e) => {
e.stopPropagation();
setConfirmDelete(character);
}}
className="flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 transition-colors hover:bg-red-500"
className="flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 transition-colors hover:bg-red-500"
>
<Trash2Icon
className="text-[10px]" />
<Trash2Icon className="text-[10px]" />
</button>
</>
)}
@@ -486,28 +487,25 @@ const CharacterListView = ({
{/* Delete confirmation overlay */}
{confirmDelete && (
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="mx-4 w-full max-w-sm rounded-xl border border-white/10 bg-[#1e1e22] p-6 shadow-2xl">
<div className="fixed inset-0 z-[100] flex items-center justify-center bg-black/60">
<div className="mx-4 w-full max-w-sm border border-white/15 bg-ui-modal p-6">
<h3 className="mb-2 text-lg font-semibold text-white">
Delete character?
</h3>
<p className="mb-5 text-sm text-white/70">
This will permanently delete{" "}
<strong>{confirmDelete.name}</strong>. This action cannot be
undone.
This will permanently delete <strong>{confirmDelete.name}</strong>
. This action cannot be undone.
</p>
<div className="flex justify-end gap-2">
<Button
variant="secondary"
className="border-none"
onClick={() => setConfirmDelete(null)}
disabled={isDeleting}
>
Cancel
</Button>
<Button
variant="primary"
className="bg-red-500 hover:bg-red-600"
variant="destructive"
onClick={() => handleDelete(confirmDelete)}
loading={isDeleting}
disabled={isDeleting}
@@ -608,7 +606,7 @@ const EditCharacterView = ({
{/* Avatar preview */}
{character.maybe_avatar?.cdn_url && (
<div className="flex h-56 max-h-56 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/[0.05]">
<div className="flex h-56 max-h-56 shrink-0 items-center justify-center overflow-hidden bg-white/[0.05]">
<img
src={character.maybe_avatar.cdn_url}
alt={character.name}
@@ -619,7 +617,10 @@ const EditCharacterView = ({
{/* Name input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-character-name" className="text-sm font-medium text-white/80">
<label
htmlFor="edit-character-name"
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
>
Name
</label>
<input
@@ -628,14 +629,18 @@ const EditCharacterView = ({
onChange={(e) => setName(e.target.value)}
placeholder="Character name"
autoComplete="off"
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
className="w-full border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
/>
</div>
{/* Description input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="edit-character-description" className="text-sm font-medium text-white/80">
Description <span className="font-normal text-white/40">(optional)</span>
<label
htmlFor="edit-character-description"
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
>
Description{" "}
<span className="font-normal text-white/40">(optional)</span>
</label>
<textarea
id="edit-character-description"
@@ -644,14 +649,14 @@ const EditCharacterView = ({
placeholder="Description..."
rows={3}
autoComplete="off"
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
className="w-full resize-none border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" className="border-none" onClick={onBack}>
<Button variant="secondary" onClick={onBack}>
Cancel
</Button>
<Button
@@ -859,8 +864,8 @@ const NewCharacterView = ({
<div
ref={dropZoneRef}
className={twMerge(
"flex h-56 max-h-56 shrink-0 cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed border-white/20 bg-white/[0.05] transition-colors overflow-hidden",
isDragging && "border-blue-400 bg-blue-500/10",
"flex h-56 max-h-56 shrink-0 cursor-pointer flex-col items-center justify-center rounded-[3px] border-2 border-dashed border-white/15 bg-white/[0.05] transition-colors overflow-hidden",
isDragging && "border-white/60 bg-white/10",
)}
onDragEnter={handleDragEnter}
onDragOver={handleDragOver}
@@ -880,9 +885,7 @@ const NewCharacterView = ({
/>
{!images[0]!.mediaToken && (
<div className="absolute inset-0 flex items-center justify-center bg-black/50">
<LoaderCircleIcon
className="text-white animate-spin" />
<LoaderCircleIcon className="text-white animate-spin" />
</div>
)}
<button
@@ -890,16 +893,14 @@ const NewCharacterView = ({
e.stopPropagation();
removeImage(0);
}}
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center rounded-full bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
className="absolute right-2 top-2 flex h-7 w-7 items-center justify-center bg-black/60 text-white/80 opacity-0 transition-all group-hover:opacity-100 hover:bg-red-500"
>
<XIcon className="text-sm" />
<XIcon className="text-sm" />
</button>
</div>
) : (
<div className="flex h-full w-full flex-col items-center justify-center text-white/60">
<UploadIcon
className="mb-2 text-xl text-white/40" />
<UploadIcon className="mb-2 text-xl text-white/40" />
<p className="text-sm">Upload reference image</p>
<p className="mb-3 text-xs text-white/40">
Click or drag an image here
@@ -918,7 +919,10 @@ const NewCharacterView = ({
{/* Name input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="character-name" className="text-sm font-medium text-white/80">
<label
htmlFor="character-name"
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
>
Name
</label>
<input
@@ -927,14 +931,18 @@ const NewCharacterView = ({
onChange={(e) => setName(e.target.value)}
placeholder="Character name"
autoComplete="off"
className="w-full rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
className="w-full border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
/>
</div>
{/* Description input */}
<div className="flex flex-col gap-1.5">
<label htmlFor="character-description" className="text-sm font-medium text-white/80">
Description <span className="font-normal text-white/40">(optional)</span>
<label
htmlFor="character-description"
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70"
>
Description{" "}
<span className="font-normal text-white/40">(optional)</span>
</label>
<textarea
id="character-description"
@@ -943,14 +951,14 @@ const NewCharacterView = ({
placeholder="Description..."
rows={3}
autoComplete="off"
className="w-full resize-none rounded-lg border border-white/10 bg-white/[0.07] px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-primary"
className="w-full resize-none border border-white/15 bg-ui-controls px-3 py-2 text-sm text-white placeholder-white/50 outline-none transition-colors focus:border-white"
/>
</div>
</div>
{/* Action buttons */}
<div className="flex justify-end gap-2 pt-4">
<Button variant="secondary" className="border-none" onClick={onBack}>
<Button variant="secondary" onClick={onBack}>
Cancel
</Button>
<Button
@@ -4,7 +4,13 @@ import {
type CSSProperties,
type SyntheticEvent,
} from "react";
import { ImageIcon, ImagesIcon, LoaderCircleIcon, PlusIcon, XIcon } from "lucide-react";
import {
ImageIcon,
ImagesIcon,
LoaderCircleIcon,
PlusIcon,
XIcon,
} from "lucide-react";
import { Button } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
import { Modal } from "@storyteller/ui-modal";
@@ -254,10 +260,7 @@ export const ImagePromptRow = ({
)}
<div
ref={rootRef}
className={twMerge(
"glass flex flex-col sm:flex-row rounded-2xl sm:rounded-t-2xl sm:rounded-b-none",
className,
)}
className={twMerge("glass flex flex-col sm:flex-row", className)}
onMouseDown={stopIfInside}
onClick={stopIfInside}
onPointerDown={stopIfInside}
@@ -265,8 +268,8 @@ export const ImagePromptRow = ({
<div className="flex min-w-0 flex-1 gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<ImageIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
<ImageIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
{sectionLabel}
{showCount && (
<span className="font-semibold text-white/60">
@@ -342,10 +345,9 @@ export const ImagePromptRow = ({
<div className="hidden sm:block w-[1px] bg-white/10" />
<div className="flex grow flex-col gap-1 sm:p-2">
<div className="flex items-center gap-2 text-white/90">
<ImageIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
End Frame{" "}
<span className="text-xs text-white/60">(optional)</span>
<ImageIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
End Frame <span className="text-white/60">(optional)</span>
</span>
</div>
<span className="text-[13px] text-white/60">
@@ -356,7 +358,7 @@ export const ImagePromptRow = ({
<div className="flex items-center gap-2">
{endFrameImage ? (
<div
className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all cursor-pointer hover:border-white/80"
className="group relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all cursor-pointer hover:border-white/40"
onClick={() => setPreviewImage(endFrameImage)}
>
<img
@@ -369,13 +371,13 @@ export const ImagePromptRow = ({
e.stopPropagation();
setEndFrameImage?.(undefined);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
) : uploadingEndFrame ? (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white" />
</div>
) : (
@@ -402,7 +404,7 @@ export const ImagePromptRow = ({
<img
src={previewImage.fullUrl || previewImage.url}
alt="Preview"
className="max-w-[90vw] max-h-[90vh] object-contain drop-shadow-2xl rounded-lg"
className="max-w-[90vw] max-h-[90vh] object-contain"
/>
</div>
)}
@@ -414,7 +416,7 @@ export const ImagePromptRow = ({
// ── Sub-components ───────────────────────────────────────────────────────
const ADD_BUTTON_CLASS =
"flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
"flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
// Upload / Pick-from-library affordance. On desktop it's a hover tooltip; on
// mobile that tooltip auto-opens from the emulated mouseenter fired on
@@ -431,9 +433,7 @@ export const AddButton = ({
const isMobile = useIsMobile();
const [drawerOpen, setDrawerOpen] = useState(false);
const plus = (
<PlusIcon className="text-2xl text-white/80" />
);
const plus = <PlusIcon className="text-2xl text-white/80" />;
if (!onPickFromLibrary) {
return (
@@ -477,7 +477,7 @@ export const AddButton = ({
<Button
variant="action"
icon={ImagesIcon}
className="w-full bg-white/15 hover:bg-white/20"
className="w-full"
onClick={() => {
setDrawerOpen(false);
onPickFromLibrary();
@@ -512,7 +512,7 @@ export const AddButton = ({
variant="action"
onClick={onPickFromLibrary}
icon={ImagesIcon}
className="w-full bg-white/15 hover:bg-white/20"
className="w-full"
>
Pick from library
</Button>
@@ -536,7 +536,7 @@ const ImageThumbnail = ({
onPreview?: (image: RefImage) => void;
}) => (
<div
className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all cursor-pointer hover:border-white/80"
className="group glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all cursor-pointer hover:border-white/40"
onClick={() => onPreview?.(image)}
>
<img
@@ -549,9 +549,9 @@ const ImageThumbnail = ({
e.stopPropagation();
onRemove(image.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
);
@@ -589,11 +589,11 @@ const SortableImage = ({
{...(allowReorder ? { ...attributes, ...listeners } : {})}
onClick={() => onPreview?.(image)}
className={twMerge(
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-opacity",
"group glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-opacity",
allowReorder
? "cursor-move hover:border-white/80"
: "cursor-pointer hover:border-white/80",
isDragging && "opacity-50 shadow-lg",
? "cursor-move hover:border-white/40"
: "cursor-pointer hover:border-white/40",
isDragging && "opacity-50",
)}
>
<img
@@ -608,9 +608,9 @@ const SortableImage = ({
}}
onMouseDown={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
);
@@ -618,7 +618,7 @@ const SortableImage = ({
const UploadingThumbnail = ({ previewUrl }: { previewUrl: string }) => {
return (
<div className="glass relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30">
<div className="glass relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15">
{previewUrl && (
<div className="absolute inset-0">
<img
@@ -1,5 +1,12 @@
import { useCallback, useRef, useState } from "react";
import { LoaderCircleIcon, MusicIcon, PlayIcon, SquareIcon, VideoIcon, XIcon } from "lucide-react";
import {
LoaderCircleIcon,
MusicIcon,
PlayIcon,
SquareIcon,
VideoIcon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { twMerge } from "tailwind-merge";
import { UploaderStates } from "@storyteller/common";
@@ -214,10 +221,7 @@ export const MediaReferenceRow = ({
onChange={handleAudioUpload}
/>
<div
className={twMerge(
"glass flex flex-col sm:flex-row rounded-2xl",
className,
)}
className={twMerge("glass flex flex-col sm:flex-row", className)}
onMouseDown={(e) => e.stopPropagation()}
onClick={(e) => e.stopPropagation()}
onPointerDown={(e) => e.stopPropagation()}
@@ -227,8 +231,8 @@ export const MediaReferenceRow = ({
<div className="flex grow gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex items-center gap-2 text-white/90">
<VideoIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
<VideoIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Video Ref
<span className="font-semibold text-white/60">
({referenceVideos.length}/{maxVideoCount})
@@ -248,11 +252,8 @@ export const MediaReferenceRow = ({
/>
))}
{uploadingVideo && (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<LoaderCircleIcon
spin
className="h-5 w-5 text-white/60" />
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white/60" />
</div>
)}
{canAddVideo && (
@@ -275,8 +276,8 @@ export const MediaReferenceRow = ({
<div className="flex grow gap-2 px-3 py-2">
<div className="flex grow flex-col gap-1">
<div className="flex items-center gap-2 text-white/90">
<MusicIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 text-sm font-medium">
<MusicIcon className="h-3.5 w-3.5" />
<span className="flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Audio Ref
<span className="font-semibold text-white/60">
({referenceAudios.length}/{maxAudioCount})
@@ -297,11 +298,8 @@ export const MediaReferenceRow = ({
/>
))}
{uploadingAudio && (
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/5">
<LoaderCircleIcon
spin
className="h-5 w-5 text-white/60" />
<div className="flex aspect-square w-10 sm:w-14 items-center justify-center overflow-hidden border border-white/15 bg-white/5">
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white/60" />
</div>
)}
{canAddAudio && (
@@ -328,7 +326,7 @@ const VideoRefTile = ({
video: RefVideo;
onRemove: (id: string) => void;
}) => (
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div className="group relative aspect-square w-10 sm:w-14 overflow-hidden border border-white/15 transition-all hover:border-white/40">
<video
src={video.url}
muted
@@ -343,9 +341,9 @@ const VideoRefTile = ({
e.stopPropagation();
onRemove(video.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
);
@@ -382,7 +380,7 @@ const AudioRefTile = ({
);
return (
<div className="group relative flex aspect-square w-10 sm:w-14 cursor-pointer items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 transition-all hover:border-white/80">
<div className="group relative flex aspect-square w-10 sm:w-14 cursor-pointer items-center justify-center overflow-hidden border border-white/15 transition-all hover:border-white/40">
<button
onClick={handleTogglePlay}
className="flex h-full w-full items-center justify-center"
@@ -403,9 +401,9 @@ const AudioRefTile = ({
e.stopPropagation();
onRemove(audio.id);
}}
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center rounded-full bg-black/50 text-white sm:opacity-0 backdrop-blur-md transition-colors hover:bg-black sm:group-hover:opacity-100"
className="absolute right-[2px] top-[2px] flex h-5 w-5 cursor-pointer items-center justify-center bg-black/50 text-white sm:opacity-0 transition-colors hover:bg-black sm:group-hover:opacity-100"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
);
@@ -8,7 +8,13 @@ import {
type ReactNode,
} from "react";
import { twMerge } from "tailwind-merge";
import { ChevronDownIcon, ChevronUpIcon, MusicIcon, UsersIcon, VideoIcon } from "lucide-react";
import {
ChevronDownIcon,
ChevronUpIcon,
MusicIcon,
UsersIcon,
VideoIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { GenerateIconButton } from "@storyteller/ui-button";
import { Tooltip } from "@storyteller/ui-tooltip";
@@ -231,8 +237,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
// mode renders no video/audio deck, so those kinds only land in
// reference mode where the user can see (and remove) them.
const isKeyframeMode = !!isVideo && !isReferenceMode;
const dropAcceptsImages =
!!supportsImagePrompts && maxImagePromptCount > 0;
const dropAcceptsImages = !!supportsImagePrompts && maxImagePromptCount > 0;
const dropAcceptsVideos =
!isKeyframeMode && !!videoRefsSupported && !!onReferenceVideosChange;
const dropAcceptsAudio =
@@ -355,8 +360,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
const deckAddActions: DeckAddAction[] = [];
if (
supportsImagePrompts &&
referenceImages.length + deck.uploadingImages.length <
maxImagePromptCount
referenceImages.length + deck.uploadingImages.length < maxImagePromptCount
) {
deckAddActions.push({
key: "upload-image",
@@ -780,8 +784,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
<div
className={twMerge(
"glass rounded-2xl p-3 sm:p-4 !transition-all duration-200",
isFocused && "ring-1 ring-primary",
"glass p-3 sm:p-4 !transition-all duration-200",
isFocused && "border-primary",
)}
{...drop.dropZoneProps}
>
@@ -854,8 +858,8 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
/>
{mentionOpen && filteredMentionItems.length > 0 && (
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-lg border border-ui-panel-border bg-ui-controls shadow-lg backdrop-blur-xl">
<div className="px-3 py-1.5 text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
<div className="absolute bottom-full left-0 z-50 mb-1 w-64 max-w-[calc(100vw-3rem)] overflow-hidden rounded-[3px] border border-ui-panel-border bg-ui-controls">
<div className="px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
Mentions
</div>
{filteredMentionItems.map((item, i) => (
@@ -873,7 +877,7 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
}}
onMouseEnter={() => setMentionIndex(i)}
>
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden rounded-md border border-white/20 bg-black/20">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center overflow-hidden border border-white/15 bg-black/20">
{(item.type === "image" ||
item.type === "character") &&
item.preview ? (
@@ -890,13 +894,13 @@ export const PromptBox = forwardRef<HTMLDivElement, PromptBoxProps>(
className="h-full w-full object-cover"
/>
) : item.type === "character" ? (
<UsersIcon
className="h-3.5 w-3.5 text-white/60" />
<UsersIcon className="h-3.5 w-3.5 text-white/60" />
) : (
<DynamicIcon
icon={
item.type === "video" ? VideoIcon : MusicIcon
item.type === "video"
? VideoIcon
: MusicIcon
}
className="h-3.5 w-3.5 text-white/60"
/>
@@ -245,15 +245,15 @@ export function PromptBoxDropOverlay({
return (
<div
className={twMerge(
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 rounded-2xl border-2 border-dashed bg-[#161618]/85 backdrop-blur-sm",
rejected ? "border-red-400/80" : "border-primary",
"promptbox-drop-overlay pointer-events-none absolute inset-0 z-40 flex flex-col items-center justify-center gap-2 border-2 border-dashed bg-ui-controls/85",
rejected ? "border-red-400/80" : "border-white/60",
)}
>
<div className="flex items-center gap-2">
{kinds.map((kind, i) => (
<div
key={kind.label}
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center rounded-lg bg-white/10 text-base-fg/90"
className="promptbox-drop-chip flex h-9 w-9 items-center justify-center bg-white/10 text-base-fg/90"
style={{ animationDelay: `${i * 50}ms` }}
>
<DynamicIcon icon={kind.icon} className="text-sm" />
@@ -14,7 +14,7 @@ import { Maximize2Icon } from "lucide-react";
// Webapp "focus mode" for the prompt editor. Mirrors the desktop lib's
// (@storyteller/ui-promptbox) fullscreen feature but lives here because the
// webapp has its own PromptBox. Reuses the shared @storyteller/ui-modal for a
// Trello-card-style centered panel + blurred backdrop. The overlay renders a
// Trello-card-style centered panel. The overlay renders a
// fresh editor bound to the SAME value + onChange, so text/mentions stay
// continuous across open/close.
@@ -39,7 +39,7 @@ export const PromptFullscreenButton = ({
type="button"
aria-label="Expand prompt to focus mode"
onClick={onClick}
className="flex h-6 w-6 items-center justify-center rounded-full bg-base-fg/5 text-base-fg/50 transition-colors hover:bg-base-fg/10 hover:text-base-fg/90 focus:outline-none"
className="flex h-6 w-6 items-center justify-center bg-base-fg/5 text-base-fg/50 transition-colors hover:bg-base-fg/10 hover:text-base-fg/90 focus:outline-none"
>
<Maximize2Icon
@@ -112,7 +112,6 @@ export const PromptFullscreenModal = ({
closeOnOutsideClick
closeOnEsc
className="w-full max-w-4xl"
backdropClassName="backdrop-blur-md"
>
{/* Explicit inline height (not a Tailwind arbitrary class) so the column
is reliably bounded across the modal's nested wrappers — that's what
@@ -19,8 +19,8 @@ export function DrawerOptionList({ items, onSelect }: DrawerOptionListProps) {
disabled={item.disabled}
onClick={() => !item.disabled && onSelect(item)}
className={twMerge(
"flex w-full items-center gap-3 rounded-xl px-3 py-3 text-left transition-colors",
item.selected ? "bg-primary/15" : "hover:bg-white/5",
"flex w-full items-center gap-3 px-3 py-3 text-left transition-colors",
item.selected ? "bg-white/10" : "hover:bg-white/5",
item.disabled && "cursor-not-allowed opacity-50",
)}
>
@@ -42,8 +42,8 @@ export function DrawerOptionList({ items, onSelect }: DrawerOptionListProps) {
{item.trailing}
{item.selected && (
<CheckIcon
className="h-3.5 w-3.5 shrink-0 text-primary" />
className="h-3.5 w-3.5 shrink-0 text-white" />
)}
</button>
))}
@@ -9,7 +9,7 @@ interface DrawerSectionProps {
export function DrawerSection({ label, children }: DrawerSectionProps) {
return (
<div className="py-1">
<span className="px-3 text-[11px] font-semibold uppercase tracking-wider text-base-fg/45">
<span className="px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/45">
{label}
</span>
{children}
@@ -33,14 +33,14 @@ export function MobileCountStepper({
};
return (
<div className="flex h-11 shrink-0 items-center rounded-xl border border-ui-panel-border bg-ui-controls px-1">
<div className="flex h-11 shrink-0 items-center rounded-[3px] border border-ui-panel-border bg-ui-controls px-1">
<button
type="button"
onClick={() => step(-1)}
disabled={index <= 0}
className="flex h-9 w-8 items-center justify-center rounded-lg text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
className="flex h-9 w-8 items-center justify-center text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
>
<MinusIcon className="h-3 w-3" />
<MinusIcon className="h-3 w-3" />
</button>
<span className="min-w-10 text-center text-sm font-semibold tabular-nums text-base-fg">
{value}/{maxValue}
@@ -49,9 +49,9 @@ export function MobileCountStepper({
type="button"
onClick={() => step(1)}
disabled={index === allowed.length - 1}
className="flex h-9 w-8 items-center justify-center rounded-lg text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
className="flex h-9 w-8 items-center justify-center text-base-fg/80 transition-colors hover:bg-white/10 disabled:opacity-30"
>
<PlusIcon className="h-3 w-3" />
<PlusIcon className="h-3 w-3" />
</button>
</div>
);
@@ -26,7 +26,7 @@ export function MobileFieldButton({
onClick={onClick}
disabled={disabled}
className={twMerge(
"flex w-full items-center gap-3 rounded-xl border border-ui-panel-border bg-ui-controls px-3.5 py-3 text-left transition-colors",
"flex w-full items-center gap-3 border border-ui-panel-border bg-ui-controls px-3.5 py-3 text-left transition-colors",
"hover:bg-ui-controls/80 active:scale-[0.99] disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
@@ -77,7 +77,7 @@ export function MobilePromptForm({
{frames}
{mediaRefs}
<div className="rounded-xl border border-ui-panel-border bg-ui-controls p-3">
<div className="border border-ui-panel-border bg-ui-controls p-3">
<MentionTextarea
value={prompt}
onChange={onPromptChange}
@@ -100,7 +100,7 @@ export function MobilePromptForm({
<div className="flex items-center gap-2 border-t border-ui-panel-border bg-ui-panel px-3 py-3">
{countField}
<GenerateButton
className="h-11 rounded-xl flex-1 items-center justify-center border-none bg-primary text-base text-white disabled:cursor-not-allowed disabled:opacity-50"
className="h-11 flex-1 items-center justify-center disabled:cursor-not-allowed disabled:opacity-50"
onClick={handleCreate}
disabled={!prompt.trim() || isSubmitting}
loading={isSubmitting}
@@ -64,13 +64,13 @@ export function SettingsDrawer({
aria-describedby={undefined}
modal={modal}
open={open}
className="ac-drawer-content max-h-[80vh] rounded-t-2xl bg-ui-panel pb-[max(1rem,env(safe-area-inset-bottom))]"
className="ac-drawer-content max-h-[80vh] bg-ui-panel pb-[max(1rem,env(safe-area-inset-bottom))]"
>
<button
type="button"
aria-label="Close"
onClick={() => onOpenChange(false)}
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center rounded-full bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white"
className="absolute right-3 top-3 z-10 flex h-8 w-8 items-center justify-center bg-white/10 text-white/70 transition-colors hover:bg-white/20 hover:text-white"
>
<XIcon className="text-sm" />
</button>
@@ -104,7 +104,7 @@ export function SettingsDrawer({
onPointerUp={endDrag}
onPointerCancel={endDrag}
>
<div className="mx-auto mt-2 h-1 w-10 shrink-0 rounded-full bg-white/20" />
<div className="mx-auto mt-2 h-1 w-10 shrink-0 bg-white/20" />
<SheetHeader className={hideHeader ? "sr-only" : "select-none pb-2 pr-12"}>
<SheetTitle>{title}</SheetTitle>
</SheetHeader>
@@ -156,7 +156,7 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
maxLength={USERNAME_MAX}
autoComplete="username"
autoFocus
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
<Input
type="password"
@@ -164,7 +164,7 @@ function UsernameForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Confirm password"
autoComplete="current-password"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
{error && <FormError message={error} />}
<FormActions
@@ -262,7 +262,7 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
maxLength={EMAIL_MAX}
autoComplete="email"
autoFocus
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
<Input
type="password"
@@ -270,7 +270,7 @@ function EmailForm({ user, isEditing, onOpen, onClose }: SectionFormProps) {
onChange={(e) => setCurrentPassword(e.target.value)}
placeholder="Confirm password"
autoComplete="current-password"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
{error && <FormError message={error} />}
<FormActions
@@ -388,7 +388,7 @@ function PasswordForm({
placeholder="Current password"
autoComplete="current-password"
autoFocus
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
)}
<div className="grid grid-cols-2 gap-2">
@@ -400,7 +400,7 @@ function PasswordForm({
minLength={PASSWORD_MIN}
autoComplete="new-password"
autoFocus={passwordNotSet}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
<Input
type="password"
@@ -411,7 +411,7 @@ function PasswordForm({
}
minLength={PASSWORD_MIN}
autoComplete="new-password"
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none"
/>
</div>
{error && <FormError message={error} />}
@@ -450,7 +450,9 @@ function SectionShell({
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">{title}</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
{title}
</p>
<p className="text-xs opacity-70">
{isEditing ? editingDescription : collapsedDescription}
</p>
@@ -480,7 +482,7 @@ function SectionShell({
function CurrentValue({ label, value }: { label: string; value: string }) {
return (
<div className="flex items-baseline gap-2">
<span className="text-[10px] font-semibold uppercase tracking-wider text-white/40">
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/40">
{label}
</span>
<span className="text-sm font-medium text-white truncate">{value}</span>
@@ -514,11 +516,7 @@ function FormActions({
className="h-9 px-4"
disabled={submitting || disabled}
>
{submitting ? (
<LoaderCircleIcon className="animate-spin" />
) : (
"Save"
)}
{submitting ? <LoaderCircleIcon className="animate-spin" /> : "Save"}
</Button>
</div>
);
@@ -2,7 +2,13 @@ import { useEffect, useState } from "react";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
import { Modal } from "@storyteller/ui-modal";
import { CheckIcon, CopyIcon, KeyIcon, LoaderCircleIcon, TrashIcon } from "lucide-react";
import {
CheckIcon,
CopyIcon,
KeyIcon,
LoaderCircleIcon,
TrashIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import {
UserApiKeysApi,
@@ -17,7 +23,7 @@ const DESCRIPTION_MAX = 512;
const PAGE_SIZE = 20;
const INPUT_CLASS =
"w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-lg px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
"w-full bg-ui-controls border border-white/15 focus:border-white/40 rounded-[3px] px-3 py-2 text-sm text-white placeholder-white/30 outline-none";
interface ApiKeySectionProps {
user: UserInfo;
@@ -97,7 +103,9 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
<div className="flex flex-col gap-3">
<div className="flex items-start justify-between gap-3 mb-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">API keys</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
API keys
</p>
<p className="text-xs opacity-70">
Create and manage keys to access the API programmatically.
</p>
@@ -131,7 +139,7 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
{loading ? (
<div className="py-6 text-center text-xs opacity-60">
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
</div>
) : keys.length === 0 ? (
<div className="py-6 text-center text-xs opacity-60">
@@ -169,7 +177,7 @@ export function ApiKeySection(_props: ApiKeySectionProps) {
onClick={() => load(false)}
>
{loadingMore ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Load more"
)}
@@ -279,16 +287,16 @@ function NewKeyReveal({
};
return (
<div className="flex flex-col gap-2 rounded-lg border border-primary/40 bg-primary/10 p-3">
<div className="flex flex-col gap-2 border border-white/30 bg-white/[0.06] p-3">
<div className="flex items-center gap-2 text-sm font-medium">
<KeyIcon className="opacity-80" />
<KeyIcon className="opacity-80" />
<span>API key created</span>
</div>
<p className="text-xs opacity-80">
Copy your key now. For security, you won't be able to see it again.
</p>
<div className="flex items-center gap-2">
<code className="min-w-0 flex-1 truncate rounded-md bg-black/40 px-3 py-2 font-mono text-xs text-white">
<code className="min-w-0 flex-1 truncate border border-white/15 bg-ui-controls px-3 py-2 font-mono text-xs text-white">
{created.api_key}
</code>
<Button
@@ -334,7 +342,7 @@ function ApiKeyRow({
onRequestDelete: () => void;
}) {
return (
<div className="flex flex-col gap-2 rounded-lg border border-white/10 bg-white/[0.03] p-3 transition-colors hover:border-white/[0.14]">
<div className="flex flex-col gap-2 border border-white/15 bg-white/[0.03] p-3 transition-colors hover:border-white/30">
<div className="flex items-start justify-between gap-3">
<div className="flex min-w-0 flex-col gap-0.5">
<p className="truncate text-sm font-medium">{item.name}</p>
@@ -346,10 +354,10 @@ function ApiKeyRow({
<div className="flex items-center gap-2 pt-1">
{/* Truncated prefix, display-only — the full secret is only shown on
create. The trailing ellipsis signals it's not the whole value. */}
<code className="min-w-0 truncate rounded bg-black/40 px-2 py-1 font-mono text-[11px] text-white/70">
<code className="min-w-0 truncate bg-ui-controls px-2 py-1 font-mono text-[11px] text-white/70">
{item.truncated_api_key}…
</code>
<span className="text-[10px] uppercase tracking-wider text-white/40">
<span className="font-mono text-[10px] uppercase tracking-[0.12em] text-white/40">
{formatDate(item.created_at)}
</span>
</div>
@@ -497,13 +505,13 @@ function DeleteConfirmModal({
</Button>
<Button
type="button"
variant="primary"
className="h-9 px-4 bg-red-500 hover:bg-red-600"
variant="destructive"
className="h-9 px-4"
onClick={handleConfirm}
disabled={deleting}
>
{deleting ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Delete"
)}
@@ -543,7 +551,7 @@ function FormActions({
disabled={submitting || disabled}
>
{submitting ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
submitLabel
)}
@@ -54,7 +54,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<div className="flex h-full flex-col sm:grid sm:grid-cols-12 sm:gap-3">
<div className="relative shrink-0 border-b border-ui-panel-border p-4 sm:col-span-4 sm:border-b-0 sm:p-3 sm:pt-2 sm:after:absolute sm:after:right-0 sm:after:top-0 sm:after:h-full sm:after:w-px sm:after:bg-ui-panel-border">
<div className="hidden items-center gap-2.5 py-0.5 sm:flex">
<h2 className="text-[18px] font-semibold opacity-80">Settings</h2>
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60">
Settings
</h2>
</div>
<hr className="my-2 hidden w-full border-ui-panel-border sm:block" />
<div className="flex gap-2 overflow-x-auto pe-10 sm:block sm:space-y-1 sm:overflow-visible sm:pe-0">
@@ -63,8 +65,10 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
key={t.id}
onClick={() => setTab(t.id)}
className={twMerge(
"h-9 shrink-0 rounded-lg px-3 text-left transition-colors sm:w-full sm:px-2",
tab === t.id ? "bg-[#63636B]/20" : "hover:bg-white/[0.04]",
"h-9 shrink-0 px-3 text-left transition-colors sm:w-full sm:px-2",
tab === t.id
? "bg-white/10 text-white"
: "text-white/70 hover:bg-white/[0.06] hover:text-white",
)}
>
<div className="flex items-center gap-2.5 whitespace-nowrap text-sm">
@@ -78,7 +82,9 @@ export function SettingsModal({ isOpen, onClose }: SettingsModalProps) {
<div className="relative flex min-h-0 flex-1 flex-col overflow-y-auto sm:col-span-8 sm:h-full pl-0.5">
<div className="w-full border-b border-ui-panel-border px-4 py-2.5 sm:px-0">
<h2 className="text-[18px] font-semibold">{activeLabel}</h2>
<h2 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80">
{activeLabel}
</h2>
</div>
<div className="h-full p-4 text-sm sm:p-3 sm:ps-0">
{tab === "general" && <GeneralPanel />}
@@ -112,7 +118,9 @@ function GeneralPanel() {
<div className="space-y-4 text-base-fg">
<div className="flex flex-col gap-2 pt-3">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Enter to generate</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Enter to generate
</p>
<p className="text-xs opacity-70">
When on, pressing Enter submits the prompt and Shift+Enter adds a
new line. When off (default), both Enter and Shift+Enter add a new
@@ -127,7 +135,9 @@ function GeneralPanel() {
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Play videos with sound</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Play videos with sound
</p>
<p className="text-xs opacity-70">
When on (default), videos in the media viewer start unmuted so you
don't have to click unmute every time. When off, they start muted.
@@ -141,7 +151,9 @@ function GeneralPanel() {
</div>
<div className="flex flex-col gap-2">
<div className="flex flex-col gap-0.5">
<p className="text-sm font-medium">Group models by family</p>
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Group models by family
</p>
<p className="text-xs opacity-70">
When on (default), the model picker groups models into submenus by
family, like Seedance or Veo. When off, every model shows in one
@@ -1,7 +1,24 @@
import { useMemo } from "react";
import { Link, useLocation } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { BoxIcon, CircleHelpIcon, DownloadIcon, FilmIcon, GiftIcon, GlobeIcon, GraduationCapIcon, GroupIcon, HouseIcon, ImageIcon, ImagesIcon, MusicIcon, NewspaperIcon, PencilIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import {
BoxIcon,
CircleHelpIcon,
DownloadIcon,
FilmIcon,
GiftIcon,
GlobeIcon,
GraduationCapIcon,
GroupIcon,
HouseIcon,
ImageIcon,
ImagesIcon,
MusicIcon,
NewspaperIcon,
PencilIcon,
VideoIcon,
WandSparklesIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { DynamicIcon, DiscordIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
@@ -33,7 +50,9 @@ type NavItem = {
badge?: string;
};
const PRIMARY_ITEMS: NavItem[] = [{ label: "Home", href: "/", icon: HouseIcon }];
const PRIMARY_ITEMS: NavItem[] = [
{ label: "Home", href: "/", icon: HouseIcon },
];
// Generation entries — make something from a prompt.
const CREATE_ITEMS_STATIC: NavItem[] = [
@@ -150,13 +169,15 @@ function NavMenuItem({
interactive, kept subtle to stay within the "restrained chrome" lane. */}
<DynamicIcon
icon={item.icon}
className="transition-transform duration-200 ease-out group-hover/menu-item:scale-110"
className="transition-colors duration-150"
/>
<span className="group-data-[collapsible=icon]:hidden">{item.label}</span>
{item.badge && (
<span
className={`ml-auto px-1.5 py-0.5 text-[10px] font-semibold uppercase leading-none text-white group-data-[collapsible=icon]:hidden rounded-full ${
item.badge === "NEW" ? "bg-purple-600" : "bg-amber-600"
className={`ml-auto border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] leading-none group-data-[collapsible=icon]:hidden ${
item.badge === "NEW"
? "border-purple-400/40 text-purple-300"
: "border-amber-400/40 text-amber-300"
}`}
>
{item.badge}
@@ -173,7 +194,7 @@ function NavMenuItem({
{active && (
<motion.span
layoutId="sidebar-active-indicator"
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-1 rounded-full bg-primary"
className="pointer-events-none absolute inset-y-1.5 left-0 z-10 w-0.5 bg-white"
transition={
reduceMotion
? { duration: 0 }
@@ -259,7 +280,7 @@ export function AppSidebar() {
className="flex items-center gap-2"
>
<img
src="/images/artcraft-logo.png"
src="/images/artcraft-logo-2.png"
alt="ArtCraft"
className="h-5 w-auto shrink-0"
/>
@@ -318,7 +339,7 @@ export function AppSidebar() {
onClick={() =>
window.open(DOWNLOAD_URL, "_blank", "noopener,noreferrer")
}
className="w-full justify-center h-9 text-sm font-semibold rounded-full"
className="w-full justify-center h-9"
>
Download ArtCraft
</Button>
@@ -81,7 +81,7 @@ export function LibraryFoldersNav({
type="button"
onClick={() => openNewFolderModal(null)}
aria-label="New folder"
className="mr-1 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
className="mr-1 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<PlusIcon className="text-xs" />
</button>
@@ -125,7 +125,7 @@ export function LibraryFoldersNav({
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse folders" : "Expand folders"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<ChevronRightIcon
@@ -63,7 +63,7 @@ export function LibraryTagsNav({
type="button"
onClick={() => setExpanded((v) => !v)}
aria-label={expanded ? "Collapse tags" : "Expand tags"}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 hover:bg-sidebar-accent hover:text-sidebar-foreground transition-colors group-data-[collapsible=icon]:hidden"
>
<ChevronRightIcon
@@ -103,7 +103,7 @@ export function LibraryTagsNav({
className="pl-5"
>
<TagIcon
className="text-violet-400" />
<span className="truncate">{tag.value}</span>
<span className="ml-auto text-[10px] text-sidebar-foreground/40 transition-opacity group-hover/menu-item:opacity-0">
@@ -123,7 +123,7 @@ export function LibraryTagsNav({
});
}}
aria-label={`Options for tag "${tag.value}"`}
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center rounded text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
className="absolute right-1 top-1/2 -translate-y-1/2 flex h-5 w-5 items-center justify-center text-sidebar-foreground/50 opacity-0 transition-opacity group-hover/menu-item:opacity-100 hover:bg-sidebar-accent hover:text-sidebar-foreground"
>
<EllipsisIcon className="text-xs" />
</button>
@@ -32,10 +32,8 @@ export function MobileBottomNav() {
key={item.href}
to={item.href}
className={twMerge(
"flex flex-1 flex-col items-center justify-center gap-1 py-2 text-[11px] font-medium transition-colors active:scale-95",
active
? "text-primary"
: "text-base-fg/55 hover:text-base-fg/80",
"flex flex-1 flex-col items-center justify-center gap-1 py-2 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] transition-colors",
active ? "text-white" : "text-base-fg/55 hover:text-base-fg/80",
)}
>
<DynamicIcon icon={item.icon} className="h-5 w-5" />
@@ -72,7 +72,7 @@ export function SignupCtaModal() {
<Modal
isOpen={isOpen}
onClose={close}
className="rounded-2xl w-full max-w-md overflow-hidden border border-white/[5%] bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
className="w-full max-w-md overflow-hidden border border-white/15 bg-ui-modal p-0"
allowBackgroundInteraction={false}
showClose={true}
closeOnOutsideClick={true}
@@ -81,16 +81,6 @@ export function SignupCtaModal() {
backdropClassName="bg-black/80"
>
<div className="relative overflow-hidden">
{/* Off-center ambient glow, feels designed, not generic */}
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
<h2 className="mt-3 font-display text-3xl font-semibold tracking-tight text-white sm:text-[34px] sm:leading-[1.1]">
Start <span className="text-primary">crafting</span> in seconds.
@@ -105,10 +95,8 @@ export function SignupCtaModal() {
key={perk}
className="flex items-start gap-3 text-[14px] text-white/75"
>
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center rounded-full bg-primary/15">
<CheckIcon
className="text-[9px] text-primary" />
<span className="mt-[2px] flex h-4 w-4 shrink-0 items-center justify-center bg-white/10">
<CheckIcon className="text-[9px] text-white" />
</span>
<span>{perk}</span>
</li>
@@ -117,16 +105,13 @@ export function SignupCtaModal() {
<div className="mt-8 flex flex-col gap-3">
{error && (
<div className="rounded-xl border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
<div className="border border-red-500/20 bg-red-500/10 px-4 py-3 text-center text-sm text-red-500">
{error}
</div>
)}
<Link to={`/signup?from=${from}`} onClick={close} className="block">
<Button
variant="primary"
className="w-full h-10 text-sm font-semibold rounded-full"
>
<Button variant="primary" className="w-full h-10">
Create account with email
</Button>
</Link>
@@ -135,7 +120,7 @@ export function SignupCtaModal() {
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
</div>
<span className="relative bg-[#161618] px-4 text-xs uppercase tracking-widest text-white/40">
<span className="relative bg-ui-modal px-4 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
or
</span>
</div>
@@ -88,10 +88,10 @@ export const ToastContainer = () => {
{toasts.map((toast) => (
<div
key={toast.id}
className="glass flex max-w-full items-center gap-3 rounded-xl px-4 py-2.5 text-sm text-white animate-toast-in sm:max-w-md"
className="glass flex max-w-full items-center gap-3 rounded-[3px] px-4 py-2.5 text-sm text-white animate-toast-in sm:max-w-md"
>
<div
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center rounded-full ${
className={`flex h-6 w-6 flex-shrink-0 items-center justify-center ${
toast.type === "success" ? "bg-green-500" : "bg-red-500"
}`}
>
@@ -107,7 +107,7 @@ export const ToastContainer = () => {
onClick={() => dismiss(toast.id)}
className="ml-1 text-white/40 transition-colors hover:text-white/80"
>
<XIcon className="h-3 w-3" />
<XIcon className="h-3 w-3" />
</button>
</div>
))}
@@ -121,7 +121,7 @@ export function ActiveSceneTitle() {
onClick={startEdit}
title="Rename scene"
aria-label="Rename scene"
className="flex min-w-0 max-w-[280px] items-center rounded-lg border border-transparent px-3 py-1.5 text-sm font-semibold text-white transition-all hover:cursor-text hover:bg-white/[0.08]"
className="flex min-w-0 max-w-[280px] items-center border border-transparent px-3 py-1.5 text-sm font-semibold text-white transition-colors hover:cursor-text hover:bg-white/10"
>
<span className="truncate">{title}</span>
<PencilIcon
@@ -129,7 +129,7 @@ export function ActiveSceneTitle() {
className="ml-2 shrink-0 text-sm opacity-50" />
</button>
) : (
<div className="max-w-[280px] truncate rounded-lg border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
<div className="max-w-[280px] truncate border border-transparent px-3 py-1.5 text-sm font-semibold text-white/80">
{title}
</div>
)}
@@ -157,18 +157,15 @@ export function ActiveSceneTitle() {
disabled={isSubmitting}
maxLength={120}
className={twMerge(
"h-[34px] w-fit max-w-full rounded-lg border border-white/[0.12] bg-white/[0.04] px-3 text-sm font-semibold text-white outline-none focus:outline focus:outline-2 focus:outline-brand-primary",
isSubmitting && "outline outline-2 outline-brand-secondary",
"h-[34px] w-fit max-w-full border border-white/15 bg-ui-controls px-3 text-sm font-semibold text-white outline-none focus:outline focus:outline-1 focus:outline-white/60",
isSubmitting && "outline outline-1 outline-white/30",
!isValid && "border-danger focus:outline-danger focus:outline-2",
)}
/>
)}
{isSubmitting && (
<LoaderCircleIcon
spin
className="shrink-0 text-sm opacity-70" />
<LoaderCircleIcon className="shrink-0 animate-spin text-sm opacity-70" />
)}
</div>
);
@@ -14,7 +14,14 @@ import {
Transition,
} from "@headlessui/react";
import { twMerge } from "tailwind-merge";
import { CoinsIcon, GemIcon, GiftIcon, ImagesIcon, LifeBuoyIcon, SettingsIcon } from "lucide-react";
import {
CoinsIcon,
GemIcon,
GiftIcon,
ImagesIcon,
LifeBuoyIcon,
SettingsIcon,
} from "lucide-react";
import { DiscordIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { PopoverMenu } from "@storyteller/ui-popover";
@@ -99,9 +106,9 @@ export function TopBarActions({ className }: { className?: string }) {
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
>
<GemIcon className="text-[11px]" />
<GemIcon className="text-[11px]" />
Pricing
</Link>
@@ -118,7 +125,7 @@ export function TopBarActions({ className }: { className?: string }) {
variant="primary"
icon={GemIcon}
onClick={() => navigate("/pricing")}
className="h-8 px-3 text-sm font-semibold rounded-lg"
className="h-8 px-3"
>
Upgrade
</Button>
@@ -128,14 +135,14 @@ export function TopBarActions({ className }: { className?: string }) {
<Link
to="/library"
className="hidden sm:flex h-8 items-center gap-1.5 px-3 rounded-lg bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-sm font-medium text-white/80 transition-colors"
className="hidden sm:flex h-8 items-center gap-1.5 px-3 rounded-[3px] border border-white/15 hover:bg-white/10 hover:border-white/30 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80 transition-colors"
>
<ImagesIcon className="text-[11px]" />
<ImagesIcon className="text-[11px]" />
My Library
</Link>
<Menu as="div" className="relative ml-1">
<MenuButton className="flex h-8 w-8 rounded-full overflow-hidden focus:outline-none focus:ring-2 focus:ring-primary/40 ring-offset-2 ring-offset-[#121212]">
<MenuButton className="flex h-8 w-8 overflow-hidden border border-white/15 focus:outline-none focus:ring-2 focus:ring-white/60 ring-offset-2 ring-offset-[#0b0b0c]">
<span className="sr-only">Open user menu</span>
<img
className="h-full w-full object-cover"
@@ -154,9 +161,9 @@ export function TopBarActions({ className }: { className?: string }) {
>
<MenuItems
modal={false}
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-xl bg-[#1a1a1a] border border-white/[0.08] shadow-xl focus:outline-none overflow-hidden"
className="absolute right-0 z-50 mt-2 w-48 origin-top-right rounded-[3px] bg-[#101014] border border-white/15 focus:outline-none overflow-hidden"
>
<div className="px-4 py-3 border-b border-white/[0.06]">
<div className="px-4 py-3 border-b border-white/15">
<p className="text-sm font-medium text-white truncate">
{user.display_name || user.username}
</p>
@@ -166,13 +173,11 @@ export function TopBarActions({ className }: { className?: string }) {
<button
onClick={() => setSettingsOpen(true)}
className={twMerge(
active ? "bg-white/[0.04]" : "",
active ? "bg-white/10" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<SettingsIcon
className="text-[11px] text-white/50" />
<SettingsIcon className="text-[11px] text-white/50" />
Settings
</button>
)}
@@ -182,13 +187,11 @@ export function TopBarActions({ className }: { className?: string }) {
<button
onClick={() => navigate("/support")}
className={twMerge(
active ? "bg-white/[0.04]" : "",
active ? "bg-white/10" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<LifeBuoyIcon
className="text-[11px] text-white/50" />
<LifeBuoyIcon className="text-[11px] text-white/50" />
Support
</button>
)}
@@ -201,13 +204,11 @@ export function TopBarActions({ className }: { className?: string }) {
<button
onClick={() => navigate("/referrals")}
className={twMerge(
active ? "bg-white/[0.04]" : "",
active ? "bg-white/10" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<GiftIcon
className="text-[11px] text-white/50" />
<GiftIcon className="text-[11px] text-white/50" />
Referrals
</button>
)}
@@ -220,13 +221,11 @@ export function TopBarActions({ className }: { className?: string }) {
target="_blank"
rel="noopener noreferrer"
className={twMerge(
active ? "bg-white/[0.04]" : "",
active ? "bg-white/10" : "",
"flex w-full items-center gap-2 px-4 py-2 text-sm text-white/70 transition-colors",
)}
>
<DiscordIcon
className="text-[11px] text-white/50" />
<DiscordIcon className="text-[11px] text-white/50" />
Join Discord
</a>
)}
@@ -252,20 +251,20 @@ export function TopBarActions({ className }: { className?: string }) {
<>
<Link
to="/pricing"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-lg text-sm font-medium text-white/85 hover:bg-white/[0.04] transition-colors"
className="hidden lg:flex h-8 items-center gap-1.5 px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:bg-white/10 hover:text-white transition-colors"
>
<GemIcon className="text-[11px]" />
<GemIcon className="text-[11px]" />
Pricing
</Link>
<Link
to="/login"
className="h-8 flex items-center px-3 rounded-lg text-sm font-medium text-white/80 hover:text-white hover:bg-white/[0.04] transition-all"
className="h-8 flex items-center px-3 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/80 hover:text-white hover:bg-white/10 transition-colors"
>
Login
</Link>
<Link
to="/signup"
className="h-8 flex items-center gap-1.5 px-3.5 rounded-full text-sm font-semibold text-black bg-white hover:bg-white/90 transition-all shadow-sm"
className="h-8 flex items-center gap-1.5 px-3.5 rounded-[3px] font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black bg-white hover:bg-white/80 transition-colors"
>
Sign up
</Link>
@@ -297,16 +296,14 @@ function CreditsChip({
<PopoverMenu
position="bottom"
align="end"
triggerIcon={
<CoinsIcon className="text-primary text-[11px]" />
}
triggerIcon={<CoinsIcon className="text-primary text-[11px]" />}
triggerLabel={
<span className="whitespace-nowrap text-sm font-medium">
{credits.toLocaleString()}
</span>
}
buttonClassName="h-8 px-3 ps-2.5 bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] shadow-none text-white/80 rounded-lg gap-1.5"
panelClassName="mt-2 bg-[#1a1a1a] border border-white/[0.08] text-white rounded-xl"
buttonClassName="h-8 px-3 ps-2.5 bg-transparent hover:bg-white/10 border border-white/15 hover:border-white/30 shadow-none text-white/80 rounded-[3px] gap-1.5"
panelClassName="mt-2 bg-[#101014] border border-white/15 text-white rounded-[3px]"
>
{(close) => (
<div className="w-72 max-w-[calc(100vw-24px)] p-3 text-white">
@@ -325,7 +322,7 @@ function CreditsChip({
</button>
</div>
<div className="flex items-center gap-2 text-3xl font-semibold text-white tracking-tight">
<CoinsIcon className="text-xl text-primary" />
<CoinsIcon className="text-xl text-primary" />
{credits.toLocaleString()}
</div>
<div className="mt-3 flex gap-2">
@@ -77,7 +77,7 @@ export function Breadcrumbs() {
if (crumbs.length === 0) return null;
return (
<nav className="hidden md:flex items-center gap-1.5 text-sm text-white/45 min-w-0">
<nav className="hidden md:flex items-center gap-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/45 min-w-0">
{crumbs.map((crumb, i) => {
const isLast = i === crumbs.length - 1;
return (
@@ -1,6 +1,17 @@
import { Tooltip } from "@storyteller/ui-tooltip";
import { PopoverMenu } from "@storyteller/ui-popover";
import { BombIcon, BrushIcon, CheckIcon, CircleAlertIcon, CopyIcon, ListChecksIcon, LoaderCircleIcon, RotateCwIcon, Trash2Icon, XIcon } from "lucide-react";
import {
BombIcon,
BrushIcon,
CheckIcon,
CircleAlertIcon,
CopyIcon,
ListChecksIcon,
LoaderCircleIcon,
RotateCwIcon,
Trash2Icon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Modal } from "@storyteller/ui-modal";
import { useEffect, useMemo, useRef, useState } from "react";
@@ -180,7 +191,7 @@ const CopyPromptButton = ({ prompt }: { prompt: string }) => {
delay={300}
>
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
aria-label="Copy prompt"
onClick={(e) => {
e.stopPropagation();
@@ -220,7 +231,7 @@ const RecreateTaskButton = ({
delay={300}
>
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls disabled:opacity-60"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls disabled:opacity-60"
aria-label="Recreate"
disabled={isRecreating}
onClick={handleRecreate}
@@ -255,13 +266,13 @@ const InProgressCard = ({
: null;
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-ui-controls">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-ui-controls">
<LoaderCircleIcon
className="animate-spin text-base-fg/60"
size="lg" />
size="lg"
/>
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
@@ -285,9 +296,9 @@ const InProgressCard = ({
</div>
)}
<div className="mt-1.5 flex items-center gap-2">
<div className="h-1.5 min-w-0 flex-1 rounded bg-ui-controls">
<div className="h-1.5 min-w-0 flex-1 bg-ui-controls">
<div
className="h-1.5 rounded bg-primary-400"
className="h-1.5 bg-white"
style={{
width: `${Math.max(0, Math.min(100, task.progress))}%`,
}}
@@ -307,7 +318,7 @@ const InProgressCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -334,12 +345,12 @@ const CompletedCard = ({
}) => {
return (
<div
className="flex cursor-pointer items-center gap-2.5 rounded-md p-2 transition-colors hover:bg-ui-controls/40"
className="flex cursor-pointer items-center gap-2.5 p-2 transition-colors hover:bg-ui-controls/40"
onClick={onClick}
role={onClick ? "button" : undefined}
tabIndex={onClick ? 0 : -1}
>
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden rounded bg-ui-controls">
<div className="h-[72px] w-[72px] shrink-0 overflow-hidden bg-ui-controls">
{task.thumbnailUrl ? (
<img
src={task.thumbnailUrl}
@@ -379,7 +390,7 @@ const CompletedCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -403,13 +414,10 @@ const FailedCard = ({
}) => {
const statusLabel = FAILED_STATUS_LABEL[task.status] || "Failed";
return (
<div className="rounded-md p-2 transition-colors hover:bg-ui-controls/40">
<div className="p-2 transition-colors hover:bg-ui-controls/40">
<div className="flex items-center gap-2.5">
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden rounded bg-red-500/10">
<CircleAlertIcon
className="text-red-400"
size="lg" />
<div className="flex h-[72px] w-[72px] shrink-0 items-center justify-center overflow-hidden bg-red-500/10">
<CircleAlertIcon className="text-red-400" size="lg" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between text-sm">
@@ -423,7 +431,7 @@ const FailedCard = ({
</div>
)}
<div className="mt-1 flex min-w-0 items-center gap-1.5 overflow-hidden">
<span className="shrink-0 rounded bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
<span className="shrink-0 bg-red-500/15 px-1.5 py-0 text-[11px] font-medium text-red-400">
{statusLabel}
</span>
{task.failureReason && (
@@ -471,7 +479,7 @@ const FailedCard = ({
{task.prompt && <CopyPromptButton prompt={task.prompt} />}
{onDismiss && (
<button
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 hover:bg-ui-controls"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 hover:bg-ui-controls"
aria-label="Dismiss"
onClick={(e) => {
e.stopPropagation();
@@ -1042,7 +1050,7 @@ export const TaskQueue = () => {
<div>
{inProgress.length > 0 && (
<div className="mb-4">
<div className="mb-1 px-1 text-xs uppercase tracking-wide text-base-fg/50">
<div className="mb-1 px-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
In Progress
</div>
{inProgress.map((t) => (
@@ -1057,14 +1065,14 @@ export const TaskQueue = () => {
{failed.length > 0 && (
<div className="mb-4">
<div className="mb-1 flex items-center justify-between px-1">
<div className="text-xs font-semibold uppercase tracking-wide text-red-400/70">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-red-400/70">
Failed
</div>
<button
className="text-xs tracking-wide text-red-400/70 transition-colors hover:text-red-300"
onClick={() => handleClearFailed()}
>
<XIcon className="mr-1" />
<XIcon className="mr-1" />
Clear failed
</button>
</div>
@@ -1080,14 +1088,14 @@ export const TaskQueue = () => {
{completed.length > 0 && (
<div>
<div className="mb-1 flex items-center justify-between px-1">
<div className="text-xs font-semibold uppercase tracking-wide text-base-fg/50">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
Completed
</div>
<button
className="text-xs tracking-wide text-base-fg/50 transition-colors hover:text-base-fg/100"
onClick={() => handleClearCompleted()}
>
<XIcon className="mr-1" />
<XIcon className="mr-1" />
Clear completed
</button>
</div>
@@ -1122,23 +1130,21 @@ export const TaskQueue = () => {
<Tooltip content="Task Queue" position="bottom" closeOnClick={true}>
<div className="relative task-queue-trigger">
{badgeCount > 0 && (
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center rounded-full bg-primary-400 px-1 text-[10px] font-semibold text-white ring-2 ring-[#121212]">
<div className="absolute -right-1 -top-1 z-20 flex h-[15px] min-w-[15px] items-center justify-center bg-white px-1 font-mono text-[10px] font-semibold text-black ring-2 ring-[#0b0b0c]">
{badgeCount}
</div>
)}
<PopoverMenu
mode="default"
buttonClassName="h-8 w-8 !p-0 relative bg-white/[0.04] hover:bg-white/[0.08] border border-white/[0.08] text-white/80 rounded-lg shadow-none"
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#1a1a1a] border border-white/[0.08] mt-2 rounded-xl"
buttonClassName="h-8 w-8 !p-0 relative bg-transparent hover:bg-white/10 border border-white/15 hover:border-white/30 text-white/80 rounded-[3px] shadow-none"
panelClassName="w-[calc(100vw-5rem)] sm:w-[400px] p-2 bg-[#101014] border border-white/15 mt-2 rounded-[3px]"
position="bottom"
align="end"
triggerIcon={
inProgressCount > 0 ? (
<LoaderCircleIcon
className="animate-spin text-[11px]" />
<LoaderCircleIcon className="animate-spin text-[11px]" />
) : (
<ListChecksIcon className="text-[11px]" />
<ListChecksIcon className="text-[11px]" />
)
}
onOpenChange={handleOpenChange}
@@ -1175,36 +1181,36 @@ export const TaskQueue = () => {
showClose={false}
>
<div className="flex h-full flex-col">
<div className="rounded-t-xl border-ui-panel-border bg-ui-panel">
<div className="border-b border-ui-panel-border bg-ui-panel">
<div className="flex items-center justify-between p-3">
<h2 className="text-lg font-semibold">Task Queue</h2>
<div className="flex items-center gap-2">
<Button
className="rounded-full flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
className="flex h-9 items-center justify-center bg-green-500/10 px-3 text-green-500 hover:bg-green-500/20"
onClick={() => handleClearCompleted()}
>
<BrushIcon className="mr-1.5" />
<BrushIcon className="mr-1.5" />
Clear completed
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
className="flex h-9 items-center justify-center bg-orange-500/10 px-3 text-orange-500 hover:bg-orange-500/20"
onClick={() => handleClearStale()}
>
<Trash2Icon className="mr-1.5" />
<Trash2Icon className="mr-1.5" />
Clear stale
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-400 hover:bg-red-500/20"
onClick={() => handleClearFailed()}
>
<Trash2Icon className="mr-1.5" />
<Trash2Icon className="mr-1.5" />
Clear failed
</Button>
<Button
className="rounded-full flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
className="flex h-9 items-center justify-center bg-red-500/10 px-3 text-red-500 hover:bg-red-500/20"
onClick={() => handleRemoveAll()}
>
<BombIcon className="mr-1.5" />
<BombIcon className="mr-1.5" />
Remove all
</Button>
<div className="mr-2 h-4 w-[1px] bg-base-fg/10" />
@@ -24,7 +24,7 @@ export function TopBar() {
const showSelectToggle = showViewToggle;
return (
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/[0.06] bg-[#121212]/80 backdrop-blur-md px-3 pb-4 pt-3 sm:pt-6">
<header className="sticky top-0 z-20 flex items-center gap-3 border-b border-white/15 bg-ui-background px-3 pb-4 pt-3 sm:pt-6">
{/* Left: sidebar trigger (mobile only) + logo (when sidebar closed) + breadcrumbs */}
<div className="flex items-center gap-2 min-w-0 shrink-0">
<SidebarTrigger className="md:hidden" />
@@ -35,8 +35,8 @@ export function TopBar() {
<img
src={
isMobile
? "/images/artcraft-icon.png"
: "/images/artcraft-logo.png"
? "/images/artcraft-icon-2.png"
: "/images/artcraft-logo-2.png"
}
alt="ArtCraft"
className={isMobile ? "h-5 w-auto" : "h-5 w-auto"}
@@ -53,16 +53,16 @@ const SheetContent = React.forwardRef<
<DialogPrimitive.Content
ref={ref}
className={cn(
"fixed z-50 flex flex-col bg-[#1F1F1F] shadow-lg transition ease-in-out",
"fixed z-50 flex flex-col bg-[#101014] transition ease-in-out",
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:duration-200 data-[state=open]:duration-300",
side === "left" &&
"inset-y-0 left-0 h-full border-r border-white/[0.08] data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
"inset-y-0 left-0 h-full border-r border-white/15 data-[state=open]:slide-in-from-left data-[state=closed]:slide-out-to-left",
side === "right" &&
"inset-y-0 right-0 h-full border-l border-white/[0.08] data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
"inset-y-0 right-0 h-full border-l border-white/15 data-[state=open]:slide-in-from-right data-[state=closed]:slide-out-to-right",
side === "top" &&
"inset-x-0 top-0 border-b border-white/[0.08] data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
"inset-x-0 top-0 border-b border-white/15 data-[state=open]:slide-in-from-top data-[state=closed]:slide-out-to-top",
side === "bottom" &&
"inset-x-0 bottom-0 border-t border-white/[0.08] data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
"inset-x-0 bottom-0 border-t border-white/15 data-[state=open]:slide-in-from-bottom data-[state=closed]:slide-out-to-bottom",
className,
)}
{...props}
@@ -225,7 +225,7 @@ const Sidebar = React.forwardRef<
/>
<div
className={cn(
"fixed inset-y-0 z-30 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex",
"fixed inset-y-0 z-30 hidden h-svh w-[--sidebar-width] transition-[left,right,width] duration-200 ease-linear md:flex bg-background",
side === "left"
? "left-0 group-data-[collapsible=offcanvas]:left-[calc(var(--sidebar-width)*-1)]"
: "right-0 group-data-[collapsible=offcanvas]:right-[calc(var(--sidebar-width)*-1)]",
@@ -238,7 +238,7 @@ const Sidebar = React.forwardRef<
>
<div
data-sidebar="sidebar"
className="flex h-full w-full flex-col bg-sidebar group-data-[variant=floating]:rounded-[20px] group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:backdrop-blur-[7.5px] group-data-[variant=floating]:overflow-hidden group-data-[variant=floating]:shadow-[-20px_68px_20px_0px_rgba(0,0,0,0),-13px_43px_18px_0px_rgba(0,0,0,0.01),-7px_24px_15px_0px_rgba(0,0,0,0.04),-3px_11px_11px_0px_rgba(0,0,0,0.07),-1px_3px_6px_0px_rgba(0,0,0,0.08)]"
className="flex h-full w-full flex-col bg-sidebar rounded-[3px] group-data-[variant=floating]:border group-data-[variant=floating]:border-sidebar-border group-data-[variant=floating]:overflow-hidden"
>
{children}
</div>
@@ -265,7 +265,7 @@ const SidebarTrigger = React.forwardRef<
toggleSidebar();
}}
className={cn(
"inline-flex h-8 w-8 items-center justify-center rounded-lg text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors",
"inline-flex h-8 w-8 items-center justify-center text-white/60 hover:text-white hover:bg-white/[0.06] transition-colors",
className,
)}
{...props}
@@ -314,7 +314,7 @@ const SidebarInset = React.forwardRef<
ref={ref}
className={cn(
"relative flex w-full flex-1 flex-col bg-background",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0 md:peer-data-[variant=inset]:rounded-xl md:peer-data-[variant=inset]:shadow",
"md:peer-data-[variant=inset]:m-2 md:peer-data-[state=collapsed]:peer-data-[variant=inset]:ml-2 md:peer-data-[variant=inset]:ml-0",
className,
)}
{...props}
@@ -388,7 +388,7 @@ const SidebarGroupLabel = React.forwardRef<
ref={ref}
data-sidebar="group-label"
className={cn(
"flex h-8 shrink-0 items-center rounded-md px-2 text-[11px] font-semibold uppercase tracking-wider text-sidebar-foreground/50 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"flex h-8 shrink-0 items-center px-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-sidebar-foreground/50 outline-none ring-sidebar-ring transition-[margin,opacity] duration-200 ease-linear focus-visible:ring-2 [&>svg]:size-4 [&>svg]:shrink-0",
"group-data-[collapsible=icon]:-mt-8 group-data-[collapsible=icon]:opacity-0",
className,
)}
@@ -438,7 +438,7 @@ const SidebarMenuItem = React.forwardRef<
SidebarMenuItem.displayName = "SidebarMenuItem";
const sidebarMenuButtonVariants = cva(
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-md px-2 py-1.5 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-200 ease-out hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
"peer/menu-button flex w-full items-center gap-2 overflow-hidden rounded-[3px] px-2 py-1.5 text-left text-sm outline-none ring-sidebar-ring transition-[width,height,padding,background-color,color] duration-200 ease-out hover:bg-sidebar-accent hover:text-sidebar-accent-foreground focus-visible:ring-2 active:bg-sidebar-accent active:text-sidebar-accent-foreground disabled:pointer-events-none disabled:opacity-50 aria-disabled:pointer-events-none aria-disabled:opacity-50 data-[active=true]:bg-sidebar-accent data-[active=true]:font-medium data-[active=true]:text-sidebar-accent-foreground data-[state=open]:hover:bg-sidebar-accent data-[state=open]:hover:text-sidebar-accent-foreground group-data-[collapsible=icon]:!size-8 group-data-[collapsible=icon]:!p-2 [&>span:last-child]:truncate [&>svg]:size-3.5 [&>svg]:shrink-0",
{
variants: {
variant: {
@@ -15,7 +15,7 @@ const TooltipContent = React.forwardRef<
ref={ref}
sideOffset={sideOffset}
className={cn(
"z-[60] overflow-hidden rounded-md bg-[#1f1f1f] border border-white/[0.08] px-2.5 py-1.5 text-xs text-white shadow-md",
"z-[60] overflow-hidden rounded-[3px] bg-[#101014] border border-white/15 px-2.5 py-1.5 text-xs text-white",
"animate-in fade-in-0 zoom-in-95 data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=closed]:zoom-out-95",
className,
)}
@@ -7,24 +7,19 @@ import Seo from "../../components/seo";
const CheckoutCancel = () => {
return (
<div className="relative min-h-screen overflow-hidden bg-[#101014] text-white">
<div className="relative min-h-screen overflow-hidden bg-ui-background text-white">
<Seo
title="Checkout Cancelled - ArtCraft"
description="Your checkout was cancelled. No payment was made."
/>
{/* Background gradient - subtle/neutral */}
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-gray-500/20 via-gray-600/10 to-gray-700/5 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
{/* Cancel Card */}
<div className="max-w-lg w-full">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
<div className="bg-[#101014] border border-white/15 p-8 md:p-12 text-center">
{/* Cancel Icon */}
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-white/10 flex items-center justify-center">
<div className="w-20 h-20 mx-auto border border-white/15 bg-white/10 flex items-center justify-center">
<CircleXIcon
className="text-5xl text-white/50" />
@@ -41,7 +36,7 @@ const CheckoutCancel = () => {
</p>
{/* Info Box */}
<div className="bg-[#252529] rounded-2xl p-5 mb-8 text-left">
<div className="bg-ui-controls border border-white/15 p-5 mb-8 text-left">
<p className="text-white/70 text-sm">
<span className="text-white font-medium">
Changed your mind?
@@ -56,14 +51,14 @@ const CheckoutCancel = () => {
<Button
as="link"
href="/pricing"
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
className="px-8 py-3 justify-center"
>
View Plans Again
</Button>
</div>
{/* Discord CTA */}
<div className="mt-8 pt-6 border-t border-white/10 flex flex-col gap-2 items-center">
<div className="mt-8 pt-6 border-t border-white/15 flex flex-col gap-2 items-center">
<div className="flex items-center justify-center gap-2 mb-2">
<span className="text-white/70 text-sm">
Need help deciding?
@@ -73,7 +68,7 @@ const CheckoutCancel = () => {
as="link"
href={SOCIAL_LINKS.DISCORD}
target="_blank"
className="rounded-full bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
className="bg-white text-black hover:bg-white/80 px-4 py-2 justify-center border-transparent"
>
<DiscordIcon />
Join Discord
@@ -1,4 +1,14 @@
import { ArrowRightIcon, BoxIcon, CircleCheckIcon, DownloadIcon, ImageIcon, MonitorIcon, RocketIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import {
ArrowRightIcon,
BoxIcon,
CircleCheckIcon,
DownloadIcon,
ImageIcon,
MonitorIcon,
RocketIcon,
VideoIcon,
WandSparklesIcon,
} from "lucide-react";
import { DynamicIcon, DiscordIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { Link, useSearchParams } from "react-router-dom";
@@ -59,35 +69,27 @@ const CheckoutSuccess = () => {
if (isCheckingOnboarding) {
return (
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
<div className="relative min-h-screen bg-ui-background text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
</div>
);
}
return (
<div className="relative min-h-full bg-[#101014] text-white">
<div className="relative min-h-full bg-ui-background text-white">
<Seo
title="Payment Successful - ArtCraft"
description="Your ArtCraft subscription is now active. Download and start creating!"
/>
{/* Background gradient — clipped to its own wrapper so the outer
page can grow naturally and let SidebarInset handle scrolling. */}
<div className="absolute inset-0 overflow-hidden flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
{/* Success Card */}
<div className="max-w-4xl w-full">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-12 text-center">
<div className="bg-[#101014] border border-white/15 p-8 md:p-12 text-center">
{/* Success Icon */}
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
<CircleCheckIcon
className="text-5xl text-green-400" />
<div className="w-20 h-20 mx-auto border border-white/15 bg-green-500/10 flex items-center justify-center">
<CircleCheckIcon className="text-5xl text-green-400" />
</div>
</div>
@@ -102,12 +104,10 @@ const CheckoutSuccess = () => {
</p>
{/* Start creating in the browser */}
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
<div className="bg-ui-controls border border-white/15 p-6 mb-8 text-left">
<div className="flex items-center gap-3 mb-2">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<WandSparklesIcon
className="text-primary text-sm" />
<div className="w-8 h-8 bg-white/10 flex items-center justify-center">
<WandSparklesIcon className="text-white/70 text-sm" />
</div>
<h2 className="text-lg font-medium text-white">
Start creating right here
@@ -130,30 +130,26 @@ const CheckoutSuccess = () => {
<Link
key={item.to}
to={item.to}
className="group flex items-center gap-3 bg-[#1A1A1E] hover:bg-[#222227] border border-white/[0.06] hover:border-white/[0.12] rounded-xl px-4 py-3 transition-all"
className="group flex items-center gap-3 rounded-[3px] bg-ui-background hover:bg-white/10 border border-white/15 hover:border-white/30 px-4 py-3 transition-all"
>
<DynamicIcon
icon={item.icon}
className="text-primary text-base shrink-0"
className="text-white/70 text-base shrink-0"
/>
<span className="text-white/90 font-medium flex-1">
{item.label}
</span>
<ArrowRightIcon
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
<ArrowRightIcon className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
</Link>
))}
</div>
</div>
{/* Next Steps */}
<div className="bg-[#252529] rounded-2xl p-6 mb-8 text-left">
<div className="bg-ui-controls border border-white/15 p-6 mb-8 text-left">
<div className="flex items-center gap-3 mb-4">
<div className="w-8 h-8 rounded-full bg-primary/20 flex items-center justify-center">
<RocketIcon
className="text-primary text-sm" />
<div className="w-8 h-8 bg-white/10 flex items-center justify-center">
<RocketIcon className="text-white/70 text-sm" />
</div>
<h2 className="text-lg font-medium text-white">Next Steps</h2>
</div>
@@ -166,7 +162,7 @@ const CheckoutSuccess = () => {
"Start creating with all premium features!",
].map((step, idx) => (
<div key={idx} className="flex items-start gap-3">
<div className="w-6 h-6 shrink-0 rounded-full bg-primary/50 flex items-center justify-center text-xs font-bold text-white mt-0.5">
<div className="w-6 h-6 shrink-0 bg-white/10 border border-white/15 flex items-center justify-center font-mono text-[11px] font-semibold text-white mt-0.5">
{idx + 1}
</div>
<span className="text-white/80">{step}</span>
@@ -181,16 +177,16 @@ const CheckoutSuccess = () => {
<Button
as="link"
href={downloadUrl}
className="rounded-full bg-primary hover:bg-primary-600 px-8 py-3 text-sm font-bold rounded-xl justify-center"
className="px-8 py-3 justify-center"
>
<DownloadIcon className="mr-2" />
<DownloadIcon className="mr-2" />
Download ArtCraft
</Button>
</div>
) : (
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
<div className="bg-[#431407] border border-orange-900/50 p-6 text-orange-200 text-sm leading-relaxed">
<div className="flex items-center justify-center mb-3 text-orange-400">
<MonitorIcon className="text-2xl" />
<MonitorIcon className="text-2xl" />
</div>
ArtCraft is a desktop application. <br />
Please head to your computer to download and start creating.
@@ -204,21 +200,21 @@ const CheckoutSuccess = () => {
href={DOWNLOAD_LINKS.WINDOWS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
<span className="w-1.5 h-1.5 bg-current"></span>
Windows
</a>
<a
href={DOWNLOAD_LINKS.MACOS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
<span className="w-1.5 h-1.5 bg-current"></span>
Mac
</a>
</div>
)}
{/* Discord CTA */}
<div className="mt-8 pt-6 border-t border-white/10 flex flex-col gap-2 items-center">
<div className="mt-8 pt-6 border-t border-white/15 flex flex-col gap-2 items-center">
<div className="flex items-center justify-center gap-2 mb-2">
<span className="text-white/70 text-sm">
Join our community
@@ -228,7 +224,7 @@ const CheckoutSuccess = () => {
as="link"
href={SOCIAL_LINKS.DISCORD}
target="_blank"
className="rounded-full bg-white text-black hover:bg-white/80 px-4 py-2 text-sm font-bold rounded-xl justify-center border-transparent"
className="bg-white text-black hover:bg-white/80 px-4 py-2 justify-center border-transparent"
>
<DiscordIcon />
Join Discord
@@ -77,6 +77,16 @@ const AUDIO_REF_MAX_DURATION_SECONDS = 600;
const supports = (flag: boolean | null | undefined): boolean => flag === true;
// The lib ToggleButton still carries the old glassy look (2px transparent
// border, grey translucent blur). These overrides align the audio toggles
// with their sibling toolbar controls (the PopoverMenu triggers): flat
// ui-controls surface, 1px hairline border, 4px corners. Active keeps the
// brand tint as the on-state affordance.
const toolbarToggleClassName = (isActive: boolean): string =>
isActive
? "rounded-[3px] border border-ui-controls-border backdrop-blur-none hover:border-ui-controls-border"
: "rounded-[3px] border border-ui-controls-border bg-ui-controls backdrop-blur-none hover:bg-ui-controls/80";
// Store API model data alongside popover items via a lookup map
let _modelLookup = new Map<string, OmniGenAudioModelDetails>();
@@ -490,6 +500,7 @@ export default function CreateAudio() {
icon={MicOffIcon}
activeIcon={MicOffIcon}
label="Instrumental"
className={toolbarToggleClassName(ui.isInstrumental)}
onClick={() => setUi({ isInstrumental: !ui.isInstrumental })}
/>
</Tooltip>
@@ -506,6 +517,7 @@ export default function CreateAudio() {
icon={MicIcon}
activeIcon={MicIcon}
label="Keep lyrics"
className={toolbarToggleClassName(ui.keepLyrics)}
onClick={() => setUi({ keepLyrics: !ui.keepLyrics })}
/>
</Tooltip>
@@ -522,6 +534,7 @@ export default function CreateAudio() {
icon={RepeatIcon}
activeIcon={RepeatIcon}
label="Loop"
className={toolbarToggleClassName(ui.isLoopable)}
onClick={() => setUi({ isLoopable: !ui.isLoopable })}
/>
</Tooltip>
@@ -630,7 +643,7 @@ export default function CreateAudio() {
settingsFields={
<>
{styleSupported && (
<div className="rounded-xl border border-ui-panel-border bg-ui-controls px-3 py-1.5">
<div className="rounded-[3px] border border-ui-panel-border bg-ui-controls px-3 py-1.5">
<StylePromptRow
value={ui.stylePrompt}
onChange={setStylePrompt}
@@ -774,7 +787,7 @@ export default function CreateAudio() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -953,7 +966,7 @@ function MobileSliderRow({
<button
type="button"
onClick={onReset}
className="rounded bg-white/5 px-2 py-0.5 text-xs font-medium text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg"
className="bg-white/5 px-2 py-0.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/60 transition-colors hover:bg-white/10 hover:text-base-fg"
>
Auto
</button>
@@ -37,7 +37,6 @@ export const AspectRatioIcon = ({
y={y}
width={w}
height={h}
rx={1.5}
stroke="currentColor"
strokeWidth={1.5}
/>
@@ -680,7 +680,7 @@ export default function CreateImage() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -2,7 +2,11 @@ import { useRef, useState } from "react";
import { BoxIcon, ImageIcon, LoaderCircleIcon, XIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { AddButton, type RefImage } from "../../../components/prompt-box";
import { MESH_FILE_ACCEPT, uploadMeshFile, uploadViewImage } from "./mesh-upload";
import {
MESH_FILE_ACCEPT,
uploadMeshFile,
uploadViewImage,
} from "./mesh-upload";
// Multi-view side inputs (front/back/left/right) and the mesh-to-mesh input
// file — the mobile form band. The desktop prompt box renders the same store
@@ -53,59 +57,61 @@ export function MeshInputsRow({
return (
// Title on the left, upload slots right-aligned + top-aligned (matches
// ImagePromptRow).
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2">
<div className="glass flex items-start gap-3 px-3 py-2">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<DynamicIcon icon={titleIcon} className="h-3.5 w-3.5" />
<span className="text-sm font-medium">{title}</span>
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
{title}
</span>
</div>
<span className="text-[13px] text-white/60">{subtitle}</span>
</div>
<div className="flex flex-wrap items-end justify-end gap-3">
{showMultiView && (
<>
<ImageSlot
label="Front"
image={frontImage}
onChange={(img) => onChange({ frontImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("front")
: undefined
}
/>
<ImageSlot
label="Back"
image={backImage}
onChange={(img) => onChange({ backImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("back")
: undefined
}
/>
<ImageSlot
label="Left"
image={leftImage}
onChange={(img) => onChange({ leftImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("left")
: undefined
}
/>
<ImageSlot
label="Right"
image={rightImage}
onChange={(img) => onChange({ rightImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("right")
: undefined
}
/>
</>
)}
<>
<ImageSlot
label="Front"
image={frontImage}
onChange={(img) => onChange({ frontImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("front")
: undefined
}
/>
<ImageSlot
label="Back"
image={backImage}
onChange={(img) => onChange({ backImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("back")
: undefined
}
/>
<ImageSlot
label="Left"
image={leftImage}
onChange={(img) => onChange({ leftImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("left")
: undefined
}
/>
<ImageSlot
label="Right"
image={rightImage}
onChange={(img) => onChange({ rightImage: img })}
onPickFromLibrary={
onPickSlotFromLibrary
? () => onPickSlotFromLibrary("right")
: undefined
}
/>
</>
)}
{showMeshInput && (
<MeshFileSlot
mesh={inputMesh}
@@ -120,7 +126,7 @@ export function MeshInputsRow({
// ── Slots ──────────────────────────────────────────────────────────────────
const SLOT_CLASS =
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
function SlotShell({
label,
@@ -132,7 +138,9 @@ function SlotShell({
return (
<div className="flex flex-col items-center gap-1">
{children}
<span className="text-[11px] text-white/60">{label}</span>
<span className="font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/60">
{label}
</span>
</div>
);
}
@@ -171,7 +179,7 @@ function ImageSlot({
onChange={handleUpload}
/>
{image ? (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
<div className="group relative aspect-square w-14 overflow-hidden rounded-[3px] border border-white/30">
<img
src={image.url}
alt={`${label} view`}
@@ -179,17 +187,14 @@ function ImageSlot({
/>
<button
onClick={() => onChange(undefined)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
<div className={SLOT_CLASS}>
<LoaderCircleIcon
spin
className="h-5 w-5 text-white" />
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white" />
</div>
) : (
<AddButton
@@ -231,28 +236,25 @@ function MeshFileSlot({
onChange={handleUpload}
/>
{mesh ? (
<div className="group relative flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-white/30 bg-white/10">
<BoxIcon className="h-5 w-5 text-white/80" />
<div className="group relative flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-white/30 bg-white/10">
<BoxIcon className="h-5 w-5 text-white/80" />
<button
onClick={() => onChange(undefined)}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
<div className={SLOT_CLASS}>
<LoaderCircleIcon
spin
className="h-5 w-5 text-white" />
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white" />
</div>
) : (
<button
onClick={() => inputRef.current?.click()}
className={SLOT_CLASS}
>
<BoxIcon className="text-xl text-white/80" />
<BoxIcon className="text-xl text-white/80" />
</button>
)}
</SlotShell>
@@ -625,7 +625,7 @@ export default function CreateObject() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -247,16 +247,16 @@ export default function CreateVFX() {
if (!authChecked) {
return (
<div className="flex h-screen items-center justify-center bg-[#101014]">
<div className="flex h-screen items-center justify-center bg-ui-background">
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
</div>
);
}
return (
<div className="relative h-full w-full bg-[#101014] text-white">
<div className="relative h-full w-full bg-ui-background text-white">
<Seo
title="Background Change - ArtCraft"
description="Swap the backdrop of a video using a reference image."
@@ -304,7 +304,7 @@ export default function CreateVFX() {
<div
aria-hidden
className="pointer-events-none fixed bottom-0 right-0 z-20 h-72 bg-gradient-to-t from-[#101014] via-[#101014]/85 to-transparent transition-[left] duration-200 ease-linear"
className="pointer-events-none fixed bottom-0 right-0 z-20 h-72 bg-gradient-to-t from-ui-background via-ui-background/85 to-transparent transition-[left] duration-200 ease-linear"
style={{ left: "var(--ac-sidebar-offset, 0px)" }}
/>
@@ -338,7 +338,7 @@ interface EmptyStateProps {
const EmptyState = ({ title, subtitle }: EmptyStateProps) => (
<div className="flex max-w-md flex-col items-center gap-4 text-center">
<div className="flex h-12 w-12 items-center justify-center rounded-2xl bg-white/5 ring-1 ring-white/10">
<div className="flex h-12 w-12 items-center justify-center border border-white/15 bg-white/5">
<SparklesIcon className="text-2xl text-white/40" />
</div>
<h3 className="text-2xl font-bold text-white">{title}</h3>
@@ -1,7 +1,11 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { flushSync } from "react-dom";
import { AudioLinesIcon, ClockIcon, InfoIcon, SparklesIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import {
AudioLinesIcon,
ClockIcon,
InfoIcon,
SparklesIcon,
} from "lucide-react";
import { CharactersApi, FilterMediaClasses } from "@storyteller/api";
import type { OmniGenVideoModelInfo } from "@storyteller/api";
import { Button, ToggleButton } from "@storyteller/ui-button";
@@ -1487,10 +1491,8 @@ export default function CreateVideo() {
autoAdvance={loggedIn && !!prompt.trim() && !isGenerating && !needsImage}
banner={
requiresImageInput ? (
<div className="flex items-start gap-2.5 rounded-lg border border-amber-500/40 bg-amber-900/80 px-3.5 py-2.5 text-xs text-amber-100">
<InfoIcon
className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<div className="flex items-start gap-2.5 border border-amber-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-amber-100">
<InfoIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<span>
This model can&apos;t generate from text alone — add a starting
frame to animate your prompt.
@@ -1506,13 +1508,13 @@ export default function CreateVideo() {
key={item.label}
type="button"
onClick={() => handleInputModeChange(item)}
className={`rounded-xl border px-3 py-2.5 text-left transition-colors ${
className={`border px-3 py-2.5 text-left transition-colors ${
item.selected
? "border-primary bg-primary/15"
? "border-white bg-white/10"
: "border-ui-panel-border bg-ui-controls hover:bg-ui-controls/80"
}`}
>
<span className="block text-sm font-semibold text-base-fg">
<span className="block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
{item.label}
</span>
<span className="block text-xs text-base-fg/55">
@@ -1566,9 +1568,9 @@ export default function CreateVideo() {
/>
)}
{hasSound && (
<div className="flex items-center justify-between rounded-xl border border-ui-panel-border bg-ui-controls px-3.5 py-2.5">
<div className="flex items-center justify-between rounded-[3px] border border-ui-panel-border bg-ui-controls px-3.5 py-2.5">
<span className="flex items-center gap-2 text-sm font-medium text-base-fg/70">
<AudioLinesIcon className="h-4 w-4" />
<AudioLinesIcon className="h-4 w-4" />
Audio
</span>
<ToggleButton
@@ -1584,7 +1586,7 @@ export default function CreateVideo() {
<>
<MobileFieldButton
label="Output"
icon={<ClockIcon className="h-4 w-4" />}
icon={<ClockIcon className="h-4 w-4" />}
value={outputSummary}
onClick={() => setIsOutputDrawerOpen(true)}
/>
@@ -1653,7 +1655,7 @@ export default function CreateVideo() {
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-11 items-center justify-center gap-1 rounded-xl border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg transition-all hover:bg-ui-controls/80 active:scale-95"
className="flex h-11 items-center justify-center gap-1 border border-ui-controls-border bg-ui-controls px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
>
@Characters
</button>
@@ -1664,14 +1666,6 @@ export default function CreateVideo() {
// ── Render ────────────────────────────────────────────────────────────
const videoGlowOrbs = (
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.12] blur-[120px] transform-gpu" />
<div className="absolute bottom-[-15%] left-[-10%] h-[500px] w-[500px] rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-purple-600 opacity-[0.08] blur-[120px] transform-gpu" />
<div className="absolute bottom-[10%] right-[-10%] h-[400px] w-[400px] rounded-full bg-gradient-to-br from-blue-600 to-pink-500 opacity-[0.06] blur-[140px] transform-gpu" />
</div>
);
return (
<CreateMediaPageShell
title="Create Video - ArtCraft"
@@ -1688,7 +1682,7 @@ export default function CreateVideo() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -1697,7 +1691,6 @@ export default function CreateVideo() {
bottomOffset={promptHeight + 24}
modelItems={modelItems}
onModelChange={handleModelChange}
glowOrbs={videoGlowOrbs}
promptForm={mobileForm}
gridContent={
<GenerationGallery
@@ -1726,7 +1719,7 @@ export default function CreateVideo() {
}}
>
{/* {selectedModel?.model === "seedance_2p0" && (
<div className="mb-2 flex items-start gap-2.5 rounded-lg border border-yellow-500/40 px-3.5 py-2.5 text-xs text-yellow-200 shadow-lg backdrop-blur-xl bg-yellow-800/60">
<div className="mb-2 flex items-start gap-2.5 border border-yellow-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-yellow-200">
<TriangleAlertIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-yellow-400" />
<span>
Seedance 2.0 is in Early Alpha. Generations may be slow and may experience outages.
@@ -1735,10 +1728,8 @@ export default function CreateVideo() {
</div>
)} */}
{requiresImageInput && (
<div className="mb-2 flex items-start gap-2.5 rounded-lg border border-amber-500/40 bg-amber-900/80 px-3.5 py-2.5 text-xs text-amber-100 shadow-lg backdrop-blur-sm">
<InfoIcon
className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<div className="mb-2 flex items-start gap-2.5 border border-amber-500/40 bg-ui-panel px-3.5 py-2.5 text-xs text-amber-100">
<InfoIcon className="mt-0.5 h-3.5 w-3.5 flex-shrink-0 text-amber-400" />
<span>
This model can&apos;t generate from text alone — add a starting
frame to animate your prompt.
@@ -1900,11 +1891,7 @@ export default function CreateVideo() {
<PopoverMenu
mode="default"
panelTitle="Duration"
triggerIcon={
<ClockIcon
className="h-3.5 w-3.5" />
}
triggerIcon={<ClockIcon className="h-3.5 w-3.5" />}
triggerLabel={`${effectiveDuration}s`}
>
<div className="w-[min(16rem,calc(100vw-2rem))] pb-0.5">
@@ -1948,7 +1935,7 @@ export default function CreateVideo() {
}
className={
generateWithSound
? "bg-primary/40 hover:bg-primary/50 border-primary/30"
? "bg-white/10 hover:bg-white/15 border-white/40"
: undefined
}
/>
@@ -1973,7 +1960,7 @@ export default function CreateVideo() {
<button
type="button"
onClick={() => setIsCharactersModalOpen(true)}
className="flex h-9 items-center justify-center gap-1 rounded-lg border border-ui-controls-border bg-ui-controls px-3 text-sm font-medium text-base-fg shadow-sm transition-all duration-150 hover:bg-ui-controls/80 active:scale-95"
className="flex h-9 items-center justify-center gap-1 border border-ui-controls-border bg-ui-controls px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors duration-150 hover:bg-ui-controls/80"
>
@Characters
</button>
@@ -11,7 +11,7 @@ import type { RefVideo } from "../../../components/prompt-box";
// the desktop prompt box renders the same ref in its reference deck).
const SLOT_CLASS =
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-lg border-2 border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
"flex aspect-square w-14 items-center justify-center overflow-hidden rounded-[3px] border border-dashed border-white/25 bg-white/5 transition-all hover:border-white/40 hover:bg-white/10";
export function ReferenceVideoSlot({
video,
@@ -62,13 +62,17 @@ export function ReferenceVideoSlot({
return (
// Title on the left, upload slot right-aligned + top-aligned (matches
// ImagePromptRow).
<div className="glass flex items-start gap-3 rounded-2xl px-3 py-2">
<div className="glass flex items-start gap-3 px-3 py-2">
<div className="flex grow flex-col gap-1 min-w-32">
<div className="flex items-center gap-2 text-white/90">
<VideoIcon className="h-3.5 w-3.5" />
<span className="text-sm font-medium">Reference video</span>
<VideoIcon className="h-3.5 w-3.5" />
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Reference video
</span>
</div>
<span className="text-[13px] text-white/60">Guide the world (optional)</span>
<span className="text-[13px] text-white/60">
Guide the world (optional)
</span>
</div>
<input
type="file"
@@ -78,7 +82,7 @@ export function ReferenceVideoSlot({
onChange={handleUpload}
/>
{video ? (
<div className="group relative aspect-square w-14 overflow-hidden rounded-lg border-2 border-white/30">
<div className="group relative aspect-square w-14 overflow-hidden rounded-[3px] border border-white/30">
<video
src={video.url}
muted
@@ -87,9 +91,9 @@ export function ReferenceVideoSlot({
/>
<button
onClick={handleRemove}
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center rounded-full bg-black/50 text-white backdrop-blur-md transition-colors hover:bg-black"
className="absolute right-[2px] top-[2px] flex h-5 w-5 items-center justify-center bg-black/50 text-white transition-colors hover:bg-black"
>
<XIcon className="h-2.5 w-2.5" />
<XIcon className="h-2.5 w-2.5" />
</button>
</div>
) : uploading ? (
@@ -97,8 +101,11 @@ export function ReferenceVideoSlot({
<LoaderCircleIcon className="h-5 w-5 animate-spin text-white" />
</div>
) : (
<button onClick={() => inputRef.current?.click()} className={SLOT_CLASS}>
<PlusIcon className="text-xl text-white/80" />
<button
onClick={() => inputRef.current?.click()}
className={SLOT_CLASS}
>
<PlusIcon className="text-xl text-white/80" />
</button>
)}
</div>
@@ -478,7 +478,7 @@ export default function CreateWorld() {
variant="primary"
onClick={openSignupCta}
icon={SparklesIcon}
className="h-12 px-6 text-base font-semibold rounded-full"
className="h-12 px-6"
>
Sign up to create
</Button>
@@ -41,22 +41,14 @@ const ForgotPassword = () => {
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<Seo
title="Reset Password - ArtCraft"
description="Reset your ArtCraft password."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
{!submitted ? (
<>
<div className="text-center mb-8">
@@ -80,13 +72,13 @@ const ForgotPassword = () => {
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Email or Username
</label>
<Input
@@ -95,21 +87,19 @@ const ForgotPassword = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</div>
<div className="pt-2">
<Button
id="send-reset-btn"
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Send Reset Code"
)}
@@ -119,8 +109,8 @@ const ForgotPassword = () => {
</>
) : (
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<MailIcon className="text-2xl" />
<div className="w-16 h-16 border border-white/15 bg-green-500/10 flex items-center justify-center mx-auto mb-4 text-green-500">
<MailIcon className="text-2xl" />
</div>
<h3 className="text-xl font-medium mb-2">Check your email</h3>
<p className="text-white/60 text-sm mb-6">
@@ -128,12 +118,12 @@ const ForgotPassword = () => {
<span className="text-white font-medium">{email}</span>
</p>
<Link to="/forgot-password/verify">
<Button className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10 mb-3">
<Button className="w-full justify-center h-10 mb-3">
Enter Verification Code
</Button>
</Link>
<Button
className="rounded-full w-full bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10"
className="w-full bg-transparent border border-white/15 hover:bg-white/10 text-white justify-center h-10"
onClick={() => setSubmitted(false)}
>
Try another email
@@ -1,4 +1,10 @@
import { ArrowLeftIcon, CircleCheckIcon, EyeIcon, EyeOffIcon, LoaderCircleIcon } from "lucide-react";
import {
ArrowLeftIcon,
CircleCheckIcon,
EyeIcon,
EyeOffIcon,
LoaderCircleIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { Input } from "@storyteller/ui-input";
@@ -96,22 +102,14 @@ const VerifyReset = () => {
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<Seo
title="Verify Password Reset - ArtCraft"
description="Enter your verification code and new password."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
{!success ? (
<>
<div className="text-center mb-8">
@@ -137,14 +135,14 @@ const VerifyReset = () => {
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
{/* Verification Code */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Verification Code
</label>
<Input
@@ -154,7 +152,7 @@ const VerifyReset = () => {
onChange={(e) => setVerificationCode(e.target.value)}
placeholder="Enter verification code"
isError={!!fieldErrors.verificationCode}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
{fieldErrors.verificationCode && (
<p className="text-red-400 text-xs ml-1">
@@ -165,7 +163,7 @@ const VerifyReset = () => {
{/* New Password */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
New Password
</label>
<div className="relative">
@@ -176,7 +174,7 @@ const VerifyReset = () => {
onChange={(e) => setNewPassword(e.target.value)}
placeholder="Enter new password"
isError={!!fieldErrors.newPassword}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -197,7 +195,7 @@ const VerifyReset = () => {
{/* Verify New Password */}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Verify New Password
</label>
<div className="relative">
@@ -208,7 +206,7 @@ const VerifyReset = () => {
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Enter new password again"
isError={!!fieldErrors.confirmPassword}
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -232,14 +230,12 @@ const VerifyReset = () => {
<div className="pt-2">
<Button
id="change-password-btn"
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Change Password"
)}
@@ -259,8 +255,8 @@ const VerifyReset = () => {
) : (
<>
<div className="text-center py-8">
<div className="w-16 h-16 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-4 text-green-500">
<CircleCheckIcon className="text-2xl" />
<div className="w-16 h-16 border border-white/15 bg-green-500/10 flex items-center justify-center mx-auto mb-4 text-green-500">
<CircleCheckIcon className="text-2xl" />
</div>
<h3 className="text-xl font-medium mb-2">
Password Reset Successfully
@@ -271,7 +267,7 @@ const VerifyReset = () => {
</p>
<Button
id="back-to-homepage-btn"
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
onClick={() => navigate(redirectTo)}
>
{redirectLabel}
@@ -43,9 +43,9 @@ export const ExtractionPanel = ({
const burstAvailable = Number.isFinite(duration) && duration > 0;
return (
<div className="flex h-fit flex-col divide-y divide-ui-divider rounded-xl border border-ui-panel-border bg-ui-panel">
<div className="flex h-fit flex-col divide-y divide-ui-divider border border-ui-panel-border bg-ui-panel">
<div className="space-y-3 p-4">
<div className="flex items-center justify-between text-[11px] font-semibold uppercase tracking-wider text-base-fg/50">
<div className="flex items-center justify-between font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg/50">
<span>Playhead</span>
<span className="font-mono text-xs normal-case tracking-normal text-base-fg/80">
{disabled ? "--:--.---" : formatTimePrecise(currentTime)}
@@ -56,7 +56,7 @@ export const ExtractionPanel = ({
icon={CameraIcon}
onClick={onCaptureCurrent}
disabled={disabled || isExtracting}
className="w-full py-2 text-sm font-medium"
className="w-full py-2"
>
Capture frame
</Button>
@@ -72,14 +72,12 @@ export const ExtractionPanel = ({
className="flex w-full items-center justify-between px-4 py-3 text-sm font-medium text-base-fg/75 transition-colors hover:text-base-fg disabled:cursor-default disabled:opacity-40"
>
<span className="flex items-center gap-2">
<LayersIcon
className="text-xs text-base-fg/40" />
<LayersIcon className="text-xs text-base-fg/40" />
Burst capture
</span>
<ChevronDownIcon
className={`text-xs text-base-fg/40 transition-transform ${burstOpen ? "rotate-180" : ""}`} />
className={`text-xs text-base-fg/40 transition-transform ${burstOpen ? "rotate-180" : ""}`}
/>
</button>
<AnimatePresence initial={false}>
{burstOpen && !disabled && burstAvailable && (
@@ -108,10 +106,13 @@ export const ExtractionPanel = ({
disabled={isExtracting}
onChange={(e) =>
onNumFramesChange(
Math.max(1, Math.min(50, parseInt(e.target.value) || 1)),
Math.max(
1,
Math.min(50, parseInt(e.target.value) || 1),
),
)
}
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
className="w-full border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-white/40 focus:outline-none"
/>
</label>
<label className="block">
@@ -126,18 +127,21 @@ export const ExtractionPanel = ({
disabled={isExtracting}
onChange={(e) =>
onSpacingChange(
Math.max(1, Math.min(10000, parseInt(e.target.value) || 1)),
Math.max(
1,
Math.min(10000, parseInt(e.target.value) || 1),
),
)
}
className="w-full rounded-md border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-primary focus:outline-none"
className="w-full border border-ui-controls-border bg-ui-controls px-2.5 py-1.5 text-sm text-base-fg focus:border-white/40 focus:outline-none"
/>
</label>
</div>
{isExtracting && progress ? (
<div className="flex items-center gap-2">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/10">
<div className="h-1.5 flex-1 overflow-hidden bg-white/10">
<div
className="h-full rounded-full bg-primary transition-all"
className="h-full bg-white transition-all"
style={{
width: `${(progress.done / progress.total) * 100}%`,
}}
@@ -148,10 +152,10 @@ export const ExtractionPanel = ({
</span>
<button
onClick={onCancel}
className="flex h-6 w-6 items-center justify-center rounded-md text-base-fg/50 transition-colors hover:bg-white/10 hover:text-base-fg"
className="flex h-6 w-6 items-center justify-center text-base-fg/50 transition-colors hover:bg-white/10 hover:text-base-fg"
aria-label="Cancel extraction"
>
<XIcon className="text-xs" />
<XIcon className="text-xs" />
</button>
</div>
) : (
@@ -159,7 +163,7 @@ export const ExtractionPanel = ({
variant="action"
onClick={onExtractBurst}
disabled={isExtracting}
className="w-full py-1.5 text-sm font-medium"
className="w-full py-1.5"
>
Extract {numFrames} {numFrames === 1 ? "frame" : "frames"}
</Button>
@@ -174,13 +178,17 @@ export const ExtractionPanel = ({
<div className="flex items-center justify-between">
<span>Duration</span>
<span className="font-mono text-base-fg/70">
{!disabled && Number.isFinite(duration) ? formatTime(duration) : "—"}
{!disabled && Number.isFinite(duration)
? formatTime(duration)
: "—"}
</span>
</div>
<div className="flex items-center justify-between">
<span>Resolution</span>
<span className="font-mono text-base-fg/70">
{!disabled && resolution ? `${resolution.w} × ${resolution.h}` : "—"}
{!disabled && resolution
? `${resolution.w} × ${resolution.h}`
: "—"}
</span>
</div>
</div>
@@ -436,7 +436,7 @@ export default function FrameExtractor() {
// ── Render ─────────────────────────────────────────────────────────────────
return (
<div className="relative min-h-full w-full shrink-0 bg-[#101014] px-3 pb-8 sm:px-4 md:px-8 lg:px-12">
<div className="relative min-h-full w-full shrink-0 bg-ui-background px-3 pb-8 sm:px-4 md:px-8 lg:px-12">
<Seo
title="Video Frame Extractor - ArtCraft"
description="Grab a still frame from any video — capture, save to your library, or use it as an image prompt reference."
@@ -452,7 +452,7 @@ export default function FrameExtractor() {
variant="action"
icon={RotateCwIcon}
onClick={clearSource}
className="px-3 py-1.5 text-sm"
className="px-3 py-1.5"
>
Switch Video
</Button>
@@ -43,7 +43,7 @@ export const FramesGrid = ({
const hasUnsaved = frames.some((frame) => !actionState[frame.id]?.saved);
return (
<div className="rounded-xl border border-ui-panel-border bg-ui-panel p-4">
<div className="border border-ui-panel-border bg-ui-panel p-4">
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
<h3 className="text-sm font-semibold text-base-fg">
Captured frames
@@ -56,7 +56,7 @@ export const FramesGrid = ({
<button
onClick={onSaveAll}
disabled={isSavingAll}
className="flex items-center gap-1.5 rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/70 transition-colors hover:bg-white/5 hover:text-base-fg disabled:opacity-50"
className="flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-base-fg/70 transition-colors hover:bg-white/5 hover:text-base-fg disabled:opacity-50"
>
<DynamicIcon
icon={isSavingAll ? LoaderCircleIcon : SaveIcon}
@@ -67,7 +67,7 @@ export const FramesGrid = ({
)}
<button
onClick={onClear}
className="rounded-md px-2.5 py-1.5 text-xs font-medium text-base-fg/50 transition-colors hover:bg-white/5 hover:text-base-fg"
className="px-2.5 py-1.5 text-xs font-medium text-base-fg/50 transition-colors hover:bg-white/5 hover:text-base-fg"
>
Clear
</button>
@@ -86,7 +86,7 @@ export const FramesGrid = ({
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: 0.18, ease: EASE_EMPHASIS }}
className="group/frame overflow-hidden rounded-lg border border-ui-panel-border bg-ui-background"
className="group/frame overflow-hidden border border-ui-panel-border bg-ui-background"
>
<div className="relative aspect-video bg-black">
<img
@@ -96,12 +96,12 @@ export const FramesGrid = ({
/>
<button
onClick={() => onRemove(frame)}
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center rounded-md bg-black/60 text-white/60 opacity-0 backdrop-blur-sm transition-all hover:text-white group-hover/frame:opacity-100"
className="absolute right-1.5 top-1.5 flex h-6 w-6 items-center justify-center bg-black/60 text-white/60 opacity-0 transition-all hover:text-white group-hover/frame:opacity-100"
aria-label="Remove frame"
>
<XIcon className="text-xs" />
</button>
<span className="absolute bottom-1.5 left-1.5 rounded bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white/85 backdrop-blur-sm">
<span className="absolute bottom-1.5 left-1.5 bg-black/60 px-1.5 py-0.5 font-mono text-[10px] text-white/85">
{formatTimePrecise(frame.timestamp)}
</span>
</div>
@@ -158,14 +158,14 @@ const FrameAction = ({
disabled,
success,
}: FrameActionProps) => (
// fixed strategy: the card is overflow-hidden (rounded corners), which
// would clip an absolutely-positioned tooltip.
// fixed strategy: the card is overflow-hidden, which would clip an
// absolutely-positioned tooltip.
<Tooltip content={tooltip} position="top" strategy="fixed" className="z-50">
<button
onClick={onClick}
disabled={loading || disabled}
aria-label={tooltip}
className={`flex h-7 w-8 items-center justify-center rounded-md text-xs transition-colors disabled:cursor-default ${
className={`flex h-7 w-8 items-center justify-center text-xs transition-colors disabled:cursor-default ${
success
? "text-green"
: "text-base-fg/60 hover:bg-white/5 hover:text-base-fg"
@@ -24,11 +24,11 @@ export const VideoDropZone = ({
};
return (
<div className="overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel p-3">
<div className="overflow-hidden border border-ui-panel-border bg-ui-panel p-3">
<div
className={twMerge(
"flex aspect-video flex-col items-center justify-center gap-4 rounded-lg border border-dashed border-ui-border bg-ui-background/40 p-6 text-center transition-colors",
isDragActive && "border-primary/60 bg-primary/5",
"flex aspect-video flex-col items-center justify-center gap-4 rounded-[3px] border border-dashed border-ui-border bg-ui-background/40 p-6 text-center transition-colors",
isDragActive && "border-white/60 bg-white/5",
)}
onDragEnter={(e) => {
e.preventDefault();
@@ -65,7 +65,7 @@ export const VideoDropZone = ({
variant="primary"
icon={UploadIcon}
onClick={() => fileInputRef.current?.click()}
className="px-4 py-1.5 text-sm font-medium"
className="px-4 py-1.5"
>
Select video
</Button>
@@ -73,7 +73,7 @@ export const VideoDropZone = ({
variant="action"
icon={ImagesIcon}
onClick={onPickFromLibrary}
className="px-4 py-1.5 text-sm font-medium"
className="px-4 py-1.5"
>
From library
</Button>
@@ -228,7 +228,7 @@ export const VideoScrubber = ({
];
return (
<div className="group/scrubber overflow-hidden rounded-xl border border-ui-panel-border bg-ui-panel">
<div className="group/scrubber overflow-hidden border border-ui-panel-border bg-ui-panel">
<div className="relative aspect-video w-full bg-black">
<video
ref={videoRef}
@@ -243,7 +243,7 @@ export const VideoScrubber = ({
{!isPlaying && !disabled && (
<button
onClick={togglePlayPause}
className="absolute inset-0 m-auto flex h-12 w-12 items-center justify-center rounded-full bg-black/55 text-white opacity-0 backdrop-blur-sm transition-opacity group-hover/scrubber:opacity-100"
className="absolute inset-0 m-auto flex h-12 w-12 items-center justify-center bg-black/55 text-white opacity-0 transition-opacity group-hover/scrubber:opacity-100"
aria-label="Play"
>
<PlayIcon className="ml-0.5" />
@@ -261,21 +261,21 @@ export const VideoScrubber = ({
onPointerCancel={handleTimelinePointerUp}
onPointerLeave={() => setHoverRatio(null)}
>
<div className="absolute top-1/2 h-2 w-full -translate-y-1/2 rounded-full bg-white/10 transition-all group-hover/timeline:h-3">
<div className="absolute top-1/2 h-2 w-full -translate-y-1/2 bg-white/10 transition-all group-hover/timeline:h-3">
<div
className="h-full rounded-full bg-primary"
className="h-full bg-white"
style={{ width: `${progressPercent}%` }}
/>
</div>
<div
className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 rounded-full bg-white shadow"
className="absolute top-1/2 h-3.5 w-3.5 -translate-x-1/2 -translate-y-1/2 bg-white"
style={{ left: `${progressPercent}%` }}
/>
{hoverRatio !== null &&
Number.isFinite(duration) &&
duration > 0 && (
<div
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded-md bg-black/80 px-2 py-0.5 font-mono text-[10px] text-white"
className="pointer-events-none absolute -top-6 -translate-x-1/2 rounded-[3px] bg-black/80 px-2 py-0.5 font-mono text-[10px] text-white"
style={{ left: `${hoverRatio * 100}%` }}
>
{formatTimePrecise(hoverRatio * duration)}
@@ -289,7 +289,7 @@ export const VideoScrubber = ({
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
<button
onClick={() => seekBy(step.delta)}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
className="flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={step.tooltip}
>
<DynamicIcon icon={step.icon} className="text-xs" />
@@ -299,7 +299,7 @@ export const VideoScrubber = ({
<Tooltip content="Play/Pause (Space)" position="top" className="z-50">
<button
onClick={togglePlayPause}
className="mx-0.5 flex h-9 w-9 items-center justify-center rounded-full bg-white/10 text-white transition-colors hover:bg-white/20"
className="mx-0.5 flex h-9 w-9 items-center justify-center bg-white/10 text-white transition-colors hover:bg-white/20"
aria-label="Play or pause"
>
<DynamicIcon icon={isPlaying ? PauseIcon : PlayIcon} />
@@ -309,7 +309,7 @@ export const VideoScrubber = ({
<Tooltip key={step.label} content={step.tooltip} position="top" className="z-50">
<button
onClick={() => seekBy(step.delta)}
className="flex h-8 w-8 items-center justify-center rounded-md text-white/70 transition-colors hover:bg-white/10 hover:text-white"
className="flex h-8 w-8 items-center justify-center text-white/70 transition-colors hover:bg-white/10 hover:text-white"
aria-label={step.tooltip}
>
<DynamicIcon icon={step.icon} className="text-xs" />
@@ -322,7 +322,7 @@ export const VideoScrubber = ({
<div className="hidden items-center gap-2 sm:flex">
<button
onClick={toggleMute}
className="flex h-7 w-7 items-center justify-center rounded-md text-white/60 transition-colors hover:bg-white/10 hover:text-white"
className="flex h-7 w-7 items-center justify-center text-white/60 transition-colors hover:bg-white/10 hover:text-white"
aria-label={isMuted ? "Unmute" : "Mute"}
>
<DynamicIcon
@@ -340,7 +340,7 @@ export const VideoScrubber = ({
const video = videoRef.current;
if (video) video.volume = parseFloat(e.target.value);
}}
className="w-20 accent-primary"
className="w-20 accent-white"
aria-label="Volume"
/>
</div>
@@ -1,22 +1,28 @@
import { Link } from "react-router-dom";
import { motion, useReducedMotion } from "framer-motion";
import { ArrowRightIcon, BoxIcon, FilmIcon, GlobeIcon, GroupIcon, ImageIcon, ImagesIcon, MusicIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import {
ArrowRightIcon,
BoxIcon,
FilmIcon,
GlobeIcon,
GroupIcon,
ImageIcon,
ImagesIcon,
MusicIcon,
VideoIcon,
WandSparklesIcon,
} from "lucide-react";
import type { LucideIcon } from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { TRANSITION_SPRING } from "../../lib/motion";
const MotionLink = motion(Link);
type AppCard = {
label: string;
description: string;
href: string;
icon: LucideIcon;
accent: string;
iconBg: string;
iconColor: string;
iconTile: string;
hoverBorder: string;
badge?: string;
};
@@ -26,72 +32,64 @@ const APPS: AppCard[] = [
description: "Generate images from prompts and references.",
href: "/create-image",
icon: ImageIcon,
accent: "from-blue-500/20 to-blue-500/0",
iconBg: "bg-blue-500/25 border-blue-400/30",
iconColor: "text-blue-300",
iconTile: "border-blue-400/40 bg-blue-500/20 text-blue-300",
hoverBorder: "hover:border-blue-400/60",
},
{
label: "Video",
description: "Generate cinematic clips from text or images.",
href: "/create-video",
icon: VideoIcon,
accent: "from-purple-500/20 to-purple-500/0",
iconBg: "bg-purple-500/25 border-purple-400/30",
iconColor: "text-purple-300",
iconTile: "border-purple-400/40 bg-purple-500/20 text-purple-300",
hoverBorder: "hover:border-purple-400/60",
},
{
label: "Audio",
description: "Generate songs and sound effects from prompts.",
href: "/create-audio",
icon: MusicIcon,
accent: "from-pink-500/20 to-pink-500/0",
iconBg: "bg-pink-500/25 border-pink-400/30",
iconColor: "text-pink-300",
iconTile: "border-pink-400/40 bg-pink-500/20 text-pink-300",
hoverBorder: "hover:border-pink-400/60",
},
{
label: "3D Object",
description: "Turn a prompt or image into a 3D model.",
href: "/create-object",
icon: BoxIcon,
accent: "from-cyan-500/20 to-cyan-500/0",
iconBg: "bg-cyan-500/25 border-cyan-400/30",
iconColor: "text-cyan-300",
iconTile: "border-cyan-400/40 bg-cyan-500/20 text-cyan-300",
hoverBorder: "hover:border-cyan-400/60",
},
{
label: "3D World",
description: "Turn a prompt or image into an explorable world.",
href: "/create-world",
icon: GlobeIcon,
accent: "from-teal-500/20 to-teal-500/0",
iconBg: "bg-teal-500/25 border-teal-400/30",
iconColor: "text-teal-300",
iconTile: "border-teal-400/40 bg-teal-500/20 text-teal-300",
hoverBorder: "hover:border-teal-400/60",
},
{
label: "Edit 3D",
description: "Compose 3D scenes and render with AI cameras.",
href: "/edit-3d",
icon: BoxIcon,
accent: "from-amber-500/20 to-amber-500/0",
iconBg: "bg-amber-500/25 border-amber-400/30",
iconColor: "text-amber-300",
iconTile: "border-amber-400/40 bg-amber-500/20 text-amber-300",
hoverBorder: "hover:border-amber-400/60",
},
{
label: "Background Change",
description: "Swap or remove backgrounds with AI VFX.",
href: "/background-change",
icon: WandSparklesIcon,
accent: "from-emerald-500/20 to-emerald-500/0",
iconBg: "bg-emerald-500/25 border-emerald-400/30",
iconColor: "text-emerald-300",
iconTile: "border-emerald-400/40 bg-emerald-500/20 text-emerald-300",
hoverBorder: "hover:border-emerald-400/60",
},
{
label: "Edit Video",
description: "Trim, arrange, and edit clips on a timeline.",
href: "/video-editor",
icon: FilmIcon,
accent: "from-rose-500/20 to-rose-500/0",
iconBg: "bg-rose-500/25 border-rose-400/30",
iconColor: "text-rose-300",
iconTile: "border-rose-400/40 bg-rose-500/20 text-rose-300",
hoverBorder: "hover:border-rose-400/60",
badge: "BETA",
},
{
@@ -99,9 +97,8 @@ const APPS: AppCard[] = [
description: "Collect references and ideas to steer a generation.",
href: "/moodboard",
icon: GroupIcon,
accent: "from-indigo-500/20 to-indigo-500/0",
iconBg: "bg-indigo-500/25 border-indigo-400/30",
iconColor: "text-indigo-300",
iconTile: "border-indigo-400/40 bg-indigo-500/20 text-indigo-300",
hoverBorder: "hover:border-indigo-400/60",
badge: "BETA",
},
{
@@ -109,27 +106,35 @@ const APPS: AppCard[] = [
description: "Grab a still frame from any video.",
href: "/frame-extractor",
icon: ImagesIcon,
accent: "from-orange-500/20 to-orange-500/0",
iconBg: "bg-orange-500/25 border-orange-400/30",
iconColor: "text-orange-300",
iconTile: "border-orange-400/40 bg-orange-500/20 text-orange-300",
hoverBorder: "hover:border-orange-400/60",
badge: "NEW",
},
];
export function Home() {
const reduceMotion = useReducedMotion();
return (
<div className="min-h-full px-6 sm:px-10 py-10 sm:py-16 max-w-6xl mx-auto w-full">
<Seo
title="ArtCraft - Create AI Images and Video"
description="Generate AI images and video with ArtCraft."
/>
<Reveal as="h1" inView={false} y={20} className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight">
<Reveal
as="h1"
inView={false}
y={20}
className="text-center font-display text-4xl sm:text-6xl mx-auto font-semibold tracking-tight"
>
What will you <span className="text-primary">craft</span> today?
</Reveal>
<section className="py-12">
<Reveal as="h2" inView={false} delay={0.08} className="text-sm font-semibold text-white/85 mb-4">
<Reveal
as="h2"
inView={false}
delay={0.08}
className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-4"
>
Create
</Reveal>
<RevealGroup
@@ -140,37 +145,36 @@ export function Home() {
>
{APPS.map((app) => (
<Reveal key={app.href} y={20}>
<MotionLink
<Link
to={app.href}
className="bg-ui-controls/50 group relative flex h-full overflow-hidden rounded-2xl p-5 ring-1 ring-white/[0.06] shadow-[inset_0_1px_0_0_rgba(255,255,255,0.04)] hover:bg-ui-controls hover:ring-white/15"
whileHover={reduceMotion ? undefined : { y: -4 }}
whileTap={reduceMotion ? undefined : { scale: 0.985 }}
transition={TRANSITION_SPRING}
className={`group relative flex h-full border rounded-[3px] border-white/10 bg-white/5 p-5 transition-colors duration-150 ${app.hoverBorder}`}
>
<div
className={`absolute -inset-px rounded-2xl bg-gradient-to-br ${app.accent} opacity-0 group-hover:opacity-100 transition-opacity duration-300 pointer-events-none`}
aria-hidden
/>
<div className="relative flex w-full items-start gap-4">
<div
className={`flex h-11 w-11 shrink-0 items-center justify-center rounded-xl border ${app.iconBg} ${app.iconColor} shadow-[inset_0_1px_1px_rgba(255,255,255,0.12)] transition-transform duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:scale-105`}
className={`flex h-11 w-11 shrink-0 items-center justify-center border ${app.iconTile}`}
>
<DynamicIcon icon={app.icon} className="text-base" />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<h3 className="text-base font-semibold text-white truncate">
<h3 className=" text-sm font-bold uppercase tracking-[0.08em] text-white truncate">
{app.label}
</h3>
{app.badge && (
<span className="shrink-0 rounded-full bg-amber-600 px-1.5 py-0.5 text-[9px] font-semibold uppercase leading-none text-white">
<span
className={`shrink-0 border px-1.5 py-0.5 font-mono text-[9px] font-semibold uppercase tracking-[0.12em] leading-none ${
app.badge === "NEW"
? "border-purple-400/40 text-purple-300"
: "border-amber-400/40 text-amber-300"
}`}
>
{app.badge}
</span>
)}
</div>
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-white/[0.04] text-white/40 transition-all duration-300 ease-[cubic-bezier(0.22,1,0.36,1)] group-hover:bg-white/10 group-hover:text-white/80 group-hover:translate-x-0.5">
<ArrowRightIcon className="text-xs" />
<span className="flex h-7 w-7 shrink-0 items-center justify-center rounded-[3px] border border-white/15 text-white/40 transition-colors duration-150 group-hover:border-white/40 group-hover:text-white">
<ArrowRightIcon className="text-xs" />
</span>
</div>
<p className="mt-1 text-sm text-white/55 leading-snug">
@@ -178,7 +182,7 @@ export function Home() {
</p>
</div>
</div>
</MotionLink>
</Link>
</Reveal>
))}
</RevealGroup>
@@ -35,7 +35,31 @@ import {
showActionReminder,
isActionReminderOpen,
} from "@storyteller/ui-action-reminder-modal";
import { ArrowDownToLineIcon, BoxIcon, CloudIcon, EllipsisIcon, FolderIcon, FolderOpenIcon, FolderPlusIcon, GlobeIcon, Grid3x3Icon, ImageIcon, ImagesIcon, ListIcon, LoaderCircleIcon, MusicIcon, PencilIcon, PlusIcon, RefreshCwIcon, StarIcon, TagIcon, TagsIcon, Trash2Icon, VideoIcon, XIcon } from "lucide-react";
import {
ArrowDownToLineIcon,
BoxIcon,
CloudIcon,
EllipsisIcon,
FolderIcon,
FolderOpenIcon,
FolderPlusIcon,
GlobeIcon,
Grid3x3Icon,
ImageIcon,
ImagesIcon,
ListIcon,
LoaderCircleIcon,
MusicIcon,
PencilIcon,
PlusIcon,
RefreshCwIcon,
StarIcon,
TagIcon,
TagsIcon,
Trash2Icon,
VideoIcon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Lightbox } from "../../components/lightbox/lightbox";
import { toast } from "../../components/toast/toast";
@@ -988,7 +1012,7 @@ export default function Library() {
// ── Not logged in / loading auth ──────────────────────────────────────────
if (isLoggedIn === false) {
return (
<div className="relative min-h-full w-full bg-[#101014] flex items-center justify-center px-4">
<div className="relative min-h-full w-full bg-ui-background flex items-center justify-center px-4">
<div className="text-center space-y-6">
<h1 className="text-3xl font-medium text-white">My Library</h1>
<p className="text-white/60 text-lg max-w-md mx-auto">
@@ -996,18 +1020,12 @@ export default function Library() {
</p>
<div className="flex gap-3 justify-center">
<Link to="/login">
<Button
variant="primary"
className="rounded-full bg-white text-black hover:bg-white/90 text-sm font-semibold px-6 py-2.5"
>
<Button variant="primary" className="px-6 py-2.5">
Login
</Button>
</Link>
<Link to="/signup">
<Button
variant="primary"
className="rounded-full text-sm font-semibold px-6 py-2.5"
>
<Button variant="secondary" className="px-6 py-2.5">
Sign up
</Button>
</Link>
@@ -1019,7 +1037,7 @@ export default function Library() {
if (isLoggedIn === null) {
return (
<div className="relative min-h-full w-full bg-[#101014] flex items-center justify-center">
<div className="relative min-h-full w-full bg-ui-background flex items-center justify-center">
<LoadingSpinner className="h-10 w-10 text-white/60" />
</div>
);
@@ -1048,7 +1066,9 @@ export default function Library() {
gridColumns={4}
scrollRoot={null}
>
<h3 className="text-sm font-medium text-white/50 mb-2">{date}</h3>
<h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/50 mb-2">
{date}
</h3>
<div className={GRID_CLASS}>
{dateItems.map((item) => (
<LibraryTile
@@ -1084,7 +1104,7 @@ export default function Library() {
// `select-none` blocks the blue text/image highlight when sweeping a
// marquee or fat-fingering a click; native selection would otherwise also
// hijack the drag. Form fields opt back in so dialog inputs stay editable.
className="relative min-h-full w-full shrink-0 select-none [&_input]:select-text [&_textarea]:select-text bg-[#101014] pb-8 px-3 sm:px-4 md:px-8 lg:px-12"
className="relative min-h-full w-full shrink-0 select-none [&_input]:select-text [&_textarea]:select-text bg-ui-background pb-8 px-3 sm:px-4 md:px-8 lg:px-12"
onPointerDown={handleMarqueePointerDown}
>
<div className="mx-auto max-w-[1600px]">
@@ -1094,124 +1114,116 @@ export default function Library() {
// z-10 keeps the sticky header above the (static) grid tiles while
// staying below the app topbar (z-20), so topbar popovers (credits,
// avatar) aren't painted under this bar.
className="sticky top-0 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-[#101014] mb-4 sm:mb-6"
className="sticky top-0 z-10 -mx-3 sm:-mx-4 md:-mx-8 lg:-mx-12 px-3 sm:px-4 md:px-8 lg:px-12 pb-3 pt-3 bg-ui-background mb-4 sm:mb-6"
>
<div className="flex flex-col gap-6">
{/* Tabs + actions */}
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1">
<Link
to="/library"
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tab === "unsorted"
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tab === "unsorted" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<Grid3x3Icon
className="relative z-10 text-xs" />
<Grid3x3Icon className="relative z-10 text-xs" />
<span className="relative z-10">All Assets</span>
</Link>
<Link
to="/library/folders"
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tab === "folders"
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tab === "folders" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FolderIcon
className="relative z-10 text-xs" />
<FolderIcon className="relative z-10 text-xs" />
<span className="relative z-10">Folders</span>
</Link>
<Link
to="/library/folderless"
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tab === "folderless"
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tab === "folderless" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<FolderOpenIcon
className="relative z-10 text-xs" />
<FolderOpenIcon className="relative z-10 text-xs" />
<span className="relative z-10">Unfoldered</span>
</Link>
<Link
to="/library/tags"
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-2 px-3 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tab === "tags"
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tab === "tags" && (
<motion.span
layoutId="library-tab-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
<TagIcon
className="relative z-10 text-xs" />
<TagIcon className="relative z-10 text-xs" />
<span className="relative z-10">Tags</span>
</Link>
</div>
{(tab === "unsorted" || tab === "folderless") && (
<button
onClick={refreshRoot}
className="h-8 w-8 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-8 w-8 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Refresh library"
>
<RefreshCwIcon
className={`text-sm ${initialLoading ? "animate-spin" : ""}`} />
className={`text-sm ${initialLoading ? "animate-spin" : ""}`}
/>
</button>
)}
</div>
<div className="flex items-center gap-2">
{tab === "folderless" && (
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
{FOLDERLESS_CLASS_FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => setFolderlessClass(filter.id)}
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
folderlessClass === filter.id
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{folderlessClass === filter.id && (
<motion.span
layoutId="library-folderless-filter-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
@@ -1227,21 +1239,21 @@ export default function Library() {
</div>
)}
{tab === "unsorted" && (
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
{FILTERS.map((filter) => (
<button
key={filter.id}
onClick={() => navigate(filter.route)}
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
activeFilter === filter.id
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{activeFilter === filter.id && (
<motion.span
layoutId="library-filter-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{ duration: 0.32, ease: EASE_EMPHASIS }}
/>
)}
@@ -1261,7 +1273,7 @@ export default function Library() {
variant="primary"
icon={FolderPlusIcon}
onClick={() => openNewFolderModal(null)}
className="rounded-full text-xs sm:text-sm px-3 sm:px-4 py-2"
className="px-3 sm:px-4 py-2"
>
New folder
</Button>
@@ -1269,7 +1281,7 @@ export default function Library() {
{tab === "tags" && !activeTagToken && tags.length > 0 && (
<>
{tagView === "list" && (
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1 overflow-x-auto">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1 overflow-x-auto">
{(
[
["count", "Most used"],
@@ -1279,16 +1291,16 @@ export default function Library() {
<button
key={id}
onClick={() => setTagSort(id)}
className={`relative px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tagSort === id
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tagSort === id && (
<motion.span
layoutId="library-tag-sort-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{
duration: 0.32,
ease: EASE_EMPHASIS,
@@ -1300,7 +1312,7 @@ export default function Library() {
))}
</div>
)}
<div className="flex items-center gap-1 bg-ui-controls/40 rounded-xl p-1">
<div className="flex items-center gap-1 rounded-[3px] border border-white/15 bg-ui-controls/40 p-1">
{(
[
["cloud", CloudIcon, "Cloud"],
@@ -1311,16 +1323,16 @@ export default function Library() {
key={id}
onClick={() => setTagView(id)}
title={label}
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 rounded-md text-xs sm:text-sm font-medium transition-colors whitespace-nowrap ${
className={`relative flex items-center gap-1.5 sm:gap-2 px-2.5 sm:px-4 py-1 sm:py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors whitespace-nowrap ${
tagView === id
? "text-white"
? "text-black"
: "text-white/60 hover:text-white"
}`}
>
{tagView === id && (
<motion.span
layoutId="library-tag-view-indicator"
className="absolute inset-0 rounded-md bg-ui-controls"
className="absolute inset-0 rounded-[3px] bg-white"
transition={{
duration: 0.32,
ease: EASE_EMPHASIS,
@@ -1368,17 +1380,17 @@ export default function Library() {
</h1>
<button
onClick={() => startRename(activeFolderId!)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Rename folder"
>
<PencilIcon className="text-xs" />
<PencilIcon className="text-xs" />
</button>
<button
onClick={() => openNewFolderModal(activeFolderId)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="New subfolder"
>
<FolderPlusIcon className="text-xs" />
<FolderPlusIcon className="text-xs" />
</button>
</div>
)}
@@ -1404,17 +1416,17 @@ export default function Library() {
)}
<button
onClick={() => setTagRenameTarget(activeTagToken)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-white hover:bg-ui-controls/40 transition-colors"
title="Rename tag"
>
<PencilIcon className="text-xs" />
<PencilIcon className="text-xs" />
</button>
<button
onClick={() => confirmDeleteTag(activeTagToken)}
className="h-7 w-7 flex items-center justify-center rounded-lg text-white/50 hover:text-red hover:bg-ui-controls/40 transition-colors"
className="h-7 w-7 flex items-center justify-center text-white/50 hover:text-red hover:bg-ui-controls/40 transition-colors"
title="Delete tag"
>
<Trash2Icon className="text-xs" />
<Trash2Icon className="text-xs" />
</button>
</div>
)}
@@ -1427,7 +1439,7 @@ export default function Library() {
{tab === "folders" && currentSubfolders.length > 0 && (
<div>
{inFolder && (
<h3 className="text-sm font-medium text-white/50 mb-2">
<h3 className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/50 mb-2">
Folders
</h3>
)}
@@ -1451,17 +1463,15 @@ export default function Library() {
/* ── Folders tab, root: cards only (above) ── */
currentSubfolders.length === 0 && (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-ui-controls/30">
<FolderOpenIcon
className="text-2xl text-white/40" />
<div className="flex h-14 w-14 items-center justify-center border border-white/15 bg-ui-controls/30">
<FolderOpenIcon className="text-2xl text-white/40" />
</div>
<p className="text-white/40 text-sm">No folders yet.</p>
<Button
variant="primary"
icon={FolderPlusIcon}
onClick={() => openNewFolderModal(null)}
className="rounded-full text-sm px-4 py-2"
className="px-4 py-2"
>
New folder
</Button>
@@ -1482,18 +1492,12 @@ export default function Library() {
</p>
<div className="flex gap-3">
<Link to="/create-image">
<Button
variant="primary"
className="rounded-full text-sm px-4 py-2"
>
<Button variant="primary" className="px-4 py-2">
Create Image
</Button>
</Link>
<Link to="/create-video">
<Button
variant="secondary"
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
>
<Button variant="secondary" className="px-4 py-2">
Create Video
</Button>
</Link>
@@ -1517,10 +1521,8 @@ export default function Library() {
</div>
) : tags.length === 0 ? (
<div className="flex flex-col items-center justify-center py-20 gap-3">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-ui-controls/30">
<TagsIcon
className="text-2xl text-white/40" />
<div className="flex h-14 w-14 items-center justify-center border border-white/15 bg-ui-controls/30">
<TagsIcon className="text-2xl text-white/40" />
</div>
<p className="text-white/40 text-sm">No tags yet.</p>
<p className="text-white/30 text-xs max-w-xs text-center">
@@ -1541,7 +1543,7 @@ export default function Library() {
{sortedTags.map((t) => (
<div
key={t.token}
className="flex items-center rounded-full bg-ui-controls/40 hover:bg-ui-controls/70 transition-colors"
className="flex items-center rounded-[3px] border border-white/15 bg-ui-controls/40 hover:bg-ui-controls/70 transition-colors"
>
<button
type="button"
@@ -1556,9 +1558,7 @@ export default function Library() {
}}
className="flex items-center gap-2 pl-4 pr-1 py-2 text-sm font-medium text-white"
>
<TagIcon
className="text-xs text-violet-400" />
<TagIcon className="text-xs text-violet-400" />
<span className="max-w-[14rem] truncate">{t.value}</span>
<span className="text-xs text-white/40">
{t.useCount}
@@ -1576,9 +1576,9 @@ export default function Library() {
}}
aria-label={`Options for tag "${t.value}"`}
title="Rename or delete"
className="mr-1.5 flex h-6 w-6 items-center justify-center rounded-full text-white/40 hover:bg-white/10 hover:text-white transition-colors"
className="mr-1.5 flex h-6 w-6 items-center justify-center text-white/40 hover:bg-white/10 hover:text-white transition-colors"
>
<EllipsisIcon className="text-xs" />
<EllipsisIcon className="text-xs" />
</button>
</div>
))}
@@ -1609,7 +1609,7 @@ export default function Library() {
) : /* ── Unsorted ── */ initialLoading && allItems.length === 0 ? (
<div>
<div
className="h-4 w-24 rounded mb-3"
className="h-4 w-24 mb-3"
style={{
background:
"linear-gradient(100deg, rgba(255,255,255,0.05) 30%, rgba(255,255,255,0.1) 50%, rgba(255,255,255,0.05) 70%)",
@@ -1619,10 +1619,7 @@ export default function Library() {
/>
<div className={GRID_CLASS}>
{Array.from({ length: 15 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-lg overflow-hidden"
>
<div key={i} className="aspect-square overflow-hidden">
<div
className="h-full w-full"
style={{
@@ -1648,18 +1645,12 @@ export default function Library() {
</p>
<div className="flex gap-3">
<Link to="/create-image">
<Button
variant="primary"
className="rounded-full text-sm px-4 py-2"
>
<Button variant="primary" className="px-4 py-2">
Create Image
</Button>
</Link>
<Link to="/create-video">
<Button
variant="secondary"
className="rounded-full text-sm px-4 py-2 border border-ui-panel-border"
>
<Button variant="secondary" className="px-4 py-2">
Create Video
</Button>
</Link>
@@ -1687,7 +1678,7 @@ export default function Library() {
<div
ref={marqueeRef}
style={{ display: "none" }}
className="pointer-events-none fixed z-40 rounded-sm border border-primary/60 bg-primary/10"
className="pointer-events-none fixed z-40 border border-white/60 bg-white/10"
/>
{/* Bulk selection bar — subscribes to the selection store itself so
@@ -1772,7 +1763,7 @@ export default function Library() {
}}
/>
<div
className="fixed z-[9999] min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
className="fixed z-[9999] min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1"
style={{ left: contextMenu.x, top: contextMenu.y }}
>
{(() => {
@@ -1783,7 +1774,7 @@ export default function Library() {
<>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => {
setFolderStar(
contextMenu.folderId,
@@ -1793,8 +1784,8 @@ export default function Library() {
}}
>
<StarIcon
className={`w-4 ${menuFolder?.hasStar ? "text-amber-400" : "text-base-fg/40"}`} />
className={`w-4 ${menuFolder?.hasStar ? "text-amber-400" : "text-base-fg/40"}`}
/>
<span>{menuFolder?.hasStar ? "Unstar" : "Star"}</span>
</button>
<FolderColorRow
@@ -1809,32 +1800,32 @@ export default function Library() {
})()}
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => {
openNewFolderModal(contextMenu.folderId);
}}
>
<FolderPlusIcon className="w-4" />
<FolderPlusIcon className="w-4" />
<span>New subfolder</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => startRename(contextMenu.folderId)}
>
<PencilIcon className="w-4" />
<PencilIcon className="w-4" />
<span>Rename</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-red"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-red"
onClick={() => {
const folderId = contextMenu.folderId;
setContextMenu(null);
confirmDeleteFolder(folderId);
}}
>
<Trash2Icon className="w-4" />
<Trash2Icon className="w-4" />
<span>Delete folder</span>
</button>
</div>
@@ -1855,27 +1846,27 @@ export default function Library() {
}}
/>
<div
className="fixed z-[9999] min-w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-1 shadow-xl"
className="fixed z-[9999] min-w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-1"
style={{ left: tagContextMenu.x, top: tagContextMenu.y }}
>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-base-fg"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-base-fg"
onClick={() => setTagRenameTarget(tagContextMenu.tagToken)}
>
<PencilIcon className="w-4" />
<PencilIcon className="w-4" />
<span>Rename</span>
</button>
<button
type="button"
className="flex w-full items-center gap-2 px-2 py-2 rounded-md hover:bg-ui-controls/60 text-sm text-red"
className="flex w-full items-center gap-2 px-2 py-2 hover:bg-ui-controls/60 text-sm text-red"
onClick={() => {
const token = tagContextMenu.tagToken;
setTagContextMenu(null);
confirmDeleteTag(token);
}}
>
<Trash2Icon className="w-4" />
<Trash2Icon className="w-4" />
<span>Delete tag</span>
</button>
</div>
@@ -2051,7 +2042,7 @@ const SEND_TO_TARGETS: {
// Shared styling for the bar's action pills. `whitespace-nowrap` keeps labels
// on a single line — the bar grows instead of squishing buttons into two rows.
const BAR_BUTTON_CLASS =
"flex items-center gap-2 whitespace-nowrap rounded-full bg-ui-controls/60 px-3 py-1.5 text-sm font-medium text-white hover:bg-ui-controls/90 transition-colors";
"flex items-center gap-2 whitespace-nowrap border border-white/15 bg-ui-controls/60 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white hover:bg-ui-controls/90 transition-colors";
interface BulkSelectionBarProps {
allItems: GalleryItem[];
@@ -2180,7 +2171,7 @@ function BulkSelectionBar({
return (
<div
data-no-marquee
className="fixed bottom-20 sm:bottom-4 z-30 flex w-fit -translate-x-1/2 items-center gap-2 rounded-full border border-ui-panel-border bg-ui-panel/95 px-2.5 py-2 shadow-xl backdrop-blur"
className="fixed bottom-20 sm:bottom-4 z-30 flex w-fit -translate-x-1/2 items-center gap-2 border border-ui-panel-border bg-ui-panel px-2.5 py-2"
style={{
// Center within the content area (viewport minus the app sidebar).
left: "calc(50% + var(--ac-sidebar-offset, 0px) / 2)",
@@ -2191,7 +2182,7 @@ function BulkSelectionBar({
<BulkThumb key={si.id} item={si} />
))}
{selectedItems.length > 4 && (
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center rounded border-2 border-ui-panel bg-black/20">
<div className="flex h-8 w-8 flex-shrink-0 items-center justify-center border-2 border-ui-panel bg-black/20">
<span className="text-[11px] text-white/70">
+{selectedItems.length - 4}
</span>
@@ -2225,8 +2216,8 @@ function BulkSelectionBar({
className="fixed inset-0 z-[59]"
onClick={() => setSendToOpen(false)}
/>
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
<div className="absolute bottom-full left-0 z-[60] mb-2 w-44 rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
Use as reference in
</div>
{SEND_TO_TARGETS.filter(
@@ -2240,7 +2231,7 @@ function BulkSelectionBar({
sendToPrompt(selectedItems, target.destination, navigate);
clear();
}}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
>
<DynamicIcon icon={target.icon} className="w-4 text-xs" />
<span>{target.label}</span>
@@ -2265,7 +2256,7 @@ function BulkSelectionBar({
}}
className={BAR_BUTTON_CLASS}
>
<ImagesIcon className="text-xs" />
<ImagesIcon className="text-xs" />
<span className="hidden sm:inline">Extract frames</span>
</button>
)}
@@ -2278,7 +2269,7 @@ function BulkSelectionBar({
onClick={() => setPopoverOpen((v) => !v)}
className={BAR_BUTTON_CLASS}
>
<FolderPlusIcon className="text-xs" />
<FolderPlusIcon className="text-xs" />
{/* Icon-only on phones — five labeled buttons overflow the bar. */}
<span className="hidden sm:inline">Add to folder</span>
</button>
@@ -2288,8 +2279,8 @@ function BulkSelectionBar({
className="fixed inset-0 z-[59]"
onClick={() => setPopoverOpen(false)}
/>
<div className="absolute bottom-full right-0 z-[60] mb-2 max-h-72 w-56 overflow-y-auto rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
<div className="absolute bottom-full right-0 z-[60] mb-2 max-h-72 w-56 overflow-y-auto rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
Folders
</div>
{sortedFolders.length === 0 ? (
@@ -2306,10 +2297,9 @@ function BulkSelectionBar({
setPopoverOpen(false);
clear();
}}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white hover:bg-ui-controls/50 transition-colors"
>
<FolderIcon
className={
folder.colorCode ? "text-xs" : "text-xs text-primary"
}
@@ -2317,7 +2307,8 @@ function BulkSelectionBar({
folder.colorCode
? { color: folder.colorCode }
: undefined
} />
}
/>
<span className="truncate">{folder.name}</span>
</button>
))
@@ -2329,9 +2320,9 @@ function BulkSelectionBar({
setPopoverOpen(false);
onNewFolder(activeFolderId, Array.from(ids));
}}
className="flex w-full items-center gap-2.5 rounded-md px-2 py-1.5 text-sm text-white/70 hover:bg-ui-controls/50 transition-colors"
className="flex w-full items-center gap-2.5 px-2 py-1.5 text-sm text-white/70 hover:bg-ui-controls/50 transition-colors"
>
<PlusIcon className="w-4 text-xs" />
<PlusIcon className="w-4 text-xs" />
<span>Create new folder</span>
</button>
</div>
@@ -2353,7 +2344,7 @@ function BulkSelectionBar({
}}
className={BAR_BUTTON_CLASS}
>
<TagsIcon className="text-xs" />
<TagsIcon className="text-xs" />
<span className="hidden sm:inline">Add tags</span>
</button>
{tagsOpen && (
@@ -2362,8 +2353,8 @@ function BulkSelectionBar({
className="fixed inset-0 z-[59]"
onClick={() => setTagsOpen(false)}
/>
<div className="absolute bottom-full right-0 z-[60] mb-2 w-72 rounded-lg border border-ui-panel-border bg-ui-panel p-2 shadow-xl">
<div className="px-2 py-1 text-[11px] font-semibold uppercase tracking-wider text-white/40">
<div className="absolute bottom-full right-0 z-[60] mb-2 w-72 rounded-[3px] border border-ui-panel-border bg-ui-panel p-2">
<div className="px-2 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
Add tags
</div>
<TagChipInput
@@ -2380,7 +2371,7 @@ function BulkSelectionBar({
disabled={tagChips.length === 0}
loading={addingTags}
onClick={handleAddTags}
className="mt-2 w-full justify-center rounded-md text-sm py-1.5"
className="mt-2 w-full justify-center py-1.5"
>
Tag {ids.size} {ids.size === 1 ? "item" : "items"}
</Button>
@@ -2407,16 +2398,16 @@ function BulkSelectionBar({
<button
type="button"
onClick={onDeleteSelected}
className="flex items-center gap-2 whitespace-nowrap rounded-full bg-red/90 px-3 py-1.5 text-sm font-medium text-white hover:bg-red transition-colors"
className="flex items-center gap-2 whitespace-nowrap bg-red/90 px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white hover:bg-red transition-colors"
>
<Trash2Icon className="text-xs" />
<Trash2Icon className="text-xs" />
Delete
</button>
<button
type="button"
onClick={clear}
aria-label="Clear selection"
className="flex h-8 w-8 items-center justify-center rounded-full bg-ui-controls/60 text-white hover:bg-ui-controls/90 transition-colors"
className="flex h-8 w-8 items-center justify-center border border-white/15 bg-ui-controls/60 text-white hover:bg-ui-controls/90 transition-colors"
>
<XIcon />
</button>
@@ -2438,7 +2429,7 @@ function BulkThumb({ item }: { item: GalleryItem }) {
: ImageIcon;
const showImage = !!item.thumbnail && !failed;
return (
<div className="-ml-2 h-8 w-8 flex-shrink-0 overflow-hidden rounded border-2 border-ui-panel bg-black/30 first:ml-0">
<div className="-ml-2 h-8 w-8 flex-shrink-0 overflow-hidden border-2 border-ui-panel bg-black/30 first:ml-0">
{showImage ? (
<img
src={item.thumbnail!}
@@ -5,7 +5,11 @@ import { Input } from "@storyteller/ui-input";
import { useState } from "react";
import { Link, useNavigate, useSearchParams } from "react-router-dom";
import { UsersApi } from "@storyteller/api";
import { AuthHeader, AuthFooter, GoogleLoginButton } from "../../components/auth";
import {
AuthHeader,
AuthFooter,
GoogleLoginButton,
} from "../../components/auth";
import Seo from "../../components/seo";
import { Reveal, RevealGroup } from "../../components/motion/reveal";
import { refreshSession } from "../../lib/session";
@@ -76,7 +80,7 @@ const Login = () => {
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
@@ -93,7 +97,7 @@ const Login = () => {
className="space-y-4"
>
<Reveal className="space-y-2">
<label className="text-xs font-semibold text-white/70 ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 ml-1">
Email or Username
</label>
<Input
@@ -101,12 +105,12 @@ const Login = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@example.com or username"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
/>
</Reveal>
<Reveal className="space-y-2">
<div className="flex justify-between items-center ml-1">
<label className="text-xs font-semibold text-white/70">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70">
Password
</label>
<Link
@@ -122,7 +126,7 @@ const Login = () => {
value={password}
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -137,12 +141,12 @@ const Login = () => {
<Reveal className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Log in"
)}
@@ -157,9 +161,9 @@ const Login = () => {
<RevealGroup inView={false} delayChildren={0.28} stagger={0.08}>
<Reveal className="relative my-6 flex items-center justify-center">
<div className="absolute inset-0 flex items-center">
<div className="w-full border-t border-white/10" />
<div className="w-full border-t border-white/15" />
</div>
<span className="relative bg-[#1C1C20] px-4 text-xs uppercase tracking-widest text-white/40">
<span className="relative bg-[#101014] px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
or
</span>
</Reveal>
@@ -129,9 +129,9 @@ export default function MediaPage() {
description="View shared media from ArtCraft."
/>
<div className="mx-auto max-w-[1920px] w-full h-auto lg:h-[calc(100vh-100px)] min-h-[500px]">
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden rounded-xl border border-white/[2%]">
<div className="flex flex-col lg:flex-row h-full w-full overflow-hidden border border-white/15">
{/* Media Preview Area */}
<div className="relative flex-1 bg-black/20 backdrop-blur-lg flex items-center justify-center overflow-hidden min-h-[30vh] lg:min-h-0">
<div className="relative flex-1 bg-black flex items-center justify-center overflow-hidden min-h-[30vh] lg:min-h-0">
{mediaRecordLoading ? (
<div className="absolute inset-0 flex items-center justify-center">
<LoadingSpinner className="h-12 w-12 text-white/60" />
@@ -285,30 +285,25 @@ const Onboarding = () => {
if (isLoading) {
return (
<div className="relative min-h-screen bg-[#101014] text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-primary border-t-transparent"></div>
<div className="relative min-h-screen bg-ui-background text-white flex items-center justify-center">
<div className="animate-spin rounded-full h-12 w-12 border-4 border-white/20 border-t-white"></div>
</div>
);
}
if (currentStep === "complete") {
return (
<div className="relative min-h-screen bg-[#101014] text-white">
<div className="relative min-h-screen bg-ui-background text-white">
<Seo
title="Setup Complete - ArtCraft"
description="Your account setup is complete."
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-green-500/40 via-primary/30 to-purple-600/20 opacity-40 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<div className="max-w-md w-full text-center">
<div className="mb-6">
<div className="w-20 h-20 mx-auto rounded-full bg-green-500/20 flex items-center justify-center">
<CircleCheckIcon
className="text-5xl text-green-400" />
<div className="w-20 h-20 mx-auto border border-white/15 bg-green-500/10 flex items-center justify-center">
<CircleCheckIcon className="text-5xl text-green-400" />
</div>
</div>
<h1 className="text-3xl md:text-4xl font-medium mb-4 text-white">
@@ -322,28 +317,20 @@ const Onboarding = () => {
}
return (
<div className="relative min-h-screen bg-[#101014] text-white">
<div className="relative min-h-screen bg-ui-background text-white">
<Seo
title="Complete Your Setup - ArtCraft"
description="Just a few more details to get you started with ArtCraft."
/>
<div className="absolute inset-0 flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-20 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-20 pb-20 px-4 sm:px-6 lg:px-8 flex flex-col items-center justify-center min-h-[calc(100vh-200px)]">
<div className="max-w-md w-full">
{isNewAccount && (
<div className="bg-blue-500/10 border border-blue-500/30 rounded-xl p-4 mb-6 flex items-start gap-3 animate-fade-in shadow-lg shadow-blue-900/10">
<InfoIcon
className="text-blue-400 mt-1 flex-shrink-0 text-lg" />
<div className="bg-white/5 border border-white/15 p-4 mb-6 flex items-start gap-3 animate-fade-in">
<InfoIcon className="text-white/70 mt-1 flex-shrink-0 text-lg" />
<div>
<h3 className="text-blue-200 font-medium mb-1">
Account Created
</h3>
<p className="text-blue-300/80 text-sm leading-relaxed">
<h3 className="text-white font-medium mb-1">Account Created</h3>
<p className="text-white/60 text-sm leading-relaxed">
We've automatically created an account for you. Please
complete your setup below to secure it.
</p>
@@ -351,7 +338,7 @@ const Onboarding = () => {
</div>
)}
<div className="bg-[#1C1C20] border border-white/10 rounded-2xl p-6 py-8 shadow-2xl">
<div className="bg-[#101014] border border-white/15 p-6 py-8">
<div className="text-center mb-8">
<h1 className="text-2xl font-semibold mb-2">
{currentStep === "password" &&
@@ -376,7 +363,7 @@ const Onboarding = () => {
<form onSubmit={handleSubmit} className="space-y-4">
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
@@ -384,7 +371,7 @@ const Onboarding = () => {
{currentStep === "password" && (
<>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Password
</label>
<Input
@@ -393,13 +380,13 @@ const Onboarding = () => {
value={password}
onChange={(e) => setPassword(e.target.value)}
required
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Enter your password"
minLength={8}
/>
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Confirm Password
</label>
<Input
@@ -408,7 +395,7 @@ const Onboarding = () => {
value={passwordConfirmation}
onChange={(e) => setPasswordConfirmation(e.target.value)}
required
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Confirm your password"
minLength={8}
/>
@@ -418,7 +405,7 @@ const Onboarding = () => {
{currentStep === "email" && (
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Email Address
</label>
<Input
@@ -427,7 +414,7 @@ const Onboarding = () => {
value={email}
onChange={(e) => setEmail(e.target.value)}
required
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="your@email.com"
/>
</div>
@@ -435,7 +422,7 @@ const Onboarding = () => {
{currentStep === "username" && (
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Username
</label>
<Input
@@ -443,7 +430,7 @@ const Onboarding = () => {
id="username"
value={username}
onChange={(e) => setUsername(e.target.value)}
inputClassName="w-full bg-black/20 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors"
placeholder="Your display name"
/>
</div>
@@ -452,13 +439,11 @@ const Onboarding = () => {
<div className="flex gap-3 pt-2">
<Button
type="submit"
className="flex-1 bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="flex-1 justify-center h-10"
disabled={isSubmitting}
>
{isSubmitting ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Continue"
)}
@@ -467,7 +452,7 @@ const Onboarding = () => {
<Button
type="button"
onClick={handleSkip}
className="bg-white/10 hover:bg-white/20 text-white border-none justify-center font-bold h-10 px-6"
className="bg-transparent border border-white/15 hover:bg-white/10 text-white justify-center h-10 px-6"
>
Skip
</Button>
@@ -8,14 +8,16 @@
// - start from a blank canvas → BlankCanvasModal (from the lib)
import { useState } from "react";
import { ImagesIcon, LoaderCircleIcon, MaximizeIcon, PencilIcon } from "lucide-react";
import {
ImagesIcon,
LoaderCircleIcon,
MaximizeIcon,
PencilIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { UploaderStates } from "@storyteller/common";
import { MediaFilesApi } from "@storyteller/api";
import {
GalleryModal,
type GalleryItem,
} from "@storyteller/ui-gallery-modal";
import { GalleryModal, type GalleryItem } from "@storyteller/ui-gallery-modal";
import {
BlankCanvasModal,
type BaseSelectorImage,
@@ -141,10 +143,10 @@ export function BaseImageSelector({
}}
onDragLeave={() => setIsDragging(false)}
onDrop={onDrop}
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center gap-4 rounded-2xl border-2 border-dashed p-8 text-center transition-colors ${
className={`flex h-full w-full cursor-pointer flex-col items-center justify-center gap-4 rounded-[3px] border border-dashed p-8 text-center transition-colors ${
isDragging
? "border-primary-400 bg-primary/10"
: "border-ui-panel-border bg-ui-background hover:border-primary-400/50"
? "border-white bg-white/10"
: "border-white/15 bg-ui-background hover:border-white/40"
} ${busy ? "pointer-events-none opacity-60" : ""}`}
>
<input
@@ -154,7 +156,7 @@ export function BaseImageSelector({
onChange={onFileInput}
disabled={busy}
/>
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-primary-400/30 bg-primary/30 text-primary-300">
<div className="flex h-14 w-14 items-center justify-center border border-primary-400/30 bg-primary/30 text-primary-300">
<DynamicIcon
icon={busy ? LoaderCircleIcon : PencilIcon}
className={`text-xl ${busy ? "animate-spin" : ""}`}
@@ -174,7 +176,7 @@ export function BaseImageSelector({
setIsGalleryOpen(true);
}}
disabled={busy}
className="flex items-center gap-2 rounded-lg border border-ui-controls-border bg-ui-controls px-3 py-2 text-sm font-medium text-base-fg transition-colors hover:bg-ui-controls/80"
className="flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
>
<ImagesIcon />
Pick from Library
@@ -186,7 +188,7 @@ export function BaseImageSelector({
setIsBlankCanvasOpen(true);
}}
disabled={busy}
className="flex items-center gap-2 rounded-lg border border-ui-controls-border bg-ui-controls px-3 py-2 text-sm font-medium text-base-fg transition-colors hover:bg-ui-controls/80"
className="flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors hover:bg-ui-controls/80"
>
<MaximizeIcon />
Blank Canvas
@@ -28,7 +28,7 @@ export default function PageDraw() {
transform: "translateZ(0)",
// Recolors the lib's `.pegboard-bg` canvas backdrop (the surface
// behind the artboard) to the webapp's page color.
"--st-canvas": "#101014",
"--st-canvas": "#0b0b0c",
// With the global top bar hidden but the bottom prompt bar still
// present, the usable canvas area's center sits above the geometric
// center. Pull the side panels (toolbar + history) up by half the
@@ -39,7 +39,7 @@ export default function PageDraw() {
>
<PageDrawLib
adapter={adapter}
backgroundClassName="bg-[#101014]"
backgroundClassName="bg-ui-background"
fillParentHeight={!isMobile}
// Cost/help live in the webapp's own chrome, not over the canvas.
showBottomRightControls={false}
@@ -1,5 +1,12 @@
import { useEffect, useMemo, useState } from "react";
import { ImageIcon, MaximizeIcon, MinimizeIcon, VideoIcon, WandSparklesIcon, XIcon } from "lucide-react";
import {
ImageIcon,
MaximizeIcon,
MinimizeIcon,
VideoIcon,
WandSparklesIcon,
XIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { MediaFilesApi, PromptsApi } from "@storyteller/api";
import { addCorsParam, PLACEHOLDER_IMAGES } from "@storyteller/common";
@@ -154,7 +161,7 @@ export function DemoOutputOverlay({
if (isExpanded) {
return (
<div className="pointer-events-auto absolute inset-0 z-30 flex items-center justify-center bg-black/70 p-8 backdrop-blur-sm animate-in fade-in duration-200">
<div className="pointer-events-auto absolute inset-0 z-30 flex items-center justify-center bg-black/70 p-8 animate-in fade-in duration-200">
<button
type="button"
aria-label="Close expanded view"
@@ -209,9 +216,9 @@ function ShowOutputPill({ onClick }: { onClick: () => void }) {
type="button"
onClick={onClick}
aria-label="Show rendered output"
className="pointer-events-auto absolute right-2 top-16 z-30 flex items-center gap-2 rounded-xl border border-ui-controls-border bg-ui-controls px-3 py-1.5 text-sm font-medium text-base-fg shadow-xl transition-colors duration-150 hover:bg-ui-controls/80 animate-in fade-in slide-in-from-right-4"
className="pointer-events-auto absolute right-2 top-16 z-30 flex items-center gap-2 border border-ui-controls-border bg-ui-controls px-3 py-1.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg transition-colors duration-150 hover:bg-ui-controls/80 animate-in fade-in slide-in-from-right-4"
>
<ImageIcon className="h-3 w-3 text-primary" />
<ImageIcon className="h-3 w-3 text-primary" />
Show output
</button>
);
@@ -223,9 +230,9 @@ interface ViewToggleProps {
}
// Single-button sliding toggle. The whole pill is the click target —
// clicking anywhere on it flips between video and image. A primary-
// coloured indicator translates between the two icons to show which
// side is currently active.
// clicking anywhere on it flips between video and image. A white
// indicator translates between the two icons to show which side is
// currently active.
function ViewToggle({ view, onChange }: ViewToggleProps) {
const next: View = view === "video" ? "image" : "video";
return (
@@ -233,11 +240,11 @@ function ViewToggle({ view, onChange }: ViewToggleProps) {
type="button"
aria-label={view === "video" ? "Switch to image" : "Switch to video"}
onClick={() => onChange(next)}
className="relative flex h-7 w-14 shrink-0 items-center rounded-full border border-ui-controls-border bg-black/30 p-0.5 transition-colors hover:bg-black/40"
className="relative flex h-7 w-14 shrink-0 items-center border border-ui-controls-border bg-black/30 p-0.5 transition-colors hover:bg-black/40"
>
<span
aria-hidden="true"
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-0.125rem)] rounded-full bg-primary shadow-sm transition-transform duration-200 ease-out"
className="pointer-events-none absolute top-0.5 bottom-0.5 left-0.5 w-[calc(50%-0.125rem)] bg-white transition-transform duration-200 ease-out"
style={{
transform: view === "image" ? "translateX(100%)" : "translateX(0%)",
}}
@@ -258,7 +265,7 @@ function ToggleIcon({ icon, active }: ToggleIconProps) {
<span
className={
"relative z-10 flex h-full flex-1 items-center justify-center transition-colors " +
(active ? "text-white" : "text-base-fg/60")
(active ? "text-black" : "text-base-fg/60")
}
>
<DynamicIcon icon={icon} className="h-3 w-3" />
@@ -297,14 +304,12 @@ function Card({
onToggleExpanded();
};
return (
<div className="glass pointer-events-auto overflow-hidden rounded-xl shadow-xl border-2 border-primary">
<div className="glass pointer-events-auto overflow-hidden border border-primary">
<div className="flex items-center justify-between gap-3 border-b border-ui-controls-border/60 px-3 py-2">
<div className="flex min-w-0 items-center gap-2">
<WandSparklesIcon
className="h-3 w-3 shrink-0 text-primary" />
<WandSparklesIcon className="h-3 w-3 shrink-0 text-primary" />
<div className="min-w-0 leading-tight">
<div className="text-xs font-semibold uppercase tracking-wider text-base-fg">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
Rendered Output
</div>
<div className="truncate text-[10px] text-base-fg/50">
@@ -322,9 +327,9 @@ function Card({
? "Collapse to picture-in-picture"
: "Hide rendered output"
}
className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
className="flex h-6 w-6 shrink-0 items-center justify-center text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
>
<XIcon className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</button>
</div>
</div>
@@ -339,8 +344,9 @@ function Card({
onToggleExpanded();
}
}}
className={`relative aspect-video w-full bg-black/40 ${isExpanded ? "cursor-zoom-out" : "cursor-zoom-in"
}`}
className={`relative aspect-video w-full bg-black/40 ${
isExpanded ? "cursor-zoom-out" : "cursor-zoom-in"
}`}
>
{children}
<button
@@ -349,8 +355,10 @@ function Card({
e.stopPropagation();
onToggleExpanded();
}}
aria-label={isExpanded ? "Collapse output view" : "Expand output view"}
className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center rounded-md border border-white/10 bg-black/60 text-white/80 shadow-lg backdrop-blur-md transition-colors duration-150 hover:bg-black/80 hover:text-white"
aria-label={
isExpanded ? "Collapse output view" : "Expand output view"
}
className="absolute bottom-2 right-2 flex h-7 w-7 items-center justify-center border border-white/15 bg-black/60 text-white/80 transition-colors duration-150 hover:bg-black/80 hover:text-white"
>
<DynamicIcon
icon={isExpanded ? MinimizeIcon : MaximizeIcon}
@@ -63,9 +63,9 @@ function CollapsedPill({ onClick }: { onClick: () => void }) {
<button
type="button"
onClick={onClick}
className="flex items-center gap-2.5 rounded-full border border-primary/40 bg-gradient-to-r from-primary/90 to-primary px-5 py-2.5 text-sm font-semibold text-white transition-colors hover:from-primary hover:to-primary animate-in fade-in slide-in-from-bottom-2 duration-300"
className="flex items-center gap-2.5 bg-white px-5 py-2.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors hover:bg-white/80 animate-in fade-in slide-in-from-bottom-2 duration-300"
>
<BoxesIcon className="h-3.5 w-3.5" />
<BoxesIcon className="h-3.5 w-3.5" />
<span>See other demo scenes</span>
</button>
);
@@ -79,13 +79,11 @@ interface ExpandedPanelProps {
function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
return (
<div className="glass w-[860px] max-w-[90vw] overflow-hidden rounded-xl border border-ui-controls-border shadow-2xl animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="glass w-[860px] max-w-[90vw] overflow-hidden rounded-[3px] border border-ui-controls-border animate-in fade-in slide-in-from-bottom-2 duration-200">
<div className="flex items-center justify-between border-b border-ui-controls-border/60 px-4 py-2.5">
<div className="flex items-center gap-2">
<LayersIcon
className="h-3 w-3 text-primary" />
<div className="text-xs font-semibold uppercase tracking-wider text-base-fg">
<LayersIcon className="h-3 w-3 text-primary" />
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-base-fg">
Other demo scenes
</div>
</div>
@@ -93,9 +91,9 @@ function ExpandedPanel({ scenes, onClose, onPick }: ExpandedPanelProps) {
type="button"
onClick={onClose}
aria-label="Close other scenes"
className="flex h-6 w-6 items-center justify-center rounded-full text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
className="flex h-6 w-6 items-center justify-center text-base-fg/60 transition-colors hover:bg-ui-controls hover:text-base-fg"
>
<XIcon className="h-4 w-4" />
<XIcon className="h-4 w-4" />
</button>
</div>
<div className="grid grid-cols-2 gap-3 p-3 sm:grid-cols-3">
@@ -386,7 +386,7 @@ function MobileGate() {
return (
<div className="flex h-full w-full items-center justify-center p-6">
<div className="flex max-w-sm flex-col items-center gap-4 text-center">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl border border-amber-400/30 bg-amber-500/25 text-amber-300">
<div className="flex h-14 w-14 items-center justify-center border border-amber-400/30 bg-amber-500/25 text-amber-300">
<MonitorIcon className="text-xl" />
</div>
<h1 className="text-xl font-semibold text-white">
@@ -1,7 +1,7 @@
// One reusable card for the edit-3D splash. The blank-scene variant
// renders a dashed plus tile; the example variant renders a thumbnail
// of the scene's rendered output (with the prompt's first reference
// image cross-fading in on hover) over the accent gradient fallback.
// image cross-fading in on hover) over the accent surface fallback.
// Sharing one component keeps the hover/border/typography treatment
// consistent across the grid.
@@ -29,7 +29,7 @@ interface ExampleCardProps {
type SceneSplashCardProps = BlankCardProps | ExampleCardProps;
const SHELL_CLASS =
"group flex flex-col overflow-hidden rounded-xl border bg-white/[0.03] text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-primary/60";
"group flex flex-col overflow-hidden border bg-white/[0.03] text-left transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-white/60";
export function SceneSplashCard(props: SceneSplashCardProps) {
return (
@@ -53,8 +53,8 @@ function CardThumbnail(props: SceneSplashCardProps) {
if (props.variant === "blank") {
return (
<div className="flex aspect-video items-center justify-center bg-white/[0.02]">
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-white/55 transition-colors group-hover:bg-primary/15 group-hover:text-primary">
<PlusIcon className="text-sm" />
<div className="flex h-9 w-9 items-center justify-center bg-white/5 text-white/55 transition-colors group-hover:bg-white/10 group-hover:text-white">
<PlusIcon className="text-sm" />
</div>
</div>
);
@@ -78,10 +78,7 @@ function ExampleThumbnail({
return (
<div
className={twMerge(
"relative aspect-video overflow-hidden bg-gradient-to-br",
accentClass,
)}
className={twMerge("relative aspect-video overflow-hidden", accentClass)}
>
{previewUrl && (
<img
@@ -99,7 +96,7 @@ function ExampleThumbnail({
className="absolute inset-0 h-full w-full select-none object-cover opacity-0 transition-opacity duration-200 group-hover:opacity-100"
/>
)}
<span className="absolute top-2 left-2 rounded-full bg-black/55 px-2 py-0.5 text-[10px] font-medium uppercase tracking-wider text-white/75 backdrop-blur-sm">
<span className="absolute top-2 left-2 bg-black/55 px-2 py-0.5 font-mono text-[10px] font-semibold uppercase tracking-[0.12em] text-white/75">
Preview
</span>
</div>
@@ -125,7 +122,7 @@ function CardCaption({
// - previewUrl: the rendered output image (the "after"), shown by default
// - hoverUrl: the prompt's first reference image (the "before"), shown
// on hover so users can see what the scene generated FROM
// Failures stay silent — the card's accent gradient is a graceful fallback.
// Failures stay silent, the card's accent surface is a graceful fallback.
function useSceneCardImages(outputToken: string): {
previewUrl: string | null;
hoverUrl: string | null;
@@ -157,7 +154,7 @@ function useSceneCardImages(outputToken: string): {
promptResp?.data?.maybe_context_images?.[0]?.media_links?.cdn_url;
if (firstRef) setHoverUrl(firstRef);
} catch {
// Decorative imagery — gradient fallback is acceptable.
// Decorative imagery, the fallback surface is acceptable.
}
})();
@@ -1,6 +1,6 @@
// Edit-3D splash modal. Mirrors the design language of
// SignupCtaModal (bg-[#161618], border-white/10, off-center primary
// glow, font-display heading) so the editor's onboarding modal feels
// SignupCtaModal (flat near-black surface, hairline border,
// font-display heading) so the editor's onboarding modal feels
// like the rest of the webapp, not a one-off.
import { Modal } from "@storyteller/ui-modal";
@@ -22,7 +22,7 @@ export function SceneSplashModal({
<Modal
isOpen={isOpen}
onClose={close}
className="rounded-2xl w-full max-w-3xl overflow-hidden border border-white/10 bg-[#161618] p-0 shadow-[0_20px_60px_-15px_rgba(0,0,0,0.6)]"
className="w-full max-w-3xl overflow-hidden border border-white/15 bg-ui-modal p-0"
childPadding={false}
backdropClassName="!bg-black/80"
closeOnOutsideClick
@@ -32,11 +32,7 @@ export function SceneSplashModal({
<div className="relative overflow-hidden">
<div
aria-hidden
className="pointer-events-none absolute -top-24 -right-16 h-64 w-64 rounded-full bg-primary/25 blur-[80px]"
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-gradient-to-r from-transparent via-white/15 to-transparent"
className="pointer-events-none absolute inset-x-0 top-0 h-px bg-white/15"
/>
<div className="relative px-8 pt-10 pb-8 sm:px-10 sm:pt-12 sm:pb-10">
@@ -8,7 +8,7 @@ export interface ExampleScene {
id: string;
title: string;
description: string;
// Tailwind gradient class fragment used by the card's thumbnail. Kept
// Tailwind class fragment used by the card's thumbnail fallback. Kept
// as a class string (not a CSS color) so JIT picks it up at build time.
accentClass: string;
sceneToken: string;
@@ -27,7 +27,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
id: "lone-drifter",
title: "Lone Drifter",
description: "Sundown standoff, dust on the wind",
accentClass: "from-orange-500/30 to-amber-700/20",
accentClass: "bg-orange-500/15",
sceneToken: "m_ywnjq1bdjw2163p456tczbg5p7ean8",
outputToken: "m_9n2w5c4teefm21nhsceex3degeca9h",
},
@@ -35,7 +35,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
id: "black-sails",
title: "Black Sails",
description: "Galleon at dawn, salt on the bow",
accentClass: "from-slate-700/30 to-teal-800/20",
accentClass: "bg-teal-500/15",
sceneToken: PLACEHOLDER_SCENE_TOKEN,
outputToken: PLACEHOLDER_OUTPUT_TOKEN,
},
@@ -43,7 +43,7 @@ export const EXAMPLE_SCENES: readonly ExampleScene[] = [
id: "cold-brew-hero",
title: "Cold Brew",
description: "Studio bottle shot, soft rim light",
accentClass: "from-amber-900/30 to-stone-700/20",
accentClass: "bg-amber-500/15",
sceneToken: "m_c8earcb2pnhb4n6pnbcfn01hg9vn9w",
outputToken: "m_a5y9cvskk8xe2y4j3hjskrha86x65q",
},
@@ -14,10 +14,10 @@ import { Reveal } from "../../components/motion/reveal";
const SeedanceBanner = () => (
<div className="flex flex-col gap-5">
<div className="flex flex-wrap items-center gap-2">
<span className="inline-flex items-center text-[11px] font-semibold uppercase tracking-[0.18em] text-primary-200 bg-primary/[0.12] border border-primary/25 rounded-full px-3 py-1">
<span className="inline-flex items-center font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white bg-white/10 border border-white/30 px-3 py-1">
Early access
</span>
<span className="inline-flex items-center text-[11px] font-semibold uppercase tracking-[0.18em] text-white/65 bg-white/[0.04] border border-white/[0.08] rounded-full px-3 py-1">
<span className="inline-flex items-center font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/65 bg-white/5 border border-white/15 px-3 py-1">
Available today in ArtCraft
</span>
</div>
@@ -33,7 +33,7 @@ const SeedanceBanner = () => (
</p>
</div>
<div className="relative w-full rounded-2xl sm:rounded-[24px] overflow-hidden bg-[#080808] border border-white/[0.08]">
<div className="relative w-full overflow-hidden bg-[#080808] border border-white/15">
<div style={{ paddingTop: "56.25%" }} className="relative">
<iframe
src="https://player.vimeo.com/video/1169289718?autoplay=1&muted=1&loop=1&background=0&byline=0&portrait=0&title=0"
@@ -46,16 +46,16 @@ const SeedanceBanner = () => (
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-xl bg-gradient-to-br from-primary/15 via-white/[0.03] to-white/[0.02] border border-primary/25 p-4">
<div className="text-[12px] font-semibold uppercase tracking-wider text-primary mb-1">
<div className="bg-[#101014] border border-white/30 p-4">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white mb-1">
Seedance video credits
</div>
<div className="text-white/55 text-sm leading-snug">
Included with every paid ArtCraft plan
</div>
</div>
<div className="rounded-xl bg-[#080808] border border-white/[0.08] p-4">
<div className="text-[12px] font-semibold uppercase tracking-wider text-white/70 mb-1">
<div className="bg-[#101014] border border-white/15 p-4">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 mb-1">
First in the world
</div>
<div className="text-white/55 text-sm leading-snug">
@@ -73,10 +73,10 @@ const BuyMoreCreditsCta = ({ onClick }: { onClick: () => void }) => (
<p className="text-base text-white/65">Need more credits?</p>
<Button
variant="secondary"
className="mt-3 gap-2 rounded-full border border-white/[0.1] bg-white/[0.06] hover:bg-white/[0.1] px-5 py-2 h-11 text-[14px] font-semibold text-white"
className="mt-3 gap-2 border border-white/15 bg-transparent hover:bg-white/10 px-5 py-2 h-11 text-white"
onClick={onClick}
>
<CoinsIcon className="text-primary text-[13px]" />
<CoinsIcon className="text-[13px]" />
Buy more credits
</Button>
</div>
@@ -113,25 +113,12 @@ const Pricing = () => {
}, []);
return (
<div className="relative min-h-full bg-[#101014] text-white">
<div className="relative min-h-full bg-ui-background text-white">
<Seo
title="Pricing - ArtCraft"
description="Simple, transparent pricing for ArtCraft. Start for free and scale as you grow."
/>
{/* Subtle radial accent at top, matches landing3. Clipped to its own
wrapper so the outer page can grow naturally and let SidebarInset
handle scrolling. */}
<div className="absolute inset-0 overflow-hidden pointer-events-none z-0">
<div
className="absolute inset-x-0 top-0 h-[700px]"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
</div>
<main className="relative z-10 px-4 sm:px-8 pt-10">
{isSeedanceRef ? (
<div className="max-w-6xl mx-auto grid grid-cols-1 lg:grid-cols-[5fr_7fr] gap-8 xl:gap-12 items-start">
@@ -152,7 +139,12 @@ const Pricing = () => {
) : (
<div className="max-w-6xl mx-auto">
<PricingPromoBanner className="mb-10 sm:mb-12" />
<Reveal as="div" inView={false} y={20} className="text-center mb-14">
<Reveal
as="div"
inView={false}
y={20}
className="text-center mb-14"
>
<h1 className="text-4xl sm:text-5xl md:text-6xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
Invest in <span className="font-serif-italic">yourself</span>.
</h1>
@@ -187,12 +179,10 @@ const Pricing = () => {
</p>
<Button
variant="secondary"
className="mt-4 gap-2 rounded-full border border-white/[0.1] bg-white/[0.06] hover:bg-white/[0.1] px-5 py-2 h-11 text-[14px] font-semibold text-white"
className="mt-4 gap-2 border border-white/15 bg-transparent hover:bg-white/10 px-5 py-2 h-11 text-white"
onClick={() => setCreditsModalOpen(true)}
>
<CoinsIcon
className="text-primary text-[13px]" />
<CoinsIcon className="text-[13px]" />
Buy Credits
</Button>
</div>
@@ -120,7 +120,7 @@ export default function Referrals() {
if (!authChecked) {
return (
<div className="flex min-h-full items-center justify-center bg-[#101014]">
<div className="flex min-h-full items-center justify-center bg-ui-background">
<LoadingSpinner className="h-10 w-10 text-white/60" />
</div>
);
@@ -133,25 +133,16 @@ export default function Referrals() {
const profileLink = `${SHARE_ORIGIN}/?u=${encodeURIComponent(user.username)}`;
return (
<div className="relative min-h-full w-full bg-[#101014] text-white">
<div className="relative min-h-full w-full bg-ui-background text-white">
<Seo
title="Referrals - ArtCraft"
description="Create and share referral links to invite people to ArtCraft."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0 overflow-hidden"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 px-4 sm:px-8 pt-10 pb-10">
<div className="max-w-3xl mx-auto">
<header className="text-center mb-8">
<span className="inline-block text-xs font-semibold uppercase tracking-[0.18em] text-primary mb-5">
<span className="inline-block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-5">
Share the craft
</span>
<h1 className="text-5xl tracking-[-0.035em] font-medium leading-[1.02] mb-5">
@@ -166,16 +157,16 @@ export default function Referrals() {
<div className="my-6 sm:my-8 flex items-center justify-center gap-3 text-white/40">
<div className="h-px w-10 bg-white/20" />
<span className="text-xs font-semibold uppercase tracking-[0.18em]">
<span className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em]">
Or
</span>
<div className="h-px w-10 bg-white/20" />
</div>
<section className="rounded-2xl bg-[#1C1C20] border border-white/10 p-6 sm:p-8 shadow-2xl">
<section className="bg-[#101014] border border-white/15 p-6 sm:p-8">
<div className="flex items-start justify-between gap-4 mb-5">
<div>
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary mb-2">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-2">
Option 2
</div>
<h2 className="text-lg font-medium text-white">
@@ -196,7 +187,7 @@ export default function Referrals() {
<LoadingSpinner className="h-7 w-7 text-white/60" />
</div>
) : listError ? (
<div className="rounded-xl bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
<div className="bg-red-500/10 border border-red-500/20 px-4 py-3 text-sm text-red-400">
{listError}
</div>
) : codes.length === 0 ? (
@@ -222,7 +213,7 @@ export default function Referrals() {
<form onSubmit={handleCreate} className="mt-6 space-y-2">
<label
htmlFor="new-referral-code"
className="block text-xs font-bold uppercase tracking-wide text-white/60 ml-1"
className="block font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1"
>
New code
</label>
@@ -240,14 +231,14 @@ export default function Referrals() {
}
disabled={atLimit || creating}
maxLength={REFERRAL_CODE_MAX_LENGTH}
className="flex-1 h-11 rounded-xl bg-black/20 border border-white/10 focus:border-primary/50 px-4 text-sm text-white placeholder-white/25 outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
className="flex-1 h-11 bg-ui-controls border border-white/15 focus:border-white/40 px-4 text-sm text-white placeholder-white/25 outline-none transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
/>
<button
type="submit"
disabled={submitDisabled}
className="inline-flex h-11 items-center justify-center gap-2 rounded-full bg-primary hover:bg-primary-600 px-6 text-[14px] font-semibold text-white transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
className="inline-flex h-11 items-center justify-center gap-2 bg-white hover:bg-white/80 px-6 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-black transition-colors disabled:opacity-40 disabled:cursor-not-allowed"
>
<PlusIcon className="text-[12px]" />
<PlusIcon className="text-[12px]" />
{creating ? "Creating…" : "Create"}
</button>
</div>
@@ -275,9 +266,9 @@ export default function Referrals() {
function ProfileLinkPanel({ profileLink }: { profileLink: string }) {
return (
<section className="rounded-2xl bg-[#1C1C20] border border-white/10 p-6 sm:p-8 shadow-2xl">
<section className="bg-[#101014] border border-white/15 p-6 sm:p-8">
<div className="mb-4">
<div className="text-[11px] font-semibold uppercase tracking-[0.18em] text-primary mb-2">
<div className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 mb-2">
Option 1
</div>
<h2 className="text-lg font-medium text-white">
@@ -316,13 +307,13 @@ function ReferralCodeRow({
});
return (
<li className="rounded-xl bg-black/20 border border-white/[0.08] px-4 py-3.5">
<li className="bg-ui-controls border border-white/15 px-4 py-3.5">
<div className="flex items-center gap-3 mb-3">
<div className="flex-1 min-w-0">
<div className="text-[15px] font-medium text-white truncate">
{entry.code}
</div>
<div className="mt-0.5 text-[11px] uppercase tracking-wide text-white/40">
<div className="mt-0.5 font-mono text-[11px] uppercase tracking-[0.12em] text-white/40">
Created {createdLabel}
</div>
</div>
@@ -332,7 +323,7 @@ function ReferralCodeRow({
type="button"
onClick={onCancelDelete}
disabled={isDeleting}
className="h-9 px-3 rounded-full text-xs font-semibold text-white/70 hover:text-white hover:bg-white/[0.06] transition-colors disabled:opacity-50"
className="h-9 px-3 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/70 hover:text-white hover:bg-white/10 transition-colors disabled:opacity-50"
>
Cancel
</button>
@@ -340,7 +331,7 @@ function ReferralCodeRow({
type="button"
onClick={onConfirmDelete}
disabled={isDeleting}
className="h-9 px-3.5 rounded-full text-xs font-semibold text-white bg-red-500/85 hover:bg-red-500 transition-colors disabled:opacity-50"
className="h-9 px-3.5 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white bg-red-500/85 hover:bg-red-500 transition-colors disabled:opacity-50"
>
{isDeleting ? "Deleting…" : "Delete"}
</button>
@@ -349,10 +340,10 @@ function ReferralCodeRow({
<button
type="button"
onClick={onRequestDelete}
className="h-9 w-9 inline-flex items-center justify-center rounded-full text-white/45 hover:text-white hover:bg-white/[0.06] transition-colors"
className="h-9 w-9 inline-flex items-center justify-center text-white/45 hover:text-white hover:bg-white/10 transition-colors"
title="Delete referral code"
>
<Trash2Icon className="text-[13px]" />
<Trash2Icon className="text-[13px]" />
</button>
)}
</div>
@@ -381,12 +372,12 @@ function CopyableLink({ value }: { value: string }) {
value={value}
readOnly
onFocus={(e) => e.currentTarget.select()}
className="flex-1 min-w-0 h-11 rounded-xl bg-black/20 border border-white/10 focus:border-primary/50 px-4 text-sm text-white/80 font-mono outline-none transition-colors"
className="flex-1 min-w-0 h-11 bg-ui-controls border border-white/15 focus:border-white/40 px-4 text-sm text-white/80 font-mono outline-none transition-colors"
/>
<button
type="button"
onClick={handleCopy}
className="inline-flex h-11 items-center gap-2 rounded-full bg-white/[0.06] hover:bg-white/[0.1] border border-white/[0.1] px-4 text-xs font-semibold text-white/85 transition-all"
className="inline-flex h-11 items-center gap-2 bg-transparent hover:bg-white/10 border border-white/15 px-4 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/85 transition-colors"
>
<DynamicIcon
icon={copied ? CheckIcon : CopyIcon}
@@ -25,10 +25,8 @@ const SetPassword = () => {
// a password) from being misrouted here.
if (!authChecked) {
return (
<div className="flex min-h-screen items-center justify-center bg-[#101014]">
<LoaderCircleIcon
className="animate-spin text-4xl text-primary/80" />
<div className="flex min-h-screen items-center justify-center bg-ui-background">
<LoaderCircleIcon className="animate-spin text-4xl text-white/80" />
</div>
);
}
@@ -68,22 +66,14 @@ const SetPassword = () => {
};
return (
<div className="relative min-h-screen bg-[#101014] text-white overflow-hidden flex flex-col">
<div className="relative min-h-screen bg-ui-background text-white overflow-hidden flex flex-col">
<Seo
title="Set Password - ArtCraft"
description="Set a password for your ArtCraft account."
/>
<div
aria-hidden
className="pointer-events-none absolute inset-x-0 top-0 h-[700px] z-0"
style={{
background:
"radial-gradient(ellipse 60% 50% at 50% 0%, rgba(45,129,255,0.18) 0%, transparent 70%)",
}}
/>
<main className="relative z-10 flex-1 flex items-center justify-center p-4">
<div className="w-full max-w-md bg-[#1C1C20] border border-white/[4%] rounded-2xl p-6 py-8 shadow-2xl">
<div className="w-full max-w-md bg-[#101014] border border-white/15 p-6 py-8">
<div className="text-center mb-8">
<img
src="/images/artcraft-icon.png"
@@ -105,13 +95,13 @@ const SetPassword = () => {
}}
>
{error && (
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 rounded-xl text-sm text-center">
<div className="bg-red-500/10 border border-red-500/20 text-red-500 px-4 py-3 text-sm text-center">
{error}
</div>
)}
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Password
</label>
<div className="relative">
@@ -121,7 +111,7 @@ const SetPassword = () => {
onChange={(e) => setPassword(e.target.value)}
placeholder="Min. 8 characters"
autoFocus
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -135,7 +125,7 @@ const SetPassword = () => {
</div>
<div className="space-y-2">
<label className="text-xs font-bold text-white/60 uppercase tracking-wide ml-1">
<label className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/60 ml-1">
Confirm password
</label>
<div className="relative">
@@ -144,7 +134,7 @@ const SetPassword = () => {
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
placeholder="Re-enter your password"
inputClassName="w-full bg-black/40 border border-white/10 focus:border-primary/50 rounded-xl px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
inputClassName="w-full bg-ui-controls border border-white/15 focus:border-white/40 px-4 py-3 text-white placeholder-white/20 outline-none transition-colors pr-12"
/>
<button
type="button"
@@ -161,14 +151,12 @@ const SetPassword = () => {
<div className="pt-2">
<Button
className="rounded-full w-full bg-primary hover:bg-primary-600 text-white border-none justify-center font-bold h-10"
className="w-full justify-center h-10"
type="submit"
disabled={isLoading}
>
{isLoading ? (
<LoaderCircleIcon
className="animate-spin" />
<LoaderCircleIcon className="animate-spin" />
) : (
"Set password"
)}
@@ -44,7 +44,7 @@ const Support = () => {
"Get help with ArtCraft: browse the FAQ, join our Discord community, or email us directly.";
return (
<div className="relative min-h-screen bg-[#101014] text-white">
<div className="relative min-h-screen bg-ui-background text-white">
<Seo title={title} description={description} />
<main className="relative z-10 mx-auto w-full max-w-4xl px-4 sm:px-6 pt-24 sm:pt-32 pb-20">
@@ -58,7 +58,7 @@ const Support = () => {
</p>
</header>
<ul className="divide-y divide-white/[0.06] border border-white/[0.06] rounded-xl overflow-hidden bg-white/[0.02]">
<ul className="divide-y divide-white/15 border border-white/15 overflow-hidden bg-[#101014]">
{ROWS.map((row) => (
<li key={row.title}>
<SupportLink row={row} />
@@ -72,13 +72,13 @@ const Support = () => {
const SupportLink = ({ row }: { row: SupportRow }) => {
const className =
"group flex items-center gap-4 px-4 py-4 sm:px-5 sm:py-5 transition-colors duration-150 hover:bg-white/[0.03]";
"group flex items-center gap-4 rounded-[3px] px-4 py-4 sm:px-5 sm:py-5 transition-colors duration-150 hover:bg-white/[0.03]";
const content = (
<>
<div
className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-md border transition-colors ${
row.iconBgClass ?? "bg-white/[0.04] border-white/[0.06]"
className={`flex h-10 w-10 shrink-0 items-center justify-center border transition-colors ${
row.iconBgClass ?? "bg-white/[0.04] border-white/15"
} ${row.iconColorClass ?? "text-white/70 group-hover:text-white"}`}
>
<DynamicIcon icon={row.icon} />
@@ -18,8 +18,8 @@ import {
// Landing view for /video-editor (no project in the URL): the user's saved
// video projects plus a "New project" tile. Styled to match the create
// pages and library: #101014 canvas, glow orbs, eyebrow + display heading,
// ui-controls cards with primary hover rings, kebab menus, and the app's
// pages and library: near-black canvas, eyebrow + display heading,
// ui-controls cards with hairline hover rings, kebab menus, and the app's
// standard confirm dialog.
const GRID_CLASSES =
@@ -128,14 +128,7 @@ export function VideoEditorProjectsLanding({
};
return (
<div className="relative h-full w-full overflow-y-auto bg-[#101014] text-white">
{/* Video-themed glow orbs (create-video recipe; absolute, not fixed,
since this page scrolls inside SidebarInset). */}
<div className="pointer-events-none absolute inset-0 z-0 overflow-hidden">
<div className="absolute left-1/2 top-[-10%] h-[700px] w-[700px] -translate-x-1/2 transform-gpu rounded-full bg-gradient-to-br from-blue-700 via-blue-500 to-[#00AABA] opacity-[0.10] blur-[120px]" />
<div className="absolute bottom-[-15%] left-[-10%] h-[500px] w-[500px] transform-gpu rounded-full bg-gradient-to-br from-[#00AABA] via-blue-500 to-purple-600 opacity-[0.07] blur-[120px]" />
</div>
<div className="relative h-full w-full overflow-y-auto bg-ui-background text-white">
{/* The global topbar is hidden on /video-editor routes; surface its
actions here like the editor header does. */}
<div className="absolute right-4 top-3 z-20">
@@ -144,7 +137,7 @@ export function VideoEditorProjectsLanding({
<div className="relative z-10 mx-auto w-full max-w-[1600px] px-3 pb-16 pt-10 sm:px-4 md:px-8 lg:px-12">
<header className="animate-fade-in-up">
<p className="text-[11px] font-semibold uppercase tracking-wider text-white/40">
<p className="font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white/40">
Video editor
</p>
<h1 className="mt-1.5 font-display text-3xl font-semibold md:text-4xl">
@@ -193,7 +186,7 @@ export function VideoEditorProjectsLanding({
variant="primary"
icon={SparklesIcon}
onClick={onNewProject}
className="mt-8 h-12 rounded-full px-6 text-base font-semibold"
className="mt-8 h-12 px-6"
>
New project
</Button>
@@ -202,9 +195,7 @@ export function VideoEditorProjectsLanding({
{status === "error" && (
<div className="flex flex-col items-center justify-center gap-4 py-28 text-center">
<TriangleAlertIcon
className="text-3xl text-white/30" />
<TriangleAlertIcon className="text-3xl text-white/30" />
<div>
<div className="text-lg font-medium">
Couldn't load your projects
@@ -1,5 +1,11 @@
import { useState } from "react";
import { ClapperboardIcon, EllipsisIcon, PenIcon, PlusIcon, Trash2Icon } from "lucide-react";
import {
ClapperboardIcon,
EllipsisIcon,
PenIcon,
PlusIcon,
Trash2Icon,
} from "lucide-react";
import { twMerge } from "tailwind-merge";
import { PopoverMenu } from "@storyteller/ui-popover";
import type { ProjectMeta } from "@storyteller/ui-video-editor";
@@ -7,19 +13,18 @@ import { formatTimeAgo } from "../../../lib/format-time-ago";
// Card, skeleton, and "new project" tile for the video projects landing.
// Visual language follows the library/create-page cards: ui-controls
// surface, primary hover ring, hover-revealed kebab menu, and the house
// surface, hairline hover ring, hover-revealed kebab menu, and the house
// dashed recipe for the new-item tile.
// Deterministic per-project placeholder art so thumbnail-less cards don't
// all look identical (blue/teal/purple/pink families, matching the
// create-page glow orbs).
const PLACEHOLDER_GRADIENTS = [
"from-blue-600/30 via-blue-500/15 to-[#00AABA]/20",
"from-purple-600/30 via-blue-500/15 to-pink-500/20",
"from-[#00AABA]/30 via-blue-600/15 to-purple-500/20",
"from-pink-500/25 via-purple-500/15 to-blue-600/20",
"from-indigo-500/30 via-blue-500/15 to-teal-500/20",
"from-sky-500/25 via-indigo-500/15 to-purple-600/20",
// all look identical (flat blue/teal/purple/pink tints).
const PLACEHOLDER_TINTS = [
"bg-blue-600/20",
"bg-purple-600/20",
"bg-[#00AABA]/20",
"bg-pink-500/15",
"bg-indigo-500/20",
"bg-sky-500/15",
];
const STAGGER_STEP_MS = 30;
@@ -48,7 +53,7 @@ export function VideoProjectCard({
return (
<div
className={twMerge(
"animate-fade-in-up group relative flex flex-col overflow-hidden rounded-xl bg-ui-controls/40 ring-1 ring-white/5 transition-all duration-200 hover:ring-2 hover:ring-primary-400/60",
"animate-fade-in-up group relative flex flex-col overflow-hidden rounded-[3px] bg-ui-controls/40 ring-1 ring-white/15 transition-all duration-200 hover:ring-white/40",
isDeleting && "pointer-events-none opacity-50",
)}
style={{
@@ -73,17 +78,15 @@ export function VideoProjectCard({
) : (
<div
className={twMerge(
"flex h-full w-full items-center justify-center bg-gradient-to-br",
gradientForProject(project.id),
"flex h-full w-full items-center justify-center",
tintForProject(project.id),
)}
>
<ClapperboardIcon
className="text-3xl text-white/25 transition-colors group-hover:text-white/40" />
<ClapperboardIcon className="text-3xl text-white/25 transition-colors group-hover:text-white/40" />
</div>
)}
<div className="absolute inset-0 flex items-end bg-gradient-to-t from-black/70 to-transparent p-3 opacity-0 transition-opacity duration-200 group-hover:opacity-100">
<span className="rounded-full bg-white/15 px-3 py-1 text-xs font-semibold text-white backdrop-blur-sm">
<span className="bg-white/15 px-3 py-1 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] text-white">
Open project
</span>
</div>
@@ -106,10 +109,8 @@ export function VideoProjectCard({
position="bottom"
align="end"
mode="default"
triggerIcon={
<EllipsisIcon className="text-base-fg" />
}
buttonClassName="h-7 w-7 p-0 rounded-full bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
triggerIcon={<EllipsisIcon className="text-base-fg" />}
buttonClassName="h-7 w-7 p-0 bg-ui-controls/60 hover:bg-ui-controls/90 text-base-fg border border-ui-controls-border"
panelClassName="w-max min-w-44 p-1"
closeOnUnhover
>
@@ -117,26 +118,26 @@ export function VideoProjectCard({
<div className="flex flex-col">
<button
type="button"
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-base-fg hover:bg-ui-controls/60"
className="flex w-full items-center gap-2 whitespace-nowrap px-2 py-2 text-sm text-base-fg hover:bg-ui-controls/60"
onClick={(e) => {
e.stopPropagation();
close();
onRename();
}}
>
<PenIcon className="w-3.5" />
<PenIcon className="w-3.5" />
Rename
</button>
<button
type="button"
className="flex w-full items-center gap-2 whitespace-nowrap rounded-md px-2 py-2 text-sm text-red hover:bg-red/10"
className="flex w-full items-center gap-2 whitespace-nowrap px-2 py-2 text-sm text-red hover:bg-red/10"
onClick={(e) => {
e.stopPropagation();
close();
onDelete();
}}
>
<Trash2Icon className="w-3.5" />
<Trash2Icon className="w-3.5" />
Delete
</button>
</div>
@@ -149,11 +150,11 @@ export function VideoProjectCard({
export function VideoProjectCardSkeleton() {
return (
<div className="flex flex-col overflow-hidden rounded-xl ring-1 ring-white/5">
<div className="flex flex-col overflow-hidden rounded-[3px] ring-1 ring-white/15">
<div className="animate-shimmer aspect-video w-full bg-white/[0.04]" />
<div className="space-y-2 px-3 py-3">
<div className="animate-shimmer h-3.5 w-2/3 rounded bg-white/[0.06]" />
<div className="animate-shimmer h-3 w-1/3 rounded bg-white/[0.05]" />
<div className="animate-shimmer h-3.5 w-2/3 bg-white/[0.06]" />
<div className="animate-shimmer h-3 w-1/3 bg-white/[0.05]" />
</div>
</div>
);
@@ -164,9 +165,9 @@ export function NewProjectTile({ onClick }: { onClick: () => void }) {
<button
type="button"
onClick={onClick}
className="group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2.5 rounded-xl border border-dashed border-white/20 bg-white/[0.02] py-10 transition-colors hover:border-white/40 hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/60"
className="group flex h-full w-full cursor-pointer flex-col items-center justify-center gap-2.5 border border-dashed border-white/20 bg-white/[0.02] py-10 transition-colors hover:border-white/40 hover:bg-white/[0.05] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-white/60"
>
<span className="flex h-9 w-9 items-center justify-center rounded-full bg-white/5 text-white/55 transition-colors group-hover:bg-primary/15 group-hover:text-primary">
<span className="flex h-9 w-9 items-center justify-center bg-white/5 text-white/55 transition-colors group-hover:bg-white/15 group-hover:text-white">
<PlusIcon />
</span>
<span className="text-sm font-medium text-white/70">New project</span>
@@ -174,10 +175,10 @@ export function NewProjectTile({ onClick }: { onClick: () => void }) {
);
}
function gradientForProject(id: string): string {
function tintForProject(id: string): string {
let hash = 0;
for (let i = 0; i < id.length; i++) {
hash = (hash * 31 + id.charCodeAt(i)) >>> 0;
}
return PLACEHOLDER_GRADIENTS[hash % PLACEHOLDER_GRADIENTS.length];
return PLACEHOLDER_TINTS[hash % PLACEHOLDER_TINTS.length];
}
@@ -117,9 +117,7 @@ export default function VideoEditorPage() {
return (
<VideoEditorProjectsLanding
projectStorage={projectStorage}
onNewProject={() =>
navigate(`/video-editor/${makeLocalProjectId()}`)
}
onNewProject={() => navigate(`/video-editor/${makeLocalProjectId()}`)}
onOpenProject={(token) => navigate(`/video-editor/${token}`)}
/>
);
@@ -215,7 +213,7 @@ function VideoEditorSession({
</p>
<button
onClick={() => navigate("/video-editor")}
className="rounded-lg border border-white/15 px-4 py-2 text-sm transition-colors hover:bg-white/10"
className="border border-white/15 px-4 py-2 font-mono text-[11px] font-semibold uppercase tracking-[0.12em] transition-colors hover:bg-white/10"
>
Back to projects
</button>
@@ -1,4 +1,13 @@
import { ArrowRightIcon, BoxIcon, CircleCheckIcon, DownloadIcon, ImageIcon, MonitorIcon, VideoIcon, WandSparklesIcon } from "lucide-react";
import {
ArrowRightIcon,
BoxIcon,
CircleCheckIcon,
DownloadIcon,
ImageIcon,
MonitorIcon,
VideoIcon,
WandSparklesIcon,
} from "lucide-react";
import { DynamicIcon } from "@storyteller/icons";
import { Button } from "@storyteller/ui-button";
import { Link } from "react-router-dom";
@@ -11,25 +20,17 @@ const Welcome = () => {
const downloadUrl = isMacOs ? DOWNLOAD_LINKS.MACOS : DOWNLOAD_LINKS.WINDOWS;
return (
<div className="relative min-h-full bg-[#101014] text-white">
<div className="relative min-h-full bg-ui-background text-white">
<Seo
title="Welcome - ArtCraft"
description="Welcome to ArtCraft. Get started with your subscription."
/>
{/* Background gradient — clipped to its own wrapper so the outer
page can grow naturally and let SidebarInset handle scrolling. */}
<div className="absolute inset-0 overflow-hidden flex items-center justify-center pointer-events-none z-0">
<div className="w-[900px] h-[900px] rounded-full bg-gradient-to-br from-primary/40 via-purple-600/30 to-blue-500/20 opacity-30 blur-[120px]"></div>
</div>
<main className="relative z-10 pt-12 pb-20 px-4 sm:px-6 lg:px-8">
{/* Success Header */}
<div className="text-center mb-12">
<div className="inline-flex items-center gap-3 bg-primary/10 border border-primary/30 rounded-full px-6 py-3 mb-6">
<CircleCheckIcon
className="text-xl text-primary" />
<div className="inline-flex items-center gap-3 bg-white/5 border border-white/15 px-6 py-3 mb-6">
<CircleCheckIcon className="text-xl text-green-400" />
<span className="text-white font-medium">
Account created successfully!
</span>
@@ -56,12 +57,10 @@ const Welcome = () => {
{/* Create-in-browser CTA — desktop download is optional */}
<div className="max-w-3xl mx-auto mb-8">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
<div className="bg-[#101014] border border-white/15 p-8 md:p-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
<WandSparklesIcon
className="text-primary text-lg" />
<div className="w-10 h-10 bg-white/10 flex items-center justify-center">
<WandSparklesIcon className="text-white/70 text-lg" />
</div>
<h2 className="text-2xl font-medium text-white">
Start creating right here
@@ -84,18 +83,16 @@ const Welcome = () => {
<Link
key={item.to}
to={item.to}
className="group flex items-center gap-3 bg-[#252529] hover:bg-[#2D2D32] border border-white/[0.06] hover:border-white/[0.12] rounded-xl px-4 py-3 transition-all"
className="group flex items-center gap-3 rounded-[3px] bg-ui-controls hover:bg-white/10 border border-white/15 hover:border-white/30 px-4 py-3 transition-all"
>
<DynamicIcon
icon={item.icon}
className="text-primary text-base shrink-0"
className="text-white/70 text-base shrink-0"
/>
<span className="text-white/90 font-medium flex-1">
{item.label}
</span>
<ArrowRightIcon
className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
<ArrowRightIcon className="text-white/30 text-[11px] transition-transform group-hover:translate-x-0.5" />
</Link>
))}
</div>
@@ -104,12 +101,10 @@ const Welcome = () => {
{/* Desktop app alternative */}
<div className="max-w-3xl mx-auto">
<div className="bg-[#1A1A1E] border border-white/10 rounded-3xl p-8 md:p-10">
<div className="bg-[#101014] border border-white/15 p-8 md:p-10">
<div className="flex items-center gap-3 mb-2">
<div className="w-10 h-10 rounded-full bg-primary/20 flex items-center justify-center">
<MonitorIcon
className="text-primary text-lg" />
<div className="w-10 h-10 bg-white/10 flex items-center justify-center">
<MonitorIcon className="text-white/70 text-lg" />
</div>
<h2 className="text-2xl font-medium text-white">
Want the desktop app too?
@@ -130,9 +125,9 @@ const Welcome = () => {
].map((item) => (
<div
key={item.step}
className="bg-[#252529] p-4 rounded-xl flex items-center gap-4"
className="bg-ui-controls border border-white/15 p-4 flex items-center gap-4"
>
<div className="w-8 h-8 shrink-0 rounded-full bg-primary/60 flex items-center justify-center text-sm font-bold text-white">
<div className="w-8 h-8 shrink-0 bg-white/10 border border-white/15 flex items-center justify-center font-mono text-[11px] font-semibold text-white">
{item.step}
</div>
<div className="text-white/90 font-medium">{item.text}</div>
@@ -142,7 +137,7 @@ const Welcome = () => {
{/* Download Contingency */}
{!isMobile && (
<div className="pt-6 border-t border-white/10">
<div className="pt-6 border-t border-white/15">
<div className="flex flex-col sm:flex-row items-center justify-between gap-4">
<span className="text-white/50 text-sm">
Download didn't start?
@@ -151,9 +146,9 @@ const Welcome = () => {
<Button
as="link"
href={downloadUrl}
className="rounded-full bg-white text-black hover:bg-gray-100 dark:bg-white dark:text-black dark:hover:bg-gray-200 px-6 py-2.5 text-sm font-bold"
className="bg-white text-black hover:bg-white/80 px-6 py-2.5"
>
<DownloadIcon className="mr-2" />
<DownloadIcon className="mr-2" />
Download for {isMacOs ? "Mac" : "Windows"}
</Button>
<div className="flex gap-4 text-sm font-medium text-white/30">
@@ -161,14 +156,14 @@ const Welcome = () => {
href={DOWNLOAD_LINKS.WINDOWS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
<span className="w-1.5 h-1.5 bg-current"></span>
Windows
</a>
<a
href={DOWNLOAD_LINKS.MACOS}
className="hover:text-white transition-colors flex items-center gap-2"
>
<span className="w-1.5 h-1.5 rounded-full bg-current"></span>
<span className="w-1.5 h-1.5 bg-current"></span>
Mac
</a>
</div>
@@ -178,9 +173,9 @@ const Welcome = () => {
)}
{isMobile && (
<div className="bg-[#431407] border border-orange-900/50 rounded-2xl p-6 text-orange-200 text-sm leading-relaxed">
<div className="bg-[#431407] border border-orange-900/50 p-6 text-orange-200 text-sm leading-relaxed">
<div className="flex items-center justify-center mb-3 text-orange-400">
<MonitorIcon className="text-2xl" />
<MonitorIcon className="text-2xl" />
</div>
ArtCraft is a powerful desktop experience. <br />
Please head to your computer to download and install.
+78 -90
View File
@@ -5,36 +5,37 @@
@layer base {
:root {
/* Sidebar tokens (consumed by tailwind colors in tailwind.config.js).
Stored as raw hex so we can reference them via theme() if needed. */
--sidebar-bg: #1a1a1a;
Brutalist system: near-black surfaces, white/15 hairlines, white
accent for selection/focus. */
--sidebar-bg: #1e1f22;
--sidebar-fg: rgba(255, 255, 255, 0.72);
--sidebar-border: rgba(64, 64, 64, 0.5);
--sidebar-accent: rgba(255, 255, 255, 0.06);
--sidebar-border: rgba(255, 255, 255, 0.15);
--sidebar-accent: rgba(255, 255, 255, 0.08);
--sidebar-accent-fg: #ffffff;
--sidebar-ring: rgba(45, 129, 255, 0.4);
--sidebar-ring: rgba(255, 255, 255, 0.4);
/* shadcn design tokens consumed by @storyteller/ui-video-editor.
Dark-theme palette (the editor only runs in dark mode today).
--primary matches the webapp's brand blue (#2d81ff). */
--background: 0 0% 9%;
--background: 240 5% 8%;
--foreground: 0 0% 98%;
--card: 0 0% 12%;
--card: 240 11% 7%;
--card-foreground: 0 0% 98%;
--popover: 0 0% 12%;
--popover: 240 4% 5%;
--popover-foreground: 0 0% 98%;
--primary: 216 100% 59%;
--primary-foreground: 0 0% 98%;
--secondary: 0 0% 24%;
--secondary: 240 4% 9%;
--secondary-foreground: 0 0% 98%;
--muted: 0 0% 18%;
--muted: 240 4% 9%;
--muted-foreground: 0 0% 64%;
--accent: 0 0% 16%;
--accent: 240 4% 9%;
--accent-foreground: 0 0% 98%;
--destructive: 0 70% 50%;
--destructive-foreground: 0 0% 98%;
--border: 0 0% 18%;
--input: 0 0% 18%;
--ring: 217 91% 60%;
--border: 0 0% 100% / 0.15;
--input: 0 0% 100% / 0.15;
--ring: 0 0% 100% / 0.6;
/* ── Motion tokens ──────────────────────────────────────────────────
Shared easing curves + durations so motion across the app feels like
@@ -71,9 +72,25 @@
}
}
/* Subtle 3px corners on interactive controls. The brutalist system keeps
large surfaces (panels, modals, tables), passive labels, and media tiles
square; anything pressable gets a soft corner for affordance. Explicit
rounding elsewhere uses the same rounded-[3px] value. Tailwind radius
utilities (rounded-full, rounded-none) still win — the utilities layer
is emitted after base. */
@layer base {
button,
select,
textarea,
input:not([type="radio"]):not([type="range"]),
[role="button"] {
border-radius: 3px;
}
}
* {
scrollbar-width: thin;
scrollbar-color: #363636 #171717;
scrollbar-color: #2c2c2f #0b0b0c;
}
*::-webkit-scrollbar {
@@ -81,13 +98,12 @@
}
*::-webkit-scrollbar-track {
background: #171717;
background: #0b0b0c;
}
*::-webkit-scrollbar-thumb {
background-color: #363636;
border-radius: 4px;
border: 2px solid #171717;
background-color: #2c2c2f;
border: 2px solid #0b0b0c;
}
.scrollbar-hidden {
@@ -100,6 +116,9 @@
}
@layer base {
/* Display face: Archivo — an industrial grotesque that matches the
hard-edged HUD system better than Bricolage's editorial quirk.
Weights still come from per-usage utilities. */
h1,
h2,
h3,
@@ -107,7 +126,7 @@
h5,
h6 {
font-family:
"Outfit",
"Archivo",
system-ui,
-apple-system,
BlinkMacSystemFont,
@@ -116,12 +135,19 @@
"Helvetica Neue",
Arial,
sans-serif;
letter-spacing: -0.02em;
letter-spacing: -0.03em;
}
/* Only display-scale headings run expanded on the wdth axis; compact
headings (card titles, section labels) stay at normal width so they
don't truncate in tight layouts. */
h1,
h2 {
font-stretch: 118%;
}
body {
font-family:
"Source Sans 3",
system-ui,
-apple-system,
BlinkMacSystemFont,
@@ -131,11 +157,21 @@
Arial,
sans-serif;
color: #ffffff;
background-color: #121212;
background-color: #0b0b0c;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
/* Mono HUD micro-label, matching the marketing site's .hud-label. */
.hud-label {
font-family:
"Geist Mono", ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.6875rem;
font-weight: 500;
letter-spacing: 0.15em;
text-transform: uppercase;
}
.font-serif-italic {
font-family: "Instrument Serif", Georgia, "Times New Roman", serif;
font-style: italic;
@@ -144,6 +180,15 @@
}
}
/* Display utility carries the ax-display reference treatment (Archivo at
weight 620, 118% width), matching the marketing site. Plain (unlayered)
rule so it wins over font-weight utilities like font-semibold, which are
emitted at the top of the file. */
.font-display {
font-stretch: 118%;
font-weight: 620;
}
/* Shared component utilities (matching desktop app theme) */
.bg-ui-badge {
background-color: rgba(0, 0, 0, 0.4);
@@ -153,16 +198,11 @@
filter: invert(1);
}
/* Gradient panel surface used by pagescene + lib components. */
/* Panel surface used by pagescene + lib components. Brutalist system:
flat near-black, no gradient (class name kept for lib compatibility). */
.bg-ui-panel-gradient {
background:
radial-gradient(
60.68% 60.68% at 50% 0%,
rgba(255, 255, 255, 0.08) 0%,
rgba(0, 0, 0, 0) 70%
),
#1f1f1f;
background-color: #1f1f1f;
background: #101014;
background-color: #101014;
}
.prevent-select {
@@ -233,19 +273,18 @@
}
}
/* Brutalist system: former "glass" surfaces are now flat near-black panels
with white/15 hairline borders (class names kept for lib compatibility). */
@layer components {
.glass {
@apply relative border border-white/10 shadow-lg backdrop-blur-3xl transition-colors duration-300;
@apply bg-[#303032]/90 !important;
@apply relative border border-white/15 transition-colors duration-300 rounded-[3px] backdrop-blur-sm;
@apply bg-[#1E1F22]/95 !important;
}
}
.liquid-glass {
background: #232323c0;
border: 1px solid #ffffff14;
backdrop-filter: blur(18px) saturate(180%);
-webkit-backdrop-filter: blur(12px) saturate(180%);
box-shadow: 0 8px 32px -8px rgba(0, 0, 0, 0.6);
background: rgba(11, 11, 12, 0.92);
border: 1px solid rgba(255, 255, 255, 0.15);
}
/* Minimal thin scrollbar for prompt textareas */
@@ -264,7 +303,6 @@
.promptbox-scrollbar::-webkit-scrollbar-thumb {
background-color: rgba(255, 255, 255, 0.2);
border-radius: 9999px;
}
.promptbox-scrollbar::-webkit-scrollbar-thumb:hover {
@@ -412,56 +450,6 @@
@apply bg-center;
}
@property --angle {
syntax: "<angle>";
inherits: false;
initial-value: 0deg;
}
@keyframes rotate {
0% {
--angle: 0deg;
}
100% {
--angle: 360deg;
}
}
.glow-border-animated {
position: relative;
overflow: hidden;
}
.glow-border-animated::before {
content: "";
position: absolute;
inset: -3px;
background: conic-gradient(
from var(--angle),
transparent 0%,
transparent 80%,
rgba(147, 197, 253, 0.4) 85%,
rgba(191, 219, 254, 0.9) 90%,
rgba(255, 255, 255, 1) 92.5%,
rgba(191, 219, 254, 0.9) 95%,
rgba(147, 197, 253, 0.4) 100%,
transparent 100%
);
border-radius: inherit;
animation: rotate 2.2s linear infinite;
z-index: -1;
}
.glow-border-animated::after {
content: "";
position: absolute;
inset: 2px;
background: inherit;
border-radius: inherit;
z-index: -1;
}
/* Mobile bottom-sheet (SettingsDrawer). The shadcn Sheet relies on the
tailwindcss-animate plugin, which isn't enabled here, so we drive the
slide ourselves off Radix's data-state. Defining a closed-state animation
@@ -18,8 +18,26 @@ module.exports = {
theme: {
extend: {
fontFamily: {
mono: [
"Geist Mono",
"ui-monospace",
"SFMono-Regular",
"Menlo",
"Consolas",
"monospace",
],
display: [
"Outfit",
"Archivo",
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
"Segoe UI",
"Roboto",
"Helvetica Neue",
"Arial",
"sans-serif",
],
sans: [
"system-ui",
"-apple-system",
"BlinkMacSystemFont",
@@ -159,16 +177,20 @@ module.exports = {
ring: "var(--sidebar-ring)",
},
ui: {
// Aligned with artcraft app's theme-gray (frontend/apps/artcraft/app/src/styles/base.css)
background: "#242424",
panel: "#1F1F1F",
"panel-border": "#272727",
border: "#3f3f3f",
controls: "#3e3e41",
"controls-button": "#3E3E41",
"controls-border": "#3f3f3f",
divider: "#272727",
modal: "#1F1F1F",
// Brutalist system: near-black surfaces with white/15 hairline
// borders, aligned with artcraft-website + artcraft-website-next.
background: "#121316",
panel: "#0b0b0c",
"panel-border": "rgb(255 255 255 / 0.15)",
border: "rgb(255 255 255 / 0.15)",
// Raised control surface — a step above the #0b0b0c page/panel bg
// (matches the sidebar's #1e1f22) so buttons and inputs read as
// surfaces instead of blending into the background.
controls: "#1e1f22",
"controls-button": "#1e1f22",
"controls-border": "rgb(255 255 255 / 0.15)",
divider: "rgb(255 255 255 / 0.15)",
modal: "#0b0b0c",
},
"axis-x": "#D33242",
"axis-y": "#308752",
@@ -0,0 +1,4 @@
node_modules/
.next/
next-env.d.ts
*.tsbuildinfo
@@ -0,0 +1,59 @@
# Design Ideology
The working philosophy behind the ArtCraft marketing site
(`artcraft-website-next`). This captures direction and standards, not a
finished visual spec — the aesthetic itself is still under experiment.
## What we are building
A product website with a sales/marketing angle. The bar: it should read as
a professionally developed, premium site with a design language that
belongs to ArtCraft — not a template, not AI-slop defaults, and not a
studio/agency showpiece. Creative technology (Three.js, GSAP, smooth
scroll) is welcome and encouraged, but subordinated to selling the
product: used where it deepens the product story, never as decoration for
its own sake, and never overdone.
## The premium principle
Premium is felt immediately, and it is built from accumulated small
details — not from one big idea. Small details make a big difference.
- **One or two elements are never enough.** A single hero gimmick floating
in unoccupied space reads amateur and unfinished — the "blank/empty
negative space" failure. Compositions need layered density: many quiet,
coordinated details rather than one loud one.
- **Negative space must be structured, not merely empty.** Rails, rules,
ticks, annotations, and measurements make emptiness intentional.
- **Everything is choreographed.** Nothing just appears; elements arrive
with easing and stagger. Motion that maps to scroll is scrub-linked and
reversible.
- **Finish every surface.** Raw flat color, unlit 3D, and default
materials read as demos. Grain, lighting, shadow, vignette, and
micro-contrast are the difference between blocked-out and finished.
- **One voice for micro-detail.** Annotations, counters, tick marks, and
labels share a single vocabulary (mono uppercase HUD labels, hairline
rules, crosshair ticks) so density never becomes noise.
## Current experimental language (subject to change)
Brutalist/instrument direction. Nothing here is final; treat every item as
a hypothesis under test, replaceable by a better experiment:
- Two themes, one structure: dark "viewport" (near-black terminal), light
"blueprint" (warm paper). Light-dominant positioning, deliberately apart
from dark competitors.
- Hard edges, zero border radius, hairline rules, ticked 1280px rails.
- Type: Archivo display (stretched), Instrument Serif italic for one
contrast word per headline, Inter body, Geist Mono for HUD micro-labels.
- One blue accent, used sparingly. Color discipline over rainbow.
## Working practices
- **Experiments over debates.** Contested aesthetics get built behind the
dev tuner and judged on the page, not argued in the abstract.
- **Every tweakable constant is registered with the tuner** (see
TUNER.md). A missing slider costs a whole build round-trip.
- **Progressive enhancement is non-negotiable.** Reduced motion, coarse
pointers, no-JS crawlers, and WebGL failure all receive a complete,
legible page.
@@ -0,0 +1,80 @@
# Dev Tuner
A floating, draggable panel for live-tuning the site's visual/physics
constants without an edit-build-reload loop. Drag it by its header; collapse
the whole box with `–`, or expand/collapse individual sections.
## When it shows
- Always in `next dev`.
- In any build when the URL has `?tuner=1` (handy for tuning a deployed
preview).
- Never otherwise — production visitors don't see it.
## Behavior
- **Live**: motion/look values are read every frame, so slider changes
apply instantly. Layout values (anything that changes an element's
structure or arrangement itself) trigger a debounced rebuild (~250 ms
after the last change).
- **Persistent**: tuned values are stored in `localStorage`
(`artcraft-tuner`), so a refresh keeps your tuning. Panel position and
section collapse state persist too (`artcraft-tuner-ui`).
- **Modified values** show their label in the accent color.
- **Copy** puts a JSON snapshot of all current values on the clipboard —
paste it into a message or use it to update defaults in code.
- **Reset** clears every override back to the in-code defaults.
- **Per-section actions**: every section header carries its own ⧉ (copy
just that group's JSON) and ↺ (reset just that group's overrides), so
iterating one feature never means copying or resetting the world.
## Adding tunables (POLICY: always do this)
Whenever you introduce a new tweakable constant — a force, an intensity, a
size, a duration — register it with the tuner instead of hardcoding a bare
literal. Adding a slider is free; a missing one costs a whole
iteration/build round-trip.
```ts
// some-feature-tunables.ts
import { defineTunables } from "@/lib/tuner";
export const glowTuner = defineTunables("glow", "Glow", {
radius: {
label: "Radius px", min: 0, max: 400, step: 5, default: 120,
info: "Blur radius of the glow behind the hero card.",
},
strength: {
label: "Strength", min: 0, max: 2, step: 0.05, default: 0.8,
info: "Peak intensity of the glow (0 = off).",
},
});
```
**Every tunable must carry an `info` one-liner.** It renders as the row's
`i`-mark hover in the panel — a slider nobody can decipher ("Ghost alpha"?)
costs more than the sentence explaining it. Write what the knob visibly
does, not what the variable is named.
Then read values where they're used:
- **Per-frame consumers** (`useFrame`, rAF loops): call `glowTuner.read()`
inside the loop — it's cheap and picks up changes instantly.
- **Build-time consumers** (values baked into geometry/sampling): read at
build time and subscribe to `useTunerStore` to trigger a debounced rebuild
when the group's values change (see the Galaxy layout wiring in
`hero-galaxy.tsx`).
The panel picks up new groups automatically — no panel changes needed.
Registered groups: Intro (page-intro choreography beats; `src/lib/intro.ts`),
Wordmark (logo-A optics, blade tuck, contrast scrim;
`src/components/landing/hero-wordmark.tsx`), Galaxy layout, Galaxy motion,
Galaxy pointer, Galaxy look (hero galaxy;
`src/components/landing/hero-galaxy-tunables.ts`) and Ruler layout, Ruler
motion, Ruler look (scroll ruler;
`src/components/ruler/ruler-tunables.ts`).
## Shipping tuned values
The defaults in `defineTunables` are the shipped values. After a tuning
session: Copy → paste the JSON → update the `default:` fields → Reset.
@@ -0,0 +1,13 @@
# Netlify settings used to discover this file:
# Base directory: frontend/apps/artcraft-website-next
# (this app is standalone — its own package-lock.json, not the Nx workspace)
# Manage the build command and publish directory here; leave those UI fields empty.
# The Next.js runtime handles routing, the /cdn-media and /ext-media proxy
# rewrites, and the legacy-route redirects declared in next.config.ts.
[build]
command = "npm run build"
publish = ".next"
[[plugins]]
package = "@netlify/plugin-nextjs"
@@ -0,0 +1,51 @@
import type { NextConfig } from "next";
// Marketing routes the nav/footer reference but this app doesn't serve yet.
// They bounce to the currently shipping site (same dependency as MEDIA_BASE —
// this app already assumes the Vite site stays live) so no link dead-ends.
// Delete a route from this list when its page migrates here.
const LEGACY_ROUTES = [
"download",
"pricing",
"tutorials",
"news",
"faq",
"press-kit",
"support",
];
const nextConfig: NextConfig = {
reactStrictMode: true,
// Pin the project root: the repo has lockfiles both here and in the Nx
// workspace above, and letting Next guess picks the wrong one (breaks
// output file tracing on serverless deploys).
turbopack: { root: __dirname },
outputFileTracingRoot: __dirname,
async redirects() {
return LEGACY_ROUTES.map((route) => ({
source: `/${route}`,
destination: `https://getartcraft.com/${route}`,
permanent: false,
}));
},
// Same-origin proxies for large media served from other origins. The hero
// wall draws its clips as WebGL video textures, which need CORS-clean
// sources, and neither CDN sends Access-Control-Allow-Origin, so those
// requests route through our own origin.
async rewrites() {
return [
// Showcase clips for the hero wall (see cdnMediaUrl in lib/links.ts).
{
source: "/cdn-media/:path*",
destination: "https://frontend-cdn.fakeyou.com/:path*",
},
// Feature footage and other assets still hosted on the live site.
{
source: "/ext-media/:path*",
destination: "https://getartcraft.com/:path*",
},
];
},
};
export default nextConfig;
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,38 @@
{
"name": "@frontend/artcraft-website-next",
"version": "0.1.0",
"private": true,
"engines": {
"node": ">=20.9.0"
},
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"@radix-ui/react-navigation-menu": "^1.2.22",
"@react-three/drei": "10.7.7",
"@react-three/fiber": "9.6.1",
"@react-three/postprocessing": "3.0.4",
"gsap": "3.15.0",
"lenis": "1.3.23",
"lucide-react": "^1.31.0",
"next": "16.2.6",
"react": "19.2.6",
"react-dom": "19.2.6",
"tailwind-merge": "^3.6.0",
"three": "0.184.0",
"zustand": "5.0.14"
},
"devDependencies": {
"@tailwindcss/postcss": "4.3.0",
"@types/node": "^20",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "0.184.0",
"tailwindcss": "4.3.0",
"typescript": "^5"
}
}
@@ -0,0 +1,7 @@
const config = {
plugins: {
"@tailwindcss/postcss": {},
},
};
export default config;
@@ -0,0 +1,13 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 116.34 97.5">
<defs>
<style>
.cls-1 {
fill: #4e7bfb;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<path class="cls-1" d="M104.28,49.49L81.55,0h-31.23l-3.17,4.76L14.75,53.63,0,75.85l21.55,21.55,63.79-36.94,16.99,37.04,14.01-21.74-12.06-26.27ZM32.89,65.66l32.42-48.87,10.91,23.77-43.32,25.09Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 483 B

Loaded 100 of 275 files, more files were not shown because too many files have changed in this diff. Show more