diff --git a/README.md b/README.md
index 1e9a651f..9bc2a09d 100644
--- a/README.md
+++ b/README.md
@@ -8,6 +8,7 @@
+
diff --git a/apps/demo/index.html b/apps/demo/index.html
new file mode 100644
index 00000000..3d1709c0
--- /dev/null
+++ b/apps/demo/index.html
@@ -0,0 +1,23 @@
+
+
+
+
+
+ SnapOtter Demo
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/demo/package.json b/apps/demo/package.json
new file mode 100644
index 00000000..2277e56b
--- /dev/null
+++ b/apps/demo/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@snapotter/demo",
+ "version": "1.17.0",
+ "private": true,
+ "type": "module",
+ "scripts": {
+ "dev": "vite",
+ "build": "vite build",
+ "preview": "vite preview"
+ },
+ "dependencies": {
+ "@snapotter/shared": "workspace:*",
+ "react": "^19.0.0",
+ "react-dom": "^19.0.0"
+ },
+ "devDependencies": {
+ "@tailwindcss/vite": "^4.0.0",
+ "@types/react": "^19.0.0",
+ "@types/react-dom": "^19.0.0",
+ "@vitejs/plugin-react": "^4.3.0",
+ "tailwindcss": "^4.0.0",
+ "typescript": "^5.7.0",
+ "vite": "^6.0.0"
+ }
+}
diff --git a/apps/demo/public/_redirects b/apps/demo/public/_redirects
new file mode 100644
index 00000000..7797f7c6
--- /dev/null
+++ b/apps/demo/public/_redirects
@@ -0,0 +1 @@
+/* /index.html 200
diff --git a/apps/demo/public/apple-touch-icon.png b/apps/demo/public/apple-touch-icon.png
new file mode 100644
index 00000000..084ec2a6
Binary files /dev/null and b/apps/demo/public/apple-touch-icon.png differ
diff --git a/apps/demo/public/favicon.ico b/apps/demo/public/favicon.ico
new file mode 100644
index 00000000..030576fc
Binary files /dev/null and b/apps/demo/public/favicon.ico differ
diff --git a/apps/demo/public/favicon.png b/apps/demo/public/favicon.png
new file mode 100644
index 00000000..0ccaa93b
Binary files /dev/null and b/apps/demo/public/favicon.png differ
diff --git a/apps/demo/public/logo.png b/apps/demo/public/logo.png
new file mode 100644
index 00000000..02115384
Binary files /dev/null and b/apps/demo/public/logo.png differ
diff --git a/apps/demo/src/demo-banner.tsx b/apps/demo/src/demo-banner.tsx
new file mode 100644
index 00000000..c9d22d10
--- /dev/null
+++ b/apps/demo/src/demo-banner.tsx
@@ -0,0 +1,32 @@
+import { useState } from "react";
+
+export function DemoBanner() {
+ const [dismissed, setDismissed] = useState(false);
+
+ if (dismissed) return null;
+
+ return (
+
+
+ This is a live demo. Processing is disabled.{" "}
+
+ Self-host SnapOtter
+ {" "}
+ for full functionality.
+
+
+
+ );
+}
diff --git a/apps/demo/src/main.tsx b/apps/demo/src/main.tsx
new file mode 100644
index 00000000..e1862151
--- /dev/null
+++ b/apps/demo/src/main.tsx
@@ -0,0 +1,56 @@
+import { installMocks } from "./mock-api";
+
+installMocks();
+
+import { StrictMode } from "react";
+import { createRoot } from "react-dom/client";
+import { App } from "@/App";
+import { DemoBanner } from "./demo-banner";
+import "./styles/globals.css";
+
+const DEMO_MARKER = "demo instance";
+const GITHUB_URL = "https://github.com/snapotter-hq/SnapOtter";
+const GITHUB_LABEL = "github.com/snapotter-hq/SnapOtter";
+
+function patchDemoErrors(root: Element) {
+ const observer = new MutationObserver(() => {
+ root.querySelectorAll(".text-red-500, [data-type='error']").forEach((el) => {
+ if (!(el instanceof HTMLElement)) return;
+ if (!el.textContent?.includes(DEMO_MARKER)) return;
+ if (el.dataset.demoPatch) return;
+ el.dataset.demoPatch = "1";
+ el.classList.remove("text-red-500");
+ el.style.color = "#3b82f6";
+ el.style.fontSize = "13px";
+ el.style.lineHeight = "1.5";
+
+ const text = el.textContent;
+ const parts = text.split(GITHUB_LABEL);
+ while (el.firstChild) el.removeChild(el.firstChild);
+ el.appendChild(document.createTextNode(parts[0]));
+
+ const link = document.createElement("a");
+ link.href = GITHUB_URL;
+ link.target = "_blank";
+ link.rel = "noopener noreferrer";
+ link.textContent = GITHUB_LABEL;
+ link.style.fontWeight = "600";
+ link.style.textDecoration = "underline";
+ link.style.textUnderlineOffset = "2px";
+ el.appendChild(link);
+
+ if (parts[1]) el.appendChild(document.createTextNode(parts[1]));
+ });
+ });
+ observer.observe(root, { childList: true, subtree: true, characterData: true });
+}
+
+const rootElement = document.getElementById("root");
+if (!rootElement) throw new Error("Root element not found");
+patchDemoErrors(rootElement);
+createRoot(rootElement).render(
+
+
+
+ ,
+);
diff --git a/apps/demo/src/mock-api.ts b/apps/demo/src/mock-api.ts
new file mode 100644
index 00000000..95c24ac5
--- /dev/null
+++ b/apps/demo/src/mock-api.ts
@@ -0,0 +1,351 @@
+const PERMISSIONS = [
+ "tools:use",
+ "files:own",
+ "files:all",
+ "apikeys:own",
+ "apikeys:all",
+ "pipelines:own",
+ "pipelines:all",
+ "settings:read",
+ "settings:write",
+ "users:manage",
+ "teams:manage",
+ "features:manage",
+ "system:health",
+ "audit:read",
+];
+
+const FEATURE_BUNDLES = [
+ {
+ id: "background-removal",
+ name: "Background Removal",
+ description: "Remove image backgrounds with AI",
+ status: "installed",
+ installedVersion: "1.0.0",
+ estimatedSize: "4-5 GB",
+ enablesTools: ["remove-background", "passport-photo", "transparency-fixer"],
+ progress: null,
+ error: null,
+ },
+ {
+ id: "face-detection",
+ name: "Face Detection",
+ description: "Detect and blur faces, fix red-eye, smart crop",
+ status: "installed",
+ installedVersion: "1.0.0",
+ estimatedSize: "200-300 MB",
+ enablesTools: ["blur-faces", "red-eye-removal", "smart-crop"],
+ progress: null,
+ error: null,
+ },
+ {
+ id: "object-eraser-colorize",
+ name: "Object Eraser & Colorize",
+ description: "Erase objects from photos and colorize B&W images",
+ status: "installed",
+ installedVersion: "1.0.0",
+ estimatedSize: "1-2 GB",
+ enablesTools: ["erase-object", "colorize", "ai-canvas-expand"],
+ progress: null,
+ error: null,
+ },
+ {
+ id: "upscale-enhance",
+ name: "Upscale & Enhance",
+ description: "AI upscaling, face enhancement, and noise removal",
+ status: "installed",
+ installedVersion: "1.0.0",
+ estimatedSize: "4-5 GB",
+ enablesTools: ["upscale", "enhance-faces", "noise-removal"],
+ progress: null,
+ error: null,
+ },
+ {
+ id: "photo-restoration",
+ name: "Photo Restoration",
+ description: "Restore old or damaged photos",
+ status: "installed",
+ installedVersion: "1.0.0",
+ estimatedSize: "800 MB - 1 GB",
+ enablesTools: ["restore-photo"],
+ progress: null,
+ error: null,
+ },
+ {
+ id: "ocr",
+ name: "OCR",
+ description: "Extract text from images",
+ status: "installed",
+ installedVersion: "1.0.0",
+ estimatedSize: "3-4 GB",
+ enablesTools: ["ocr"],
+ progress: null,
+ error: null,
+ },
+];
+
+const STATE_KEY = "snapotter-demo-state";
+
+function loadState(): {
+ passwordChanged: boolean;
+ analyticsEnabled: boolean | null;
+ analyticsConsentShownAt: number | null;
+} {
+ try {
+ const raw = localStorage.getItem(STATE_KEY);
+ if (raw) return JSON.parse(raw);
+ } catch {}
+ return { passwordChanged: false, analyticsEnabled: null, analyticsConsentShownAt: null };
+}
+
+function saveState(patch: Partial>) {
+ const state = { ...loadState(), ...patch };
+ localStorage.setItem(STATE_KEY, JSON.stringify(state));
+}
+
+function json(data: unknown, status = 200): Response {
+ return new Response(JSON.stringify(data), {
+ status,
+ headers: { "Content-Type": "application/json" },
+ });
+}
+
+function matchRoute(url: string, method: string): Response | null {
+ const path = new URL(url, "http://localhost").pathname;
+
+ if (path === "/api/v1/config/auth" && method === "GET") {
+ return json({ authEnabled: true, oidcEnabled: false });
+ }
+
+ if (path === "/api/auth/login" && method === "POST") {
+ localStorage.setItem("snapotter-token", "demo-token");
+ return json({ token: "demo-token" });
+ }
+
+ if (path === "/api/auth/session" && method === "GET") {
+ const token = localStorage.getItem("snapotter-token");
+ if (!token) return json({ error: "Unauthorized" }, 401);
+ const state = loadState();
+ return json({
+ user: {
+ id: 1,
+ username: "demo",
+ displayName: "Demo User",
+ role: "admin",
+ permissions: PERMISSIONS,
+ mustChangePassword: !state.passwordChanged,
+ analyticsEnabled: state.analyticsEnabled,
+ analyticsConsentShownAt: state.analyticsConsentShownAt,
+ analyticsConsentRemindAt: null,
+ loginMethod: "local",
+ hasLocalPassword: true,
+ },
+ });
+ }
+
+ if (path === "/api/auth/logout" && method === "POST") {
+ localStorage.removeItem("snapotter-token");
+ localStorage.removeItem(STATE_KEY);
+ return json({ ok: true });
+ }
+
+ if (path === "/api/v1/health") {
+ return json({ status: "ok", version: "1.17.0" });
+ }
+
+ if (path === "/api/v1/settings" && method === "GET") {
+ return json({
+ settings: {
+ disabledTools: "[]",
+ enableExperimentalTools: "false",
+ defaultToolView: "sidebar",
+ defaultTheme: "system",
+ },
+ });
+ }
+
+ if (path === "/api/v1/features" && method === "GET") {
+ return json({ bundles: FEATURE_BUNDLES });
+ }
+
+ if (path === "/api/v1/config/analytics" && method === "GET") {
+ return json({
+ enabled: true,
+ posthogApiKey: "",
+ posthogHost: "",
+ sentryDsn: "",
+ sampleRate: 0,
+ instanceId: "demo-instance",
+ });
+ }
+
+ if (path === "/api/v1/user/analytics" && method === "PUT") {
+ saveState({ analyticsEnabled: true, analyticsConsentShownAt: Date.now() });
+ return json({ ok: true });
+ }
+
+ if (path.startsWith("/api/v1/tools/") && method === "POST") {
+ return json(
+ {
+ error:
+ "This is a demo instance. To process images, self-host SnapOtter from GitHub → github.com/snapotter-hq/SnapOtter",
+ },
+ 403,
+ );
+ }
+
+ if (path === "/api/v1/upload" && method === "POST") {
+ return json(
+ {
+ error:
+ "This is a demo instance. To upload files, self-host SnapOtter from GitHub → github.com/snapotter-hq/SnapOtter",
+ },
+ 403,
+ );
+ }
+
+ if (path === "/api/v1/files" && method === "GET") {
+ return json({ files: [], total: 0 });
+ }
+
+ if (path === "/api/v1/files" && method === "DELETE") {
+ return json({ deleted: 0 });
+ }
+
+ if (path.startsWith("/api/v1/admin/")) {
+ return json({ error: "Admin actions are disabled in this demo." }, 403);
+ }
+
+ if (path === "/api/auth/change-password" && method === "POST") {
+ saveState({ passwordChanged: true });
+ return json({ ok: true });
+ }
+
+ if (path.startsWith("/api/v1/pipelines") && method === "GET") {
+ return json({ pipelines: [] });
+ }
+
+ if (path.startsWith("/api/v1/pipelines") && method === "POST") {
+ return json({ error: "Pipelines are disabled in this demo." }, 403);
+ }
+
+ if (path.startsWith("/api/v1/audit") && method === "GET") {
+ return json({ entries: [], total: 0 });
+ }
+
+ if (path.startsWith("/api/v1/users") && method === "GET") {
+ return json({
+ users: [
+ {
+ id: 1,
+ username: "demo",
+ displayName: "Demo User",
+ role: "admin",
+ createdAt: new Date().toISOString(),
+ },
+ ],
+ });
+ }
+
+ if (path.startsWith("/api/v1/teams") && method === "GET") {
+ return json({ teams: [] });
+ }
+
+ if (path.startsWith("/api/v1/apikeys") && method === "GET") {
+ return json({ apiKeys: [] });
+ }
+
+ if (path.startsWith("/api/v1/roles") && method === "GET") {
+ return json({ roles: [] });
+ }
+
+ if (path.startsWith("/api/")) {
+ return json({ ok: true });
+ }
+
+ return null;
+}
+
+const originalFetch = window.fetch.bind(window);
+
+export function installMocks() {
+ window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise => {
+ const url = typeof input === "string" ? input : input instanceof URL ? input.href : input.url;
+ const method = init?.method?.toUpperCase() || "GET";
+
+ const mock = matchRoute(url, method);
+ if (mock) return mock;
+
+ return originalFetch(input, init);
+ };
+
+ const OriginalXHR = window.XMLHttpRequest;
+ const MockXHR = class extends OriginalXHR {
+ private _url = "";
+ private _method = "";
+
+ open(method: string, url: string | URL, ...args: unknown[]) {
+ this._method = method.toUpperCase();
+ this._url = typeof url === "string" ? url : url.href;
+ // @ts-expect-error -- variadic override
+ super.open(method, url, ...args);
+ }
+
+ send(body?: Document | XMLHttpRequestBodyInit | null) {
+ if (this._url.startsWith("/api/")) {
+ const mock = matchRoute(this._url, this._method);
+ if (mock) {
+ setTimeout(async () => {
+ const responseText = await mock.text();
+ Object.defineProperty(this, "status", { value: mock.status, writable: false });
+ Object.defineProperty(this, "readyState", { value: 4, writable: false });
+ Object.defineProperty(this, "responseText", { value: responseText, writable: false });
+ Object.defineProperty(this, "response", { value: responseText, writable: false });
+ this.dispatchEvent(new Event("loadstart"));
+ this.dispatchEvent(new ProgressEvent("progress", { loaded: 100, total: 100 }));
+ if (this.upload) {
+ this.upload.dispatchEvent(new ProgressEvent("progress", { loaded: 100, total: 100 }));
+ this.upload.dispatchEvent(new ProgressEvent("load", { loaded: 100, total: 100 }));
+ }
+ this.dispatchEvent(new Event("load"));
+ this.dispatchEvent(new Event("loadend"));
+ if (typeof this.onreadystatechange === "function") {
+ this.onreadystatechange(new Event("readystatechange") as ProgressEvent);
+ }
+ if (typeof this.onload === "function") {
+ this.onload(new ProgressEvent("load"));
+ }
+ }, 100);
+ return;
+ }
+ }
+ super.send(body);
+ }
+ };
+
+ window.XMLHttpRequest = MockXHR as unknown as typeof XMLHttpRequest;
+
+ const originalSubmit = HTMLFormElement.prototype.submit;
+ HTMLFormElement.prototype.submit = function () {
+ if (this.method.toUpperCase() === "POST") {
+ const target = this.action || window.location.href;
+ const url = new URL(target, window.location.origin);
+ window.location.href = url.pathname;
+ return;
+ }
+ originalSubmit.call(this);
+ };
+
+ const OriginalEventSource = window.EventSource;
+ window.EventSource = class extends OriginalEventSource {
+ constructor(url: string | URL, init?: EventSourceInit) {
+ const urlStr = typeof url === "string" ? url : url.href;
+ if (urlStr.startsWith("/api/")) {
+ super("about:blank", init);
+ setTimeout(() => this.close(), 0);
+ return;
+ }
+ super(url, init);
+ }
+ } as typeof EventSource;
+}
diff --git a/apps/demo/src/styles/globals.css b/apps/demo/src/styles/globals.css
new file mode 100644
index 00000000..04621971
--- /dev/null
+++ b/apps/demo/src/styles/globals.css
@@ -0,0 +1,108 @@
+@import "tailwindcss";
+@source "../../../web/src/**/*.tsx";
+@source "../../../web/src/**/*.ts";
+
+:root {
+ font-family:
+ "Inter", ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont,
+ "Segoe UI", "Noto Sans", "Noto Sans CJK SC", "Noto Sans CJK TC",
+ "Noto Sans CJK JP", "Noto Sans CJK KR", "Noto Sans Arabic",
+ "Noto Sans Devanagari", "Noto Sans Thai", sans-serif,
+ "Apple Color Emoji", "Segoe UI Emoji";
+}
+
+@theme {
+ --color-primary: #3b82f6;
+ --color-primary-foreground: #ffffff;
+ --color-background: #ffffff;
+ --color-foreground: #0f172a;
+ --color-muted: #f1f5f9;
+ --color-muted-foreground: #64748b;
+ --color-border: #e2e8f0;
+ --color-card: #ffffff;
+ --color-card-foreground: #0f172a;
+ --color-sidebar: #f8fafc;
+ --color-sidebar-foreground: #334155;
+ --color-accent: #3b82f6;
+ --color-destructive: #ef4444;
+}
+
+.dark {
+ --color-background: #0f172a;
+ --color-foreground: #f8fafc;
+ --color-muted: #1e293b;
+ --color-muted-foreground: #94a3b8;
+ --color-border: #334155;
+ --color-card: #1e293b;
+ --color-card-foreground: #f8fafc;
+ --color-sidebar: #1e293b;
+ --color-sidebar-foreground: #cbd5e1;
+}
+
+input[type="range"] {
+ -webkit-appearance: none;
+ appearance: none;
+ height: 4px;
+ border-radius: 9999px;
+ background: var(--color-muted);
+ outline: none;
+ cursor: pointer;
+}
+
+input[type="range"]::-webkit-slider-thumb {
+ -webkit-appearance: none;
+ appearance: none;
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--color-foreground);
+ cursor: pointer;
+ border: 2px solid var(--color-background);
+ box-shadow: 0 0 0 1px var(--color-border);
+ transition: transform 0.1s;
+}
+
+input[type="range"]::-webkit-slider-thumb:hover {
+ transform: scale(1.15);
+}
+
+input[type="range"]::-moz-range-thumb {
+ width: 12px;
+ height: 12px;
+ border-radius: 50%;
+ background: var(--color-foreground);
+ cursor: pointer;
+ border: 2px solid var(--color-background);
+ box-shadow: 0 0 0 1px var(--color-border);
+}
+
+input[type="range"]::-moz-range-track {
+ height: 4px;
+ border-radius: 9999px;
+ background: var(--color-muted);
+}
+
+[dir="rtl"] {
+ direction: rtl;
+ text-align: right;
+}
+
+[dir="rtl"] input[type="text"],
+[dir="rtl"] input[type="password"],
+[dir="rtl"] input[type="email"],
+[dir="rtl"] input[type="number"],
+[dir="rtl"] input[type="search"],
+[dir="rtl"] textarea,
+[dir="rtl"] select {
+ text-align: right;
+}
+
+[dir="rtl"] input[type="range"] {
+ direction: ltr;
+}
+
+[dir="rtl"] pre,
+[dir="rtl"] code {
+ direction: ltr;
+ text-align: left;
+}
diff --git a/apps/demo/tsconfig.json b/apps/demo/tsconfig.json
new file mode 100644
index 00000000..4a49bb11
--- /dev/null
+++ b/apps/demo/tsconfig.json
@@ -0,0 +1,14 @@
+{
+ "extends": "../../tsconfig.base.json",
+ "compilerOptions": {
+ "jsx": "react-jsx",
+ "lib": ["ES2022", "DOM", "DOM.Iterable"],
+ "outDir": "./dist",
+ "baseUrl": ".",
+ "paths": {
+ "@/*": ["../web/src/*"]
+ },
+ "noEmit": true
+ },
+ "include": ["src/**/*", "../web/src/**/*"]
+}
diff --git a/apps/demo/vite.config.ts b/apps/demo/vite.config.ts
new file mode 100644
index 00000000..2fa363e7
--- /dev/null
+++ b/apps/demo/vite.config.ts
@@ -0,0 +1,21 @@
+import path from "node:path";
+import tailwindcss from "@tailwindcss/vite";
+import react from "@vitejs/plugin-react";
+import { defineConfig } from "vite";
+
+export default defineConfig({
+ plugins: [react(), tailwindcss()],
+ resolve: {
+ alias: {
+ "@": path.resolve(__dirname, "../web/src"),
+ },
+ dedupe: ["react", "react-dom"],
+ },
+ server: {
+ host: true,
+ port: 1352,
+ },
+ build: {
+ outDir: "dist",
+ },
+});
diff --git a/apps/docs/guide/getting-started.md b/apps/docs/guide/getting-started.md
index bd22e74c..4d217bfe 100644
--- a/apps/docs/guide/getting-started.md
+++ b/apps/docs/guide/getting-started.md
@@ -4,6 +4,10 @@ description: Install SnapOtter with Docker in one command. Includes Docker Compo
# Getting Started
+::: tip Try before installing
+Explore the full UI at [demo.snapotter.com](https://demo.snapotter.com) -- no signup or install required.
+:::
+
## Quick Start
```bash
@@ -12,14 +16,6 @@ docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data snapotter/sn
You will be asked to change your password on first login.
-::: tip Also on GHCR
-```bash
-docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data ghcr.io/snapotter-hq/snapotter:latest
-```
-
-Both registries publish the same image on every release.
-:::
-
::: tip NVIDIA GPU acceleration
Add `--gpus all` for GPU-accelerated background removal, upscaling, OCR, face enhancement, and restoration:
@@ -30,6 +26,14 @@ docker run -d --name SnapOtter -p 1349:1349 --gpus all -v SnapOtter-data:/data s
Requires the [NVIDIA Container Toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html). Falls back to CPU automatically. See [Docker Tags](/guide/docker-tags) for benchmarks.
:::
+::: details Also on GHCR
+```bash
+docker run -d --name SnapOtter -p 1349:1349 -v SnapOtter-data:/data ghcr.io/snapotter-hq/snapotter:latest
+```
+
+Both registries publish the same image on every release.
+:::
+
## Docker Compose
```yaml
diff --git a/apps/landing/src/components/hero.tsx b/apps/landing/src/components/hero.tsx
index 6abcd50e..c8930790 100644
--- a/apps/landing/src/components/hero.tsx
+++ b/apps/landing/src/components/hero.tsx
@@ -126,7 +126,7 @@ export function Hero() {
-
+
+
No sign-ups. No credit card.
diff --git a/apps/landing/src/components/navbar.tsx b/apps/landing/src/components/navbar.tsx
index 54f6b891..8e2a4e17 100644
--- a/apps/landing/src/components/navbar.tsx
+++ b/apps/landing/src/components/navbar.tsx
@@ -92,10 +92,12 @@ export function Navbar() {
)}
- Book a Demo
+ Try Demo
@@ -142,10 +144,12 @@ export function Navbar() {
Star on GitHub
- Book a Demo
+ Try Demo
diff --git a/apps/web/src/components/common/before-after-slider.tsx b/apps/web/src/components/common/before-after-slider.tsx
index 239c48a4..41aa5184 100644
--- a/apps/web/src/components/common/before-after-slider.tsx
+++ b/apps/web/src/components/common/before-after-slider.tsx
@@ -117,8 +117,14 @@ export function BeforeAfterSlider({
{/* After image (clipped, top layer) */}