the officer shell: designs per user, and the handoff desk

manifest, schema, router and two panels. the editor is hosted and
saving; the last step is not finished and the readme says so.

designs are rows scoped per user, with userId in every WHERE rather
than checked after the read — another person's design is 'not found',
so the id space cannot be probed. the Doc is stored whole as jsonb
because upstream adds fields to it constantly and none of them should
be a migration here.

the handoff is the reason the plugin exists and it is the part that
does not work: /api/chat has NO session-creation route, a session is
constructed by the browser opening a websocket, so a plugin has nothing
to POST to. onExport returns false and the clipboard still runs, which
is upstream's own behaviour — degraded, not broken. docs/handoff.md
names the one platform change that would finish it, and argues it
should be generic rather than ours.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-05 12:16:35 +01:00
co-authored by Claude Fable 5
parent e99b4afbf9
commit a0349cc7a3
17 changed files with 855 additions and 424 deletions
+119
View File
@@ -0,0 +1,119 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { FilePlus2, PenTool, RefreshCw, Trash2 } from "lucide-react";
import { useClient } from "hooks/useClient";
/**
* The list of designs — the one concept upstream does not have.
*
* Standalone M3E Canvas holds exactly one document, in one browser, in `localStorage`. Everything here
* exists because Officer gives it several, belonging to a person rather than to a browser.
*
* Selection is `?doc=<id>` in the URL rather than a channel, so a design is addressable and survives a
* reload — `docs/navigation-audit.md`. The canvas panel reads the same parameter rather than being
* handed the id.
*/
type Row = { id: number; title: string; updatedAt: string };
export const DesignsPanel = () => {
const client = useClient();
const clientRef = useRef(client);
clientRef.current = client;
const [rows, setRows] = useState<Row[] | null>(null);
const [error, setError] = useState<string | null>(null);
const [params, setParams] = useSearchParams();
const selected = params.get("doc");
const load = useCallback(async () => {
try {
setRows(await clientRef.current.get<Row[]>("/m3e-canvas/documents"));
setError(null);
} catch {
setError("Could not load designs.");
setRows(null);
}
}, []);
useEffect(() => {
void load();
}, [load]);
// The canvas creates a row on its first save, so this only has to clear the selection — a design
// with nothing drawn in it should not exist, and creating one here would leave empties behind
// every time somebody clicked New and changed their mind.
const startNew = () => setParams((p) => { p.delete("doc"); return p; }, { replace: true });
const remove = async (id: number, title: string) => {
if (!confirm(`Delete "${title}"? This cannot be undone.`)) return;
try {
await clientRef.current.delete(`/m3e-canvas/documents/${id}`);
if (selected === String(id)) startNew();
await load();
} catch {
setError("Could not delete that design.");
}
};
return (
<div className="flex size-full flex-col">
<header className="flex items-center justify-between border-b px-2 py-1.5">
<button
type="button"
onClick={startNew}
className="flex items-center gap-1.5 rounded-md px-2 py-1 text-sm hover:bg-muted"
>
<FilePlus2 className="size-4" /> New
</button>
<button type="button" onClick={() => void load()} className="p-1 text-muted-foreground hover:text-foreground">
<RefreshCw className="size-3.5" />
</button>
</header>
{error ? <p className="px-3 py-2 text-xs text-destructive">{error}</p> : null}
{rows === null ? (
<Centred>Loading…</Centred>
) : rows.length === 0 ? (
<Centred>No designs yet. Draw something and it saves itself.</Centred>
) : (
<ul className="flex-1 overflow-auto">
{rows.map((row) => (
<li key={row.id} className="group flex items-center">
<button
type="button"
onClick={() => setParams((p) => { p.set("doc", String(row.id)); return p; }, { replace: true })}
className={`flex min-w-0 flex-1 items-center gap-2 px-3 py-2 text-left hover:bg-muted/50 ${
selected === String(row.id) ? "bg-muted" : ""
}`}
>
<PenTool className="size-3.5 shrink-0 text-muted-foreground" />
<span className="min-w-0 flex-1">
<span className="block truncate text-sm">{row.title}</span>
<span className="block truncate text-xs text-muted-foreground">
{new Date(row.updatedAt).toLocaleDateString()}
</span>
</span>
</button>
<button
type="button"
onClick={() => void remove(row.id, row.title)}
aria-label={`Delete ${row.title}`}
className="px-2 text-muted-foreground opacity-0 transition-opacity hover:text-destructive group-hover:opacity-100"
>
<Trash2 className="size-3.5" />
</button>
</li>
))}
</ul>
)}
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex flex-1 items-center justify-center p-4 text-center text-xs text-muted-foreground">
{children}
</div>
);