diff --git a/assets/PLACEHOLDER.md b/assets/PLACEHOLDER.md
new file mode 100644
index 0000000..694008e
--- /dev/null
+++ b/assets/PLACEHOLDER.md
@@ -0,0 +1,9 @@
+# [placeholder] artwork
+
+`icon.png` (256×256) and `splash.png` (1024×1024) are flat `#f59e0b` swatches — the manifest's accent
+colour, nothing more. No real Atelier artwork existed to carry over from the standalone service, and
+drawing one is outside an unattended run's scope.
+
+Replace both with real artwork before this plugin is published to the Marketplace; until then the dock
+tile and any mobile launch screen will show a plain amber square. Grep for `[placeholder]` across this
+plugin to find every other invented value.
diff --git a/assets/icon.png b/assets/icon.png
new file mode 100644
index 0000000..ce04612
Binary files /dev/null and b/assets/icon.png differ
diff --git a/assets/splash.png b/assets/splash.png
new file mode 100644
index 0000000..c8788df
Binary files /dev/null and b/assets/splash.png differ
diff --git a/web/Collections/Collections.tsx b/web/Collections/Collections.tsx
new file mode 100644
index 0000000..e2c79a2
--- /dev/null
+++ b/web/Collections/Collections.tsx
@@ -0,0 +1,161 @@
+import { Link, useSearchParams } from 'react-router';
+import { Button } from '@/components/ui/button';
+import { Input } from '@/components/ui/input';
+import { fileUrl } from '../lib/api';
+import type { CollectionSummary, GenerationRow } from '../lib/api';
+import { useCollectionsManager } from './useCollectionsManager';
+import type { CollectionsManager } from './useCollectionsManager';
+
+export const Collections = () => {
+ const [searchParams] = useSearchParams();
+ const openId = searchParams.get('collection') ?? undefined;
+ const manager = useCollectionsManager(openId);
+
+ if (manager.loading) return
Loading…
;
+ if (openId) return ;
+
+ return (
+
+
+ manager.setDraftName(ev.target.value)}
+ onKeyDown={(ev) => ev.key === 'Enter' && manager.create()}
+ />
+
+ Create
+
+
+
+ {manager.error &&
{manager.error}
}
+
+ {manager.collections.length === 0 ? (
+
+ No collections yet. Create one here, or pass ?collection=name{' '}
+ when generating and it will be made for you.
+
+ ) : (
+
+ {manager.collections.map((collection) => (
+ manager.remove(collection.id)}
+ />
+ ))}
+
+ )}
+
+ );
+};
+
+type CardProps = { collection: CollectionSummary; searchParams: URLSearchParams; onDelete: () => void };
+
+const Card = ({ collection, searchParams, onDelete }: CardProps) => {
+ const src = fileUrl(collection.cover_url);
+ const to = (() => {
+ const next = new URLSearchParams(searchParams);
+ next.set('collection', collection.id);
+ return { search: `?${next.toString()}` };
+ })();
+
+ return (
+
+
+
+ {src ? (
+
+ ) : (
+
empty
+ )}
+
+
+
{collection.name}
+
+ {collection.item_count} image{collection.item_count === 1 ? '' : 's'}
+
+
+
+
+
+ Delete
+
+
+ );
+};
+
+type DetailProps = { manager: CollectionsManager; searchParams: URLSearchParams };
+
+const Detail = ({ manager, searchParams }: DetailProps) => {
+ const collection = manager.detail;
+
+ const backTo = (() => {
+ const next = new URLSearchParams(searchParams);
+ next.delete('collection');
+ const search = next.toString();
+ return { search: search ? `?${search}` : '' };
+ })();
+
+ if (!collection) return Loading…
;
+
+ return (
+
+
+
+ ‹ Collections
+
+
{collection.name}
+
+ {collection.images.length} image{collection.images.length === 1 ? '' : 's'}
+
+
+
+ {collection.images.length === 0 ? (
+
+ Nothing here yet. Add images from the gallery, or generate with{' '}
+ ?collection={collection.name}.
+
+ ) : (
+
+ {collection.images.map((image) => (
+ - manager.removeImage(collection.id, image.id)} />
+ ))}
+
+ )}
+
+ );
+};
+
+const Item = ({ image, onRemove }: { image: GenerationRow; onRemove: () => void }) => {
+ const src = fileUrl(image.url);
+ return (
+
+
+ {src ? (
+
+ ) : (
+
{image.error ?? image.status}
+ )}
+
+
+
+ Remove
+
+
+ {image.prompt}
+
+ );
+};
diff --git a/web/Collections/useCollectionsManager.ts b/web/Collections/useCollectionsManager.ts
new file mode 100644
index 0000000..9beeb47
--- /dev/null
+++ b/web/Collections/useCollectionsManager.ts
@@ -0,0 +1,76 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useAtelierClient } from '../lib/api';
+import type { CollectionDetail, CollectionSummary } from '../lib/api';
+
+export const useCollectionsManager = (openId?: string) => {
+ const client = useAtelierClient();
+ const [collections, setCollections] = useState([]);
+ const [detail, setDetail] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [draftName, setDraftName] = useState('');
+
+ const refresh = useCallback(async () => {
+ try {
+ setCollections(await client.listCollections());
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ // The detail view is driven by the URL, so opening one is a navigation and this only follows it.
+ useEffect(() => {
+ if (!openId) {
+ setDetail(null);
+ return;
+ }
+ let cancelled = false;
+ client
+ .getCollection(openId)
+ .then((data) => !cancelled && setDetail(data))
+ .catch((err: Error) => !cancelled && setError(err.message));
+ return () => {
+ cancelled = true;
+ };
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [openId]);
+
+ const create = async () => {
+ const name = draftName.trim();
+ if (!name) return;
+ try {
+ await client.createCollection(name);
+ setDraftName('');
+ await refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ };
+
+ const remove = async (id: string) => {
+ try {
+ await client.deleteCollection(id);
+ await refresh();
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ }
+ };
+
+ const removeImage = async (collectionId: string, generationId: string) => {
+ const { images } = await client.removeFromCollection(collectionId, generationId);
+ setDetail((current) => (current ? { ...current, images } : current));
+ void refresh();
+ };
+
+ return { collections, detail, loading, error, draftName, setDraftName, create, remove, removeImage, refresh };
+};
+
+export type CollectionsManager = ReturnType;
diff --git a/web/Gallery/Gallery.tsx b/web/Gallery/Gallery.tsx
new file mode 100644
index 0000000..1e7388d
--- /dev/null
+++ b/web/Gallery/Gallery.tsx
@@ -0,0 +1,83 @@
+import { Link } from 'react-router';
+import { useSearchParams } from 'react-router';
+import { Button } from '@/components/ui/button';
+import { fileUrl } from '../lib/api';
+import type { GenerationRow } from '../lib/api';
+import { Lightbox } from './Lightbox';
+import { useLightboxManager } from './useLightboxManager';
+import { useGalleryManager } from './useGalleryManager';
+
+export const Gallery = () => {
+ const manager = useGalleryManager();
+ // Hooks run before any early return, or the order changes between renders.
+ const lightbox = useLightboxManager(manager.rows);
+ const [searchParams] = useSearchParams();
+
+ if (manager.loading) return Loading…
;
+ if (manager.error) return {manager.error}
;
+
+ if (manager.rows.length === 0) {
+ return Nothing generated yet.
;
+ }
+
+ return (
+
+
+
+ {manager.succeeded.length} image{manager.succeeded.length === 1 ? '' : 's'}
+ {manager.totalCost > 0 && ` · $${manager.totalCost.toFixed(4)} total`}
+
+
void manager.refresh()}>
+ Refresh
+
+
+
+
+ {manager.rows.map((row) => (
+
+ ))}
+
+
+
+
+ );
+};
+
+const Tile = ({ row, searchParams }: { row: GenerationRow; searchParams: URLSearchParams }) => {
+ const src = fileUrl(row.url);
+ const to = (() => {
+ const next = new URLSearchParams(searchParams);
+ next.set('selected', row.id);
+ return { search: `?${next.toString()}` };
+ })();
+
+ return (
+
+
+ {src ? (
+ // A real link, not a button with an onClick — the selection lives in the URL
+ // (`?selected=
`), so this is a navigation and it earns cmd-click, middle-click and
+ // focus-visible for free.
+
+
+
+ ) : (
+ {row.error ?? row.status}
+ )}
+
+
+
+ {row.prompt}
+
+ {row.model}
+ {row.cost_usd && ` · $${Number(row.cost_usd).toFixed(4)}`}
+ {row.duration_ms != null && ` · ${(row.duration_ms / 1000).toFixed(1)}s`}
+
+
+
+ );
+};
diff --git a/web/Gallery/Lightbox.tsx b/web/Gallery/Lightbox.tsx
new file mode 100644
index 0000000..2a8c7c0
--- /dev/null
+++ b/web/Gallery/Lightbox.tsx
@@ -0,0 +1,248 @@
+import type { PointerEvent as ReactPointerEvent, WheelEvent as ReactWheelEvent } from 'react';
+import { useEffect, useRef, useState } from 'react';
+import { Button } from '@/components/ui/button';
+import { cn } from '@/lib/utils';
+import { fileUrl, useAtelierClient } from '../lib/api';
+import type { CollectionSummary } from '../lib/api';
+import { BACKDROPS, MAX_SCALE, MIN_SCALE } from './useLightboxManager';
+import type { LightboxManager } from './useLightboxManager';
+
+/**
+ * A checkerboard, drawn in CSS rather than shipped as an image.
+ *
+ * The default backdrop, and deliberately: every asset here is destined to be an app icon, so the
+ * question asked of a generated image is usually "is the background actually gone". Against a flat
+ * colour, a transparent PNG and an opaque one of that colour look identical.
+ */
+const CHECKER = 'repeating-conic-gradient(rgb(255 255 255 / 0.08) 0% 25%, transparent 0% 50%) 50% / 20px 20px';
+
+export const Lightbox = ({ manager }: { manager: LightboxManager }) => {
+ const client = useAtelierClient();
+ const frameRef = useRef(null);
+ const closeRef = useRef(null);
+ const [dragging, setDragging] = useState(false);
+ const [collections, setCollections] = useState([]);
+ const [filedIn, setFiledIn] = useState(null);
+
+ // Loaded once the lightbox is first opened rather than with the gallery: most sessions never file
+ // anything, and this is a request nobody asked for until they do.
+ useEffect(() => {
+ if (collections.length === 0 && manager.isOpen) {
+ client
+ .listCollections()
+ .then(setCollections)
+ .catch(() => {});
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [manager.isOpen, collections.length]);
+
+ const { current, viewport } = manager;
+
+ const fileInto = async (collectionId: string) => {
+ if (!current) return;
+ const collection = collections.find((row) => row.id === collectionId);
+ await client.addToCollection(collectionId, [current.id]);
+ setFiledIn(collection?.name ?? 'collection');
+ // Cleared on a timer rather than left up: it confirms an action, it is not state.
+ setTimeout(() => setFiledIn(null), 2000);
+ };
+
+ // Move focus in on open so the dialog's controls are reachable, and put it back on close rather
+ // than leaving it on where the next Tab starts from the top of the page.
+ useEffect(() => {
+ if (!current) return;
+ const previous = document.activeElement as HTMLElement | null;
+ closeRef.current?.focus();
+ return () => previous?.focus?.();
+ }, [current]);
+
+ if (!current) return null;
+
+ const frame = () => {
+ const rect = frameRef.current?.getBoundingClientRect();
+ return { width: rect?.width ?? 0, height: rect?.height ?? 0 };
+ };
+
+ const onWheel = (event: ReactWheelEvent) => {
+ const rect = frameRef.current?.getBoundingClientRect();
+ if (!rect) return;
+ // Cursor position relative to the frame's centre — what zoomAt anchors on.
+ const point = {
+ x: event.clientX - rect.left - rect.width / 2,
+ y: event.clientY - rect.top - rect.height / 2,
+ };
+ manager.zoom(event.deltaY < 0 ? 1.15 : 1 / 1.15, point);
+ };
+
+ const onPointerDown = (event: ReactPointerEvent) => {
+ if (viewport.scale <= 1) return;
+ (event.target as Element).setPointerCapture?.(event.pointerId);
+ setDragging(true);
+ };
+
+ const onPointerMove = (event: ReactPointerEvent) => {
+ if (!dragging) return;
+ manager.pan(event.movementX, event.movementY, frame());
+ };
+
+ const stopDragging = () => setDragging(false);
+
+ const facts = [
+ current.width && current.height ? `${current.width}×${current.height}` : null,
+ current.model,
+ current.cost_usd ? `$${Number(current.cost_usd).toFixed(4)}` : null,
+ current.duration_ms != null ? `${(current.duration_ms / 1000).toFixed(1)}s` : null,
+ current.has_alpha === false ? 'no alpha' : current.opaque ? 'alpha unused — opaque' : 'transparent',
+ ].filter(Boolean);
+
+ const imageSrc = fileUrl(current.url);
+
+ return (
+ event.target === event.currentTarget && manager.close()}
+ >
+
+
+ {manager.index + 1} / {manager.images.length}
+
+
+ {current.prompt}
+
+
+
+ {BACKDROPS.map((name) => (
+ manager.setBackdrop(name)}
+ aria-pressed={manager.backdrop === name}
+ className={cn(
+ 'size-6 rounded border border-white/20',
+ manager.backdrop === name && 'ring-2 ring-amber-400 ring-offset-1 ring-offset-black',
+ )}
+ style={{
+ background: name === 'checker' ? CHECKER : name === 'dark' ? '#0a0a0a' : '#ffffff',
+ backgroundColor: name === 'checker' ? '#171717' : undefined,
+ }}
+ title={`${name} backdrop`}
+ />
+ ))}
+
+
+ {collections.length > 0 && (
+ event.target.value && void fileInto(event.target.value)}
+ className="rounded-md border border-white/20 bg-transparent px-2 py-1 text-xs text-neutral-300"
+ >
+ Add to…
+ {collections.map((collection) => (
+
+ {collection.name}
+
+ ))}
+
+ )}
+
+ {filedIn && Added to {filedIn} }
+
+
+ Esc ✕
+
+
+
+
1 ? (dragging ? 'cursor-grabbing' : 'cursor-grab') : 'cursor-zoom-in',
+ )}
+ style={{
+ background: manager.backdrop === 'checker' ? CHECKER : undefined,
+ backgroundColor: manager.backdrop === 'light' ? '#ffffff' : manager.backdrop === 'dark' ? '#0a0a0a' : '#171717',
+ }}
+ onWheel={onWheel}
+ onPointerDown={onPointerDown}
+ onPointerMove={onPointerMove}
+ onPointerUp={stopDragging}
+ onPointerLeave={stopDragging}
+ onDoubleClick={() => (viewport.scale > 1 ? manager.reset() : manager.zoom(2.5))}
+ >
+ {imageSrc && (
+
= 3 ? 'pixelated' : undefined,
+ }}
+ />
+ )}
+
+ {manager.images.length > 1 && (
+ <>
+
manager.step(-1)} />
+ manager.step(1)} />
+ >
+ )}
+
+
+
+ {facts.map((fact) => (
+ {fact}
+ ))}
+ {Math.round(viewport.scale * 100)}%
+
+
+
manager.zoom(1 / 1.4)} disabled={viewport.scale <= MIN_SCALE}>
+ −
+
+
+ Reset
+
+
manager.zoom(1.4)} disabled={viewport.scale >= MAX_SCALE}>
+ +
+
+ {imageSrc && (
+
+ Open file
+
+ )}
+
+
+
+ );
+};
+
+const Step = ({ side, onClick }: { side: 'left' | 'right'; onClick: () => void }) => (
+
+ {side === 'left' ? '‹' : '›'}
+
+);
diff --git a/web/Gallery/useGalleryManager.ts b/web/Gallery/useGalleryManager.ts
new file mode 100644
index 0000000..043b8c8
--- /dev/null
+++ b/web/Gallery/useGalleryManager.ts
@@ -0,0 +1,42 @@
+import { useCallback, useEffect } from 'react';
+import { useState } from 'react';
+import { useAtelierRefresh } from 'officerdev';
+import { useAtelierClient } from '../lib/api';
+import type { GenerationRow } from '../lib/api';
+
+export const useGalleryManager = () => {
+ const client = useAtelierClient();
+ const [rows, setRows] = useState([]);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(null);
+ const [refreshSignal] = useAtelierRefresh();
+
+ const refresh = useCallback(async () => {
+ try {
+ setRows(await client.listGenerations());
+ setError(null);
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setLoading(false);
+ }
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ useEffect(() => {
+ void refresh();
+ }, [refresh]);
+
+ // A finished generation elsewhere on the dashboard (the Generate panel) bumps this — re-read.
+ useEffect(() => {
+ if (refreshSignal > 0) void refresh();
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [refreshSignal]);
+
+ const succeeded = rows.filter((row) => row.status === 'succeeded' && row.image_path);
+ const totalCost = rows.reduce((sum, row) => sum + Number(row.cost_usd ?? 0), 0);
+
+ return { rows, succeeded, totalCost, loading, error, refresh };
+};
+
+export type GalleryManager = ReturnType;
diff --git a/web/Gallery/useLightboxManager.ts b/web/Gallery/useLightboxManager.ts
new file mode 100644
index 0000000..b184058
--- /dev/null
+++ b/web/Gallery/useLightboxManager.ts
@@ -0,0 +1,174 @@
+import { useCallback, useEffect, useState } from 'react';
+import { useSearchParams } from 'react-router';
+import type { GenerationRow } from '../lib/api';
+
+export const MIN_SCALE = 1;
+export const MAX_SCALE = 8;
+
+/** What the image sits on. Not decoration: it is how you tell transparent from white. */
+export const BACKDROPS = ['checker', 'dark', 'light'] as const;
+export type Backdrop = (typeof BACKDROPS)[number];
+
+export type Viewport = { scale: number; x: number; y: number };
+
+export const RESET: Viewport = { scale: 1, x: 0, y: 0 };
+
+export const clampScale = (scale: number) => Math.min(MAX_SCALE, Math.max(MIN_SCALE, scale));
+
+/**
+ * Zoom about a point rather than about the centre.
+ *
+ * `point` is the cursor's offset from the centre of the frame. Keeping whatever is under the cursor
+ * under the cursor is the difference between inspecting a detail and chasing it around the screen —
+ * which matters here, because the reason to zoom an icon is almost always to look at one edge of it.
+ */
+export function zoomAt(viewport: Viewport, factor: number, point: { x: number; y: number }): Viewport {
+ const scale = clampScale(viewport.scale * factor);
+ if (scale === viewport.scale) return viewport;
+
+ const ratio = scale / viewport.scale;
+ return {
+ scale,
+ x: point.x - (point.x - viewport.x) * ratio,
+ y: point.y - (point.y - viewport.y) * ratio,
+ };
+}
+
+/**
+ * Keep the image overlapping its frame.
+ *
+ * Without this, one flick of a drag sends the picture off-screen and the only way back is a control
+ * you can no longer see. The bound is half the overhang, so the image can always be pulled back.
+ */
+export function clampOffset(viewport: Viewport, frame: { width: number; height: number }): Viewport {
+ const overhangX = Math.max(0, (frame.width * viewport.scale - frame.width) / 2);
+ const overhangY = Math.max(0, (frame.height * viewport.scale - frame.height) / 2);
+
+ return {
+ scale: viewport.scale,
+ x: Math.min(overhangX, Math.max(-overhangX, viewport.x)),
+ y: Math.min(overhangY, Math.max(-overhangY, viewport.y)),
+ };
+}
+
+/** Only rows with a file can be opened; a failed generation has nothing to show. */
+export const viewable = (rows: GenerationRow[]) => rows.filter((row) => row.status === 'succeeded' && row.image_path);
+
+/**
+ * Which image is open lives in `?selected=`, not local state — a lightbox is a thing worth linking
+ * to and worth surviving a refresh, and the URL is what `docs/navigation-audit.md` calls the source of
+ * truth for selection.
+ */
+export const useLightboxManager = (rows: GenerationRow[]) => {
+ const [searchParams, setSearchParams] = useSearchParams();
+ const openId = searchParams.get('selected');
+ const [viewport, setViewport] = useState(RESET);
+ const [backdrop, setBackdrop] = useState('checker');
+
+ const images = viewable(rows);
+ const index = images.findIndex((row) => row.id === openId);
+ const current = index >= 0 ? images[index] : undefined;
+
+ const select = useCallback(
+ (id: string | null) => {
+ setSearchParams(
+ (prev) => {
+ const next = new URLSearchParams(prev);
+ if (id) next.set('selected', id);
+ else next.delete('selected');
+ return next;
+ },
+ { replace: true },
+ );
+ },
+ [setSearchParams],
+ );
+
+ const close = useCallback(() => select(null), [select]);
+
+ const open = useCallback(
+ (id: string) => {
+ select(id);
+ setViewport(RESET);
+ },
+ [select],
+ );
+
+ // Wraps, because reaching the end of a gallery and being unable to press the same key again is a
+ // small, repeated annoyance.
+ const step = useCallback(
+ (delta: number) => {
+ if (images.length === 0 || index < 0) return;
+ const next = images[(index + delta + images.length) % images.length];
+ if (next) {
+ select(next.id);
+ setViewport(RESET);
+ }
+ },
+ [images, index, select],
+ );
+
+ const zoom = useCallback((factor: number, point = { x: 0, y: 0 }) => {
+ setViewport((current) => zoomAt(current, factor, point));
+ }, []);
+
+ const reset = useCallback(() => setViewport(RESET), []);
+
+ const pan = useCallback((dx: number, dy: number, frame: { width: number; height: number }) => {
+ setViewport((current) =>
+ current.scale <= 1 ? current : clampOffset({ ...current, x: current.x + dx, y: current.y + dy }, frame),
+ );
+ }, []);
+
+ // Bound to the document rather than the dialog, so the keys work before anything is focused and
+ // keep working after a click lands on the backdrop.
+ useEffect(() => {
+ if (!current) return;
+
+ const onKey = (event: KeyboardEvent) => {
+ const handlers: Record void> = {
+ Escape: close,
+ ArrowRight: () => step(1),
+ ArrowLeft: () => step(-1),
+ '+': () => zoom(1.4),
+ '=': () => zoom(1.4),
+ '-': () => zoom(1 / 1.4),
+ '0': reset,
+ };
+
+ const handler = handlers[event.key];
+ if (handler) {
+ event.preventDefault();
+ handler();
+ }
+ };
+
+ document.addEventListener('keydown', onKey);
+ // The page behind must not scroll while a full-screen overlay is up.
+ const previousOverflow = document.body.style.overflow;
+ document.body.style.overflow = 'hidden';
+
+ return () => {
+ document.removeEventListener('keydown', onKey);
+ document.body.style.overflow = previousOverflow;
+ };
+ }, [current, close, step, zoom, reset]);
+
+ return {
+ images,
+ current,
+ index,
+ isOpen: Boolean(current),
+ viewport,
+ backdrop,
+ setBackdrop,
+ open,
+ close,
+ step,
+ zoom,
+ reset,
+ pan,
+ };
+};
+
+export type LightboxManager = ReturnType;
diff --git a/web/Generate/Generate.tsx b/web/Generate/Generate.tsx
new file mode 100644
index 0000000..a6a51ab
--- /dev/null
+++ b/web/Generate/Generate.tsx
@@ -0,0 +1,146 @@
+import type { ReactNode } from 'react';
+import { Button } from '@/components/ui/button';
+import { Card, CardContent } from '@/components/ui/card';
+import { Input } from '@/components/ui/input';
+import { Label } from '@/components/ui/label';
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select';
+import { Textarea } from '@/components/ui/textarea';
+import { fileUrl } from '../lib/api';
+import { BACKGROUNDS, QUALITIES, SIZES, useGenerateManager } from './useGenerateManager';
+
+export const Generate = () => {
+ const manager = useGenerateManager();
+
+ if (manager.providers.length > 0 && manager.usableProviders.length === 0) {
+ return (
+
+
+ No provider is ready. Add an API key in Settings → Integrations → Atelier first.
+
+
+ );
+ }
+
+ return (
+
+
+ Prompt
+
+
+
+
+
+
+
+
+
+ {manager.models.map((model) => (
+
+ {model}
+
+ ))}
+
+
+
+
+
+ manager.setN(Math.min(10, Math.max(1, Number(ev.target.value) || 1)))}
+ />
+
+
+
+
+
+
+
+
+
+ {manager.running ? 'Generating…' : 'Generate'}
+
+ {manager.job?.atelier && manager.running && (
+ {manager.job.atelier.status}…
+ )}
+
+
+ {manager.error &&
{manager.error}
}
+
+ {manager.job?.data && manager.job.data.length > 0 && (
+
+ {manager.job.data.map((image) => (
+
+
+ {image.revised_prompt ?? ''}
+
+ ))}
+
+ )}
+
+ {manager.job?.atelier?.images.some((image) => image.error) && (
+
+ {manager.job.atelier.images
+ .filter((image) => image.error)
+ .map((image) => (
+ {image.error}
+ ))}
+
+ )}
+
+ );
+};
+
+const Field = ({ label, id, children }: { label: string; id: string; children: ReactNode }) => (
+
+ {label}
+ {children}
+
+);
+
+type ChoiceProps = {
+ label: string;
+ id: string;
+ value: string;
+ onChange: (value: string) => void;
+ options: readonly string[];
+};
+
+const Choice = ({ label, id, value, onChange, options }: ChoiceProps) => (
+
+
+
+
+
+
+ {options.map((option) => (
+
+ {option}
+
+ ))}
+
+
+
+);
diff --git a/web/Generate/useGenerateManager.ts b/web/Generate/useGenerateManager.ts
new file mode 100644
index 0000000..58842a5
--- /dev/null
+++ b/web/Generate/useGenerateManager.ts
@@ -0,0 +1,108 @@
+import { useEffect, useState } from 'react';
+import { useAtelierRefresh } from 'officerdev';
+import { useAtelierClient } from '../lib/api';
+import type { JobResponse, ProviderStatus } from '../lib/api';
+
+export const SIZES = ['auto', '1024x1024', '1536x1024', '1024x1536'] as const;
+export const QUALITIES = ['auto', 'low', 'medium', 'high'] as const;
+export const BACKGROUNDS = ['auto', 'transparent', 'opaque'] as const;
+
+export const useGenerateManager = () => {
+ const client = useAtelierClient();
+ const [, bumpRefresh] = useAtelierRefresh();
+
+ const [providers, setProviders] = useState([]);
+ const [model, setModel] = useState('');
+ const [prompt, setPrompt] = useState('');
+ const [n, setN] = useState(1);
+ const [size, setSize] = useState('auto');
+ const [quality, setQuality] = useState('auto');
+ const [background, setBackground] = useState('auto');
+
+ const [job, setJob] = useState(null);
+ const [running, setRunning] = useState(false);
+ const [error, setError] = useState(null);
+
+ useEffect(() => {
+ client
+ .listProviders()
+ .then((rows) => {
+ setProviders(rows);
+ const usable =
+ rows.find((row) => row.is_default && row.configured && row.enabled) ??
+ rows.find((row) => row.configured && row.enabled);
+ if (usable) setModel(usable.model);
+ })
+ .catch((err: Error) => setError(err.message));
+ // Loaded once on mount — the providers screen (Settings → Integrations → Atelier) is where they
+ // are managed, and this panel does not need to react to edits made there in real time.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, []);
+
+ // Derived in the hook, not in the component.
+ const usableProviders = providers.filter((provider) => provider.configured && provider.enabled);
+ // One model per configured provider, deduped: two providers on the same model is legitimate (two
+ // keys, two accounts) and the request picks between them by model alone.
+ const models = [...new Set(usableProviders.map((provider) => provider.model))];
+ const ready = Boolean(prompt.trim()) && Boolean(model) && !running;
+
+ async function generate() {
+ if (!ready) return;
+
+ setRunning(true);
+ setError(null);
+ setJob(null);
+
+ try {
+ // Only what the user actually chose. `auto` is the provider's own default, so sending it would
+ // be inventing a parameter they did not set.
+ const body: Record = { prompt: prompt.trim(), model, n };
+ if (size !== 'auto') body.size = size;
+ if (quality !== 'auto') body.quality = quality;
+ if (background !== 'auto') body.background = background;
+
+ const { id } = await client.startGeneration(body);
+
+ // Long-poll in a loop: each call waits up to 30s server-side, so this is one request per half
+ // minute rather than a busy poll, and it survives a generation that takes longer.
+ for (;;) {
+ const result = await client.awaitGeneration(id, 30);
+ if (result.atelier && ['succeeded', 'failed', 'partial'].includes(result.atelier.status)) {
+ setJob(result);
+ bumpRefresh();
+ return;
+ }
+ setJob(result);
+ }
+ } catch (err) {
+ setError(err instanceof Error ? err.message : String(err));
+ } finally {
+ setRunning(false);
+ }
+ }
+
+ return {
+ providers,
+ usableProviders,
+ models,
+ model,
+ setModel,
+ prompt,
+ setPrompt,
+ n,
+ setN,
+ size,
+ setSize,
+ quality,
+ setQuality,
+ background,
+ setBackground,
+ job,
+ running,
+ error,
+ ready,
+ generate,
+ };
+};
+
+export type GenerateManager = ReturnType;
diff --git a/web/layout.ts b/web/layout.ts
new file mode 100644
index 0000000..6c876d6
--- /dev/null
+++ b/web/layout.ts
@@ -0,0 +1,15 @@
+import type { LayoutNode } from 'officerdev';
+
+// Generate stays narrow and fixed — it is a form, not a working area. Gallery gets the most room: it
+// is the main "what did I make" view. Collections is the organising view, smallest of the three. Users
+// can resize or rearrange freely; this is only the starting point.
+export const defaultLayout: LayoutNode = {
+ type: 'group',
+ id: 'atelier-root',
+ direction: 'horizontal',
+ children: [
+ { node: { type: 'panel', id: 'atelier-generate', appType: 'atelier-generate' }, size: 26 },
+ { node: { type: 'panel', id: 'atelier-gallery', appType: 'atelier-gallery' }, size: 48 },
+ { node: { type: 'panel', id: 'atelier-collections', appType: 'atelier-collections' }, size: 26 },
+ ],
+};
diff --git a/web/lib/api.ts b/web/lib/api.ts
new file mode 100644
index 0000000..0ec65cd
--- /dev/null
+++ b/web/lib/api.ts
@@ -0,0 +1,130 @@
+import { getHeaders, useClient } from 'hooks/useClient';
+
+// Every call the panels make to this plugin's own sidecar, through `/api/atelier`.
+//
+// `useClient('/api/atelier')` gives typed verbs for JSON; `fileUrl` below is the one thing a client
+// call cannot do — put a bearer token on an ` `.
+
+const ATELIER_API = '/api/atelier';
+
+/** A configured connection: adapter + model + key. Several may share one adapter. */
+export type ProviderStatus = {
+ id: string;
+ name: string;
+ adapter: string;
+ model: string;
+ enabled: boolean;
+ is_default: boolean;
+ configured: boolean;
+ key_source: 'database' | 'environment' | null;
+ env_var: string;
+ created_at: string;
+ updated_at: string;
+};
+
+/** The code side — what a new provider can be built on. */
+export type Adapter = {
+ name: string;
+ label: string;
+ models: string[];
+ default_model: string;
+ env_var: string;
+ supports_edit: boolean;
+};
+
+/** Snake case, matching the server — one spelling per field across every endpoint. */
+export type GenerationRow = {
+ id: string;
+ request_id: string;
+ index: number;
+ operation: 'generate' | 'edit';
+ status: 'queued' | 'running' | 'succeeded' | 'failed';
+ provider: string;
+ model: string;
+ prompt: string;
+ options: Record | null;
+ input_images: string[] | null;
+ mask_image: string | null;
+ image_path: string | null;
+ url: string | null;
+ width: number | null;
+ height: number | null;
+ has_alpha: boolean | null;
+ opaque: boolean | null;
+ cost_usd: string | null;
+ duration_ms: number | null;
+ error: string | null;
+ created_at: string;
+};
+
+export type JobResponse = {
+ id?: string;
+ status?: string;
+ created?: number;
+ data?: { url: string; revised_prompt?: string; b64_json?: string }[];
+ atelier?: {
+ id: string;
+ status: 'queued' | 'running' | 'succeeded' | 'failed' | 'partial';
+ cost_usd: string | null;
+ duration_ms: number;
+ images: { id: string; status: string; url: string | null; error: string | null }[];
+ };
+ error?: { message: string; param?: string | null };
+};
+
+export type Collection = {
+ id: string;
+ name: string;
+ description: string | null;
+ created_at: string;
+ updated_at: string;
+};
+
+export type CollectionSummary = Collection & { item_count: number; cover_image_path: string | null; cover_url: string | null };
+export type CollectionDetail = Collection & { images: GenerationRow[] };
+
+/**
+ * `row.url` (e.g. `/files/`) with this session's bearer token appended, for an ` `.
+ *
+ * Browsers cannot set an `Authorization` header on an ` ` request, so the token travels in the
+ * query string instead — the same escape hatch `FileViewer/file-types.ts` and the music plugin's
+ * `MusicPlayerHost` already use for exactly this reason (`permission-gate.ts` and `user-middleware.ts`
+ * both read `?token=` as well as the header).
+ */
+export function fileUrl(path: string | null): string | null {
+ if (!path) return null;
+ const token = getHeaders()['Authorization']?.replace('Bearer ', '') ?? '';
+ return `${ATELIER_API}${path}?token=${encodeURIComponent(token)}`;
+}
+
+export function useAtelierClient() {
+ const client = useClient(ATELIER_API);
+
+ return {
+ ...client,
+
+ listProviders: () => client.get<{ data: ProviderStatus[] }>('providers').then((r) => r.data),
+ listAdapters: () => client.get<{ data: Adapter[] }>('providers/adapters').then((r) => r.data),
+
+ listGenerations: (limit = 60) => client.get<{ data: GenerationRow[] }>(`images?limit=${limit}`).then((r) => r.data),
+
+ startGeneration: (body: Record) => client.post<{ id: string }>('images/generations', body),
+
+ /** Long-polls up to `waitSeconds` server-side, so the panel does not run its own retry loop. */
+ awaitGeneration: (id: string, waitSeconds = 30) => client.get(`images/generations/${id}?wait=${waitSeconds}`),
+
+ listCollections: () => client.get<{ data: CollectionSummary[] }>('collections').then((r) => r.data),
+ getCollection: (id: string) => client.get<{ data: CollectionDetail }>(`collections/${id}`).then((r) => r.data),
+ createCollection: (name: string) =>
+ client.post<{ data: Collection }>('collections', { name }).then((r) => r.data),
+ deleteCollection: (id: string) => client.delete<{ data: unknown }>(`collections/${id}`),
+ addToCollection: (id: string, generationIds: string[]) =>
+ client
+ .post<{ data: { added: number; images: GenerationRow[] } }>(`collections/${id}/items`, { generationIds })
+ .then((r) => r.data),
+ removeFromCollection: (id: string, generationId: string) =>
+ client.delete<{ data: { images: GenerationRow[] } }>(`collections/${id}/items/${generationId}`).then((r) => r.data),
+ };
+}
+
+export type UseAtelierClientType = ReturnType;
diff --git a/web/panels.ts b/web/panels.ts
new file mode 100644
index 0000000..7eb3438
--- /dev/null
+++ b/web/panels.ts
@@ -0,0 +1,19 @@
+import type { AppRegistryMeta } from 'officerdev';
+import { Sparkles, GalleryHorizontalEnd, FolderOpen } from 'lucide-react';
+import { Generate } from './Generate/Generate';
+import { Gallery } from './Gallery/Gallery';
+import { Collections } from './Collections/Collections';
+
+// No component re-exports. `panels.ts` declares PANELS — the shell mounts them by key and there is no
+// way to export a component from a plugin.
+//
+// The three coordinate through the URL and one refresh channel, never through props: Gallery reads
+// `?selected=` for its lightbox, Collections reads `?collection=` for its detail view — two
+// different param names on purpose, since both panels render on the same route at once and would
+// otherwise fight over one param. Generate bumps `useAtelierRefresh` (officerdev/src/channels.ts) when
+// a job finishes; Gallery subscribes to it. See plugins/atelier/PLUGIN.md.
+export const appRegistryMetas: AppRegistryMeta[] = [
+ { key: 'atelier-generate', name: 'Generate', icon: Sparkles, component: Generate, availableOnPanel: false },
+ { key: 'atelier-gallery', name: 'Gallery', icon: GalleryHorizontalEnd, component: Gallery, availableOnPanel: false },
+ { key: 'atelier-collections', name: 'Collections', icon: FolderOpen, component: Collections, availableOnPanel: false },
+];