Documentation

SceneVault developer docs

SceneVault is a personal Excalidraw library service — nested folders, unlimited scenes, cloud autosave, shareable links, and real-time collaboration. This guide covers the architecture in depth: the data flows, the trust boundaries, the consistency model, and how every failure mode is handled.

Introduction

Overview

Your Excalidraw drawings keep ending up in a folder called Downloads. SceneVault gives them a real home: a tidy, searchable library where every scene autosaves to the cloud and the full editor is one click away.

It is a single Next.js App Router application that stitches together four services: Better Auth for authentication, Convex as a reactive metadata database, Cloudflare R2 for scene-bundle storage, and the embedded @excalidraw/excalidraw editor. The guiding principle is a clean split: scene metadata (titles, folders, versions, object keys, content hashes) lives in Convex and is strongly consistent and reactive; the heavy scene bundles live in R2 and are accessed only through short-lived presigned URLs that never touch the Next.js server.

Folders & library
Cloud autosave
Live collaboration

Get running

Quickstart

The fastest way to try SceneVault is local demo mode, which needs no Better Auth, Convex, or R2 credentials. Scenes are stored in your browser's localStorage.

bash
# 1. Install dependencies
pnpm install

# 2. Run in local demo mode (no external services)
NEXT_PUBLIC_LOCAL_DATA=1 pnpm dev

# 3. Open the dashboard
open http://localhost:3000/dashboard

No sign-in required

In demo mode Better Auth proxy is bypassed and the dashboard loads straight away. It is also the mode the end-to-end Playwright suite runs against. Live collaboration needs Convex, so it is only available in the full stack.

Ready to wire up real services and persist scenes across devices? Jump to Production setup.

Capabilities

Features

Everything you need to keep a growing pile of drawings calm, searchable, and saved.

Nested folders

Organise scenes into folders nested as deep as you like. Move and rename freely — cycle-safe moves are enforced server-side.

Cloud autosave

Every edit streams to Cloudflare R2 and a content hash skips no-op writes, so the version only bumps when the drawing truly changes.

The real editor

The full @excalidraw/excalidraw canvas is embedded — not a clone. All native tools, libraries, and exports work as expected.

Instant search

Filter the whole library by scene title or folder name in a keystroke, entirely client-side over a live Convex subscription.

Share links

Mint view-only or editable links per scene. Tokens are rotatable and revocable, and gate access without exposing your account.

Live collaboration

Start a real-time room on any scene. Per-element last-write-wins reconciliation, live cursors, and presence — durable in Convex.

Thumbnails & previews

Scenes render a PNG thumbnail on save, served through an authenticated, cache-friendly proxy route for a fast dashboard grid.

Validated boundaries

Zod schemas guard every app, storage, and client-data boundary, so malformed payloads are rejected before they reach storage.

Architecture

Tech stack

A small, modern stack where each service owns one job. Convex holds reactive metadata; R2 holds bytes; Better Auth holds identity.

Next.js 16

App Router, route handlers, proxy

React 19

Server & client components

Excalidraw 0.18

The embedded drawing canvas

Better Auth

Authentication & session management

Convex

Reactive metadata database & live rooms

Cloudflare R2

Scene bundle & thumbnail object storage

Tailwind CSS v4

Styling + shadcn / Radix UI primitives

Zod v4

Runtime validation at every boundary

Architecture

How it fits together

The browser talks to three planes: Convex for live metadata, Next.js route handlers for presigned storage access, and R2 directly for the actual scene bytes.

Browser

Next.js + Excalidraw

Next.js route handlers — authorize & mint presigned URLs

Identity

Better Auth

Sessions & Convex JWTs

identity · JWT

Control plane

Convex

Reactive metadata & live rooms

live subscriptions ↕

Data plane

Cloudflare R2

Scene bundles & thumbnails

presigned bytes ↕

metadata via Convex · bytes via presigned R2 URLs · identity via Better Auth

The split is deliberate. Convex is the control plane — small, reactive, strongly consistent rows that every client subscribes to. R2 is the data plane — large, opaque blobs the server never reads on the hot path. The Next.js route handlers are a thin brokerage layer: they authorise a request, then hand the browser a presigned URL so the bytes flow browser ↔ R2 directly. That keeps multi-megabyte scene payloads off both the database and the serverless function.

Architecture

Request & data flows

Three flows carry every byte in the app. Each keeps large payloads off the database and routes them browser-to-R2 over short-lived presigned URLs.

Save

Editor → presigned PUT → R2 → commit mutation → fan-out

  1. 01The editor debounces changes and serialises the scene to Excalidraw JSON, computing a SHA-256 content hash.
  2. 02Client POSTs /api/scenes/[id]/upload; the handler resolves ownership via Convex and mints a presigned R2 PUT URL.
  3. 03Client uploads the bundle bytes (and, separately, a rendered PNG thumbnail) straight to R2.
  4. 04commitSceneSave records the object key, byte size, and content hash — recomputing the expected key and rejecting any mismatch.
  5. 05If the content hash is unchanged the mutation is a no-op; otherwise version++ and the reactive getLibrary subscription pushes to every tab.

Load

getLibrary pointer → presigned GET → R2

  1. 01The editor reads the current object key from the profile-scoped getLibrary subscription.
  2. 02Client GETs /api/scenes/[id]/download; the route re-checks access with getSceneStorageAccess and mints a presigned R2 GET URL.
  3. 03Client fetches the bundle bytes directly from R2 and hydrates the canvas — bytes never pass through the Next.js server.

Thumbnail

Rendered client-side → presigned PUT → authenticated proxy

  1. 01On save the client renders a PNG preview and uploads it via a presigned PUT to a deterministic thumbnail key.
  2. 02The dashboard requests /api/scenes/[id]/thumbnail?v=<version> — an authenticated proxy that streams the PNG with an immutable cache header.
  3. 03Because the URL carries the scene version, a new save busts the cache automatically; a failed thumbnail upload never wipes the existing one.

The content hash earns its keep on every save

commitSceneSave compares the incoming content hash to the stored one and returns early when they match. That skips a version bump that would otherwise re-fire the getLibrary subscription and re-render every open tab — so an idle autosave costs nothing.

Architecture

Data model

All metadata lives in Convex, profile-scoped and indexed for the exact access patterns the app needs. Eight tables, no joins on the hot path.

profiles

SceneVault app profile keyed by the Better Auth subject. Created lazily on first write.

authSubject · timestamps

indexesby_auth_subject
folders

Nested folder tree, profile-scoped. Self-referential parentFolderId; moves are cycle-checked server-side.

profileId · name · parentFolderId · timestamps

indexesby_profileby_profile_parent
scenes

Scene metadata and the authoritative pointer to current bytes: title, folder, monotonic version, R2 object keys, byte size, and content hash.

profileId · title · folderId · version · currentObjectKey · contentHash

indexesby_profile_folderby_profile_updated
sceneShares

View / edit share tokens per scene. Enable, disable, or rotate without touching the scene. At most one row per (scene, mode).

sceneId · profileId · mode · token · enabled

indexesby_tokenby_scene_modeby_profile
liveRooms

At most one live room per scene. Tracks lifecycle status, epoch, hydration claim, and snapshot watermark/hash for safe GC.

sceneId · status · epoch · snapshotMaxUpdatedAt · snapshotHash

indexesby_scene
roomElements

Live working set — one row per element. Per-element last-write-wins via version / versionNonce. Tombstones converge deletes.

sceneId · elementId · data · version · versionNonce

indexesby_sceneby_scene_element
roomSessions

Server-issued session identity. The cleartext secret is returned once by joinRoom; only its SHA-256 hash is stored.

sceneId · roomSessionId · sessionSecretHash · userId

indexesby_sceneby_room_session
presence

Ephemeral cursor / selection presence. Name and colour are denormalised so getPresence is a single index read, no joins. Swept by TTL.

sceneId · cursorX · cursorY · selectedIds · color · lastSeenAt

indexesby_sceneby_room_session
collabRateLimits

Token-bucket rate limiting per (session, action). Isolated to its own row so it never contends with element or presence writes.

sceneId · roomSessionId · action · tokens

indexesby_key

Indexes mirror the queries exactly. The dashboard's getLibrary reads folders by by_profile and scenes by by_profile_updated in parallel; share lookups hit the unique by_token index; and every collab read is a single by_scene or by_room_session scan.

Architecture

Scene storage & R2

Scene bundles and thumbnails are stored as objects in a Cloudflare R2 bucket under a deterministic, profile-scoped key layout.

R2 object key layout
users/{profileId}/scenes/{sceneId}/head/excalidraw.json   # scene bundle
users/{profileId}/scenes/{sceneId}/head/thumbnail.png     # PNG preview

Browsers never receive long-lived credentials. Every read and write goes through a presigned URL minted server-side with a 5-minute (300s) expiry, scoped to one exact object key and HTTP method. Because the key is derived from profileId + sceneId rather than supplied by the client, and the commit mutations recompute and re-check it, a client can never read or write outside its own namespace.

Object operations are kept simple and idempotent. Deletes remove the bundle and thumbnail unconditionally (a no-op if absent), and the duplicate flow uses a server-side R2 CopyObjectso a shared scene's bytes are cloned without round-tripping through the browser.

CORS is required for browser uploads

R2 must allow browser PUTrequests from your app origin. Configure the bucket's CORS policy before autosave will work in production.

Architecture

Consistency model

Two stores, two consistency guarantees, one source of truth. Convex is authoritative for which bytes are current; R2 merely holds them.

Convex — strong

Metadata mutations are transactional and reactive. The version, contentHash, and currentObjectKey on a scene are the single source of truth for the current bytes.

R2 — pointed-to

Bytes are written to R2 before the commit mutation records them. Readers always follow the Convex pointer, so they never observe a half-written or orphaned object.

A save is effectively two phases: upload bytes, then commit the pointer. If the second phase is lost, the uploaded bytes are simply never referenced — the next save overwrites the same head key — and no reader is ever exposed to them. Identical content short-circuits the commit entirely via the content hash, so the version counter only ever moves forward on a real change.

Live convergence: per-element LWW

In a live room each element converges independently. The server applies the same tie-break Excalidraw uses on the client: a higher version wins; on a tie the lower versionNonce wins; identical means no change. Mirroring reconcileElements keeps server and clients convergent, and deletes propagate as tombstones that the sweep cron later garbage-collects.

convex/collabLogic.ts
export function incomingElementWins(incoming, stored) {
  if (!stored) return true;
  if (incoming.version !== stored.version)
    return incoming.version > stored.version;        // higher version wins
  if (incoming.versionNonce !== stored.versionNonce)
    return incoming.versionNonce < stored.versionNonce; // tie: lower nonce wins
  return false;                                       // identical: no change
}

Security

Trust boundaries

Authorization is enforced three times, independently, on every storage request — defence in depth so no single bug opens a door.

1

Edge — Better Auth proxy

proxy.ts gates /dashboard, /scenes, and /api/scenes by checking for a Better Auth session cookie. /share/e is deliberately public so guests can join an edit room by token. In local demo mode the proxy short-circuits entirely.

2

Route handler — server-derived identity

Each storage route re-derives the Better Auth session server-side, mints a fresh Convex JWT, and asks Convex which profile owns the scene. The profile id used to build the R2 key comes back from Convex — never from the request.

3

Convex — profile-scoped data layer

Every query and mutation independently resolves the caller via ctx.auth, maps the Better Auth subject to a SceneVault profile, and filters by profileId. Commit mutations recompute the expected object key and reject mismatches, so a forged key can't escape the caller's namespace.

proxy.ts (auth gate)
const protectedPrefixes = ["/dashboard", "/scenes", "/api/scenes"];

export default function proxy(req: NextRequest) {
  if (process.env.NEXT_PUBLIC_LOCAL_DATA === "1") return;
  const signedIn = req.cookies
    .getAll()
    .some((cookie) => cookie.name.includes("session_token"));
  if (isProtectedPath(req.nextUrl.pathname) && !signedIn) {
    return NextResponse.redirect(new URL("/sign-in", req.url));
  }
});

Beyond the three layers: presigned URLs are scoped to a single key and expire in five minutes; share tokens are 24 random bytes looked up by a unique index and can be disabled or rotated instantly; and collab session secrets are stored only as SHA-256 hashes, so a database leak can't be replayed to spoof a session.

Collaboration

Share links

Any scene can be shared with a per-scene, per-mode token — view or edit — without exposing your account.

Tokens are 24 random bytes of hex, stored in the sceneShares table and looked up by a unique index. Owners can rotate a token (invalidating the old link instantly) or toggle enabled to revoke access without deleting the link. A disabled or unknown token resolves to null, so guests simply see “not found.”

View links

Read-only access to the current scene bundle. Served at /share/v/[token].

Edit links

Full editing, optionally as a live room. Served at /share/e/[token].

Anonymous guests can load the scene and join live editing with an edit token, but durable R2 persistence still requires sign-in: the upload, commit, thumbnail-upload, and duplicate routes demand a Better Auth session. A guest who duplicates a shared scene gets a fresh copy under their ownprofile namespace. The token grants entry to the room; it never grants direct write access to someone else's storage.

Collaboration

Live collaboration

Start a real-time room on any scene and edit together. The live working set lives in Convex so a browser crash can never lose edits.

Room lifecycle

Only the scene's profile can startRoom. The room then moves through four states as the first joiner seeds it from the durable R2 snapshot:

empty

Room started on a scene with no durable bytes yet — ready to edit from scratch.

needsHydration

A durable R2 snapshot exists and must seed the working set before edits begin.

hydrating

The first joiner has claimed seeding from R2. A stale claim (>15s) can be re-claimed.

ready

The live working set in Convex is authoritative; clients reconcile against it in real time.

Joining issues a session: joinRoom returns a one-time secret (only its hash is stored) and a presence row. Edits are diffed against last-known versions, debounced, and pushed in batches; the server reconciles each element with last-write-wins. Cursors and selections stream as throttled presence updates, denormalised so a single index read renders every collaborator.

Live presence

Cursors, selections, and who's online — denormalised for join-free reads, swept by a 15s TTL.

Session identity

joinRoom issues a secret once; only its hash is stored. Every mutation must present a match.

Durable snapshots

An elected snapshotter persists the room to R2; a cron GCs the working set only once it's saved.

Snapshots & garbage collection

Among active signed-in sessions, exactly one is elected snapshotter — the lexicographically lowest session id, so the choice is deterministic and needs no coordination. It debounces the live set back to R2 and calls markRoomSnapshot with a watermark. A room is “dirty” whenever a live element is newer than that watermark, and the sweep cron never collects a dirty room — the guarantee that no edit is lost even if every browser disconnects before snapshotting. Guests can never snapshot; persistence always requires a signed-in session.

Timings & caps

ConstantValue
PRESENCE_TTL_MS

Presence older than this is offline.

15 s
HEARTBEAT_INTERVAL_MS

Client presence heartbeat cadence.

5 s
CURSOR_THROTTLE_MS

Outbound cursor update throttle (~10/s).

100 ms
ELEMENT_FLUSH_MS

Element-broadcast debounce.

250 ms
SNAPSHOT_DEBOUNCE_MS

Snapshot-to-R2 debounce.

4 s
ROOM_IDLE_GRACE_MS

Idle grace before a clean room is GC-eligible.

60 s
MAX_ELEMENTS_PER_SCENE

Above this, live mode falls back to single-user save.

10,000
MAX_BATCH_ELEMENTS

Elements per push / hydration batch.

256
MAX_ELEMENT_BYTES

Per-element serialised cap (images live in R2).

128 KB
MAX_SESSIONS_PER_ROOM

Hard cap on simultaneous room sessions.

64

Rate limiting

Public, token-gated mutations are throttled by a per-session token-bucket limiter persisted in collabRateLimits, isolated to its own row so it never contends with element or presence writes. Each bucket refills continuously at its rate up to a burst capacity; a call that can't spend a token is rejected rather than queued.

ActionRefillBurst
joinRoom

Caps how fast a session can (re)join, blunting reconnect storms.

2 / s12
pushElements

Bounds element-broadcast throughput per session.

12 / s24
updatePresence

Absorbs cursor bursts while still throttling sustained spam.

20 / s40

Validation bounds

Every inbound element, batch, and presence payload is validated server-side before it touches a row, so a hostile or buggy client can't bloat a room or smuggle oversized data past the working set.

ConstantValue
MAX_ELEMENT_BYTES

Per element, serialised — images live in R2, not the row.

128 KB
MAX_BATCH_ELEMENTS

Elements per push / hydration batch.

256
MAX_SELECTED_IDS

Selection ids carried in a presence update.

1,000
MAX_NAME_LENGTH

Collaborator display name, trimmed server-side.

40
MAX_ELEMENT_ID_LENGTH

Rejects oversized element identifiers.

255

Operations

Failure modes

The design assumes browsers crash, networks drop, and uploads fail halfway. Here is what happens when they do.

Browser crash mid-edit

The live working set lives in Convex, and the sweep cron never collects a dirty room — so unsnapshotted edits survive even if every tab disconnects. On rejoin the room rehydrates from the R2 head plus the live set.

Snapshotter disconnects

The snapshotter is the lexicographically lowest active signed-in session, so re-election is deterministic. A pagehide handler also fires a final flush + snapshot before the tab closes.

Save commit never lands

Bytes are written to R2 before the commit mutation. If the commit is lost, the orphaned bytes are simply never referenced; the next save overwrites the same head key. Readers always follow Convex, never half-written state.

Thumbnail upload fails

commitSceneSave only advances the thumbnail pointer when a new one was uploaded, so a stale-but-valid preview is preserved. The proxy returns 404 when none exists and the grid falls back gracefully.

Hydration claimer vanishes

A hydration claim older than 15s is treated as stale and re-claimed by the next joiner, so a room can never get wedged in the hydrating state.

Stale presence & sessions

Heartbeats run every 5s against a 15s TTL. The sweep cron removes stale presence, sessions, and rate-limit rows every 60s, and GCs rooms that are both idle and fully snapshotted.

Revocation is reactive too. Disabling a share re-runs the collab queries every member is subscribed to, and the client flips to a revoked state immediately; bumping a room's epochforces every member to resync. And when Convex isn't configured, the storage routes return a clean 503 rather than crashing.

Operations

Local vs production

One codebase, two runtime modes. A single flag, NEXT_PUBLIC_LOCAL_DATA, decides whether the app talks to real services or stays entirely in the browser.

Local demo Production
StorageBrowser localStorageConvex + Cloudflare R2
AuthBypassedBetter Auth sessions
ThumbnailsInline PNG data URLsAuthenticated proxy route
Live collabUnavailableFull real-time rooms
CredentialsNone requiredBetter Auth · Convex · R2

The switch is the exported shouldUseRemoteData flag: remote mode is on only when NEXT_PUBLIC_LOCAL_DATA is not 1 and the Convex URL is present. In local mode the library provider reads and writes localStorage, renders thumbnails as inline PNG data URLs, and the auth proxy short-circuits — so the Playwright suite runs the full UI with zero external services. Live collaboration depends on Convex and is therefore remote-only.

Running the full stack locally

Convex's _generatedfiles don't exist until the project is linked, so run the Convex dev server before building the backend.

bash
# Link & run the Convex backend (creates _generated files)
pnpm exec convex dev

# In another terminal, run Next.js against your .env.local
pnpm dev

Operations

Production setup

Wire up the three external services, then deploy. The steps below mirror README.md.

  1. 1Copy .env.example to .env.local.
  2. 2Link and push Convex with pnpm exec convex dev --once.
  3. 3In .env.local, set CONVEX_DEPLOYMENT plus NEXT_PUBLIC_CONVEX_URL, CONVEX_SITE_URL, NEXT_PUBLIC_CONVEX_SITE_URL, and SITE_URL or BETTER_AUTH_URL.
  4. 4In Convex env, set SITE_URL or BETTER_AUTH_URL, BETTER_AUTH_SECRET, and Google/GitHub OAuth credentials.
  5. 5Create an R2 bucket and S3 API token, then fill the CLOUDFLARE_R2_* variables.
  6. 6Configure R2 CORS to allow browser PUT uploads from your app origin.

Run Convex dev before deploying

The Convex _generated files and HTTP actions are not created until the project is linked and pushed. Run pnpm exec convex dev --once before deploying the backend. If /api/auth/* returns the Convex HTTP actions 404, this is the missing step.

OAuth callbacks

Better Auth handles all current sign-in and session state. GitHub and Google should redirect back to the Next.js app, where app/api/auth/[...all]/route.ts forwards the request to Convex.

Provider callback URLs
GitHub local callback: http://localhost:3000/api/auth/callback/github
Google local callback: http://localhost:3000/api/auth/callback/google

GitHub production callback: https://your-domain.com/api/auth/callback/github
Google production callback: https://your-domain.com/api/auth/callback/google

Store SITE_URL or BETTER_AUTH_URL, BETTER_AUTH_SECRET, and the Google/GitHub client credentials in Convex env. Keeping them only in .env.local is not enough because Convex executes the Better Auth handler.

Operations

Project structure

A conventional Next.js App Router layout, with Convex functions and shared library code split out.

directory layout
app/            Next.js App Router — pages, layouts, route handlers
  api/          Presigned-URL brokers & share endpoints
  dashboard/    The library UI
  scenes/       The embedded Excalidraw editor
  share/        Public view (v) and edit (e) routes
components/     React components (editor, dashboard, collab, ui/)
convex/         Schema, queries, mutations, collab logic, crons
lib/            Storage access, R2 client, hashing, validation helpers
tests/          Playwright e2e + collab smoke suites
app/components/convex/lib/tests/public/

Reference

API routes

Route handlers under app/api broker presigned storage access and shared-scene operations. They never stream scene bytes themselves (except the cache-friendly thumbnail proxy).

MethodRoute
POST

/api/scenes/[sceneId]/upload

Mint a presigned R2 PUT URL for the owner's scene bundle.

GET

/api/scenes/[sceneId]/download

Presigned R2 GET URL to load a scene's current bundle.

DELETE

/api/scenes/[sceneId]/storage

Delete the owner's scene bundle and thumbnail objects from R2.

GET

/api/scenes/[sceneId]/thumbnail

Authenticated, cache-friendly proxy serving the PNG preview.

POST

/api/scenes/[sceneId]/thumbnail/upload

Presigned PUT URL for the rendered thumbnail.

GET

/api/share/[token]/metadata

Resolve a share token to scene metadata (view or edit).

GET

/api/share/[token]/download

Token-gated presigned R2 GET URL for a shared scene bundle.

POST

/api/share/[token]/upload

Signed-in edit-link user gets a presigned R2 PUT URL.

POST

/api/share/[token]/commit

Signed-in edit-link user commits the uploaded shared scene bundle.

GET

/api/share/[token]/thumbnail

Token-gated shared thumbnail proxy with no referrer leakage.

POST

/api/share/[token]/thumbnail/upload

Signed-in edit-link user gets a presigned thumbnail PUT URL.

POST

/api/share/[token]/duplicate

Copy a shared scene into the signed-in user's own library.

Reference

Environment variables

Copy .env.example to .env.local and fill in the values for the services you use. Everything except NEXT_PUBLIC_LOCAL_DATA is required for production.

VariableNotes
CONVEX_DEPLOYMENT
required
Convex deployment selected by the CLI.
NEXT_PUBLIC_CONVEX_URL
required
Convex deployment URL.
CONVEX_SITE_URL
required
Server-side Convex site URL used by the auth proxy.
NEXT_PUBLIC_CONVEX_SITE_URL
required
Convex site URL for Better Auth.
SITE_URL
required
Public app URL used by Better Auth callbacks.
BETTER_AUTH_URL
optional
Alternative to SITE_URL using Better Auth's standard env name.
BETTER_AUTH_SECRET
required
Better Auth secret key (server).
GITHUB_CLIENT_ID
required
GitHub OAuth client ID.
GITHUB_CLIENT_SECRET
required
GitHub OAuth client secret.
GOOGLE_CLIENT_ID
required
Google OAuth client ID.
GOOGLE_CLIENT_SECRET
required
Google OAuth client secret.
CLOUDFLARE_R2_ACCOUNT_ID
required
R2 account id.
CLOUDFLARE_R2_ACCESS_KEY_ID
required
R2 S3 API access key id.
CLOUDFLARE_R2_SECRET_ACCESS_KEY
required
R2 S3 API secret access key.
CLOUDFLARE_R2_BUCKET
required
Target R2 bucket name.
NEXT_PUBLIC_LOCAL_DATA
optional
Set to 1 for local demo mode (localStorage, no services).

Reference

Scripts & testing

The project ships unit tests (Vitest + convex-test), end-to-end tests (Playwright, run in local demo mode), and a collaboration smoke suite.

pnpm devStart the dev server
pnpm buildProduction build
pnpm testRun Vitest unit tests
pnpm e2ePlaywright end-to-end suite
pnpm lintESLint
pnpm typechecktsc --noEmit
pnpm formatPrettier write
pnpm allformat · lint · typecheck · test · e2e

What the E2E suite covers

Nested folders, folder-name search, scene creation, editor load, autosave, and rename persistence — all driven through local demo mode so it needs no external services. The collab logic is additionally unit-tested as a pure, dependency-free module.

Ready to draw?

Open the app and give your next idea a home.