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
+110
View File
@@ -0,0 +1,110 @@
# Canvas
Sketch a Material 3 screen, and hand the prompt to the agent.
The editor is [M3E Canvas](https://github.com/lnkiai/m3e-canvas) by lnkiai, MIT, vendored under
`web/vendor/`. Officer contributes none of the drawing, none of the Material 3 vocabulary and none of
the prompt wording. **What Officer contributes is the last step**: upstream's export button writes the
prompt to your clipboard to paste into a coding tool, and here it should open a chat session with the
prompt already in it.
**Status: the editor is hosted and saving. The handoff is not finished** — see below, and
`docs/handoff.md` for exactly why.
---
## What works
- The full editor, in a workspace panel, drawn in its own Material 3 vocabulary.
- **Designs belong to a person, not a browser.** Upstream keeps one document in `localStorage`; here
they are rows in `m3e_canvas_documents`, scoped per user, listed in a side panel, and they follow
you between machines.
- Saving is automatic and debounced, and a pending save survives closing the panel.
- Selection is `?doc=<id>` in the URL, so a design is addressable, bookmarkable and survives a reload.
## What does not
**The handoff.** `POST /_handoff` holds the prompt and hands back a `chatUrl`, but **nothing reads it
yet**, because `/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` therefore returns `false` and the
editor still writes the clipboard, which is exactly upstream's behaviour. Degraded, not broken.
Finishing it is one small change in `/chat/new`, and it is a platform change rather than this
plugin's to make. `docs/handoff.md` argues it should probably be generic rather than ours.
**Nothing has run.** Both halves build and the platform discovers all four parts, but no route has
answered a request, `db:push` has not been run so the table does not exist, and nobody has drawn
anything. Treat every claim above as "written and compiled".
---
## The vendoring, and why it is scripted
```
./scripts/vendor.sh [ref] # default: main
```
One command reproduces `web/vendor/` from nothing: clone upstream, rewrite imports, apply our host
wiring, write `PROVENANCE.md`. **Never edit anything under `web/vendor/` by hand.**
Three things make this less obvious than it looks.
**The import rewrite is a correctness requirement, not tidiness.** Upstream imports through a `@/`
alias, and this platform already binds `@/*` to `src/apps/officer-web` and `@/components/*` to its own
shared components. An unrewritten `import { M3Node } from "@/components/M3Node"` resolves to
**Officer's** directory — a wrong module rather than a build error, which is the worst way to be
broken.
**Our changes are a script, not a `.patch`.** Between `cfde698` and `5c83ac6`, one day apart, upstream
changed an import line, re-indented a 3,000-line component from 6 spaces to 8, and added an
`editAccess` guard. A context diff fails on all three, none of which concern us. So
`scripts/apply-host.py` anchors on structure with flexible whitespace, and **stops loudly** when an
anchor genuinely disappears. It did that twice during the first build, correctly both times.
**Everything else goes through `web/adapter.ts`.** No Officer file imports `web/vendor/` directly. When
upstream renames something, the repair is one file rather than every panel.
## Upstream, and why we vendor
The adapter was offered upstream first — [issue #55](https://github.com/lnkiai/m3e-canvas/issues/55)
and [PR #56](https://github.com/lnkiai/m3e-canvas/pull/56) — and declined, for two reasons worth
recording. The PR wired one of three export paths and so did not actually unblock embedding, which was
our error. And embedding is not on their roadmap and is "a single-user need so far", which is fair.
They also said the adapter's design is sound and that they would start from it if that changes, and
named what a complete version needs: the `Canvas` extraction plus **every** export path. Both are done
here. **The door is open in principle**, and the thing that would change their mind is this plugin
existing and working — a second user is a better argument than a better patch.
### What we do not use
`lib/ai.ts` and `components/AiPanel.tsx` are vendored but never surfaced. They keep model-provider API
keys in `localStorage` and call the provider from the browser, which is not how anything here should
work. They were deleted in the first attempt, which turned out to mean editing vendored code —
`Canvas.tsx` references them throughout — so they stay, inert without a key. A faithful copy carrying
an unused module beats a doctored one that cannot be re-vendored.
### Three export paths, not five
A scan for `clipboard.writeText` finds five. Two are `ShareMenu`'s share-link and its "ask an AI"
blurb, which are not the generated prompt and do not belong behind `onExport`. Written down because
the count looks wrong at a glance, and the last round of this work was rejected for wiring one path
out of three.
---
## Files
```
manifest.ts one grantable permission; /_handoff is owner-only
db/schema.ts m3e_canvas_documents — the Doc stored whole as jsonb
api/router.ts documents CRUD, scoped per user in every WHERE; the handoff desk
web/adapter.ts the ONLY door to vendored code
web/host.ts our host module — upstream declined it, so it is ours
web/CanvasPanel.tsx the editor, given storage and an export sink
web/DesignsPanel.tsx the list — the one concept upstream has no notion of
web/vendor/ upstream, do not edit
scripts/vendor.sh re-vendor, one command
scripts/apply-host.py our changes to vendored code
docs/handoff.md why the last step is unfinished, and what would finish it
```
+196
View File
@@ -0,0 +1,196 @@
import { createRouter } from '@@/create-router';
import { db } from 'officerdb/db';
import { and, desc, eq } from 'drizzle-orm';
import { m3eCanvasDocuments } from '../db/schema';
/**
* Mounted at `/api/m3e-canvas`.
*
* Documents, and the handoff. Everything is scoped to the calling user by `userId` on every query —
* the multi-user answer being a property of each statement rather than a filter to remember.
*/
export const router = createRouter();
const userIdOf = (ctx: { get: (k: 'user') => { id: number } | undefined }): number | null =>
ctx.get('user')?.id ?? null;
// ── documents ─────────────────────────────────────────────────────────────────────────────────────
router.get('/documents', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const rows = await db
.select({
id: m3eCanvasDocuments.id,
title: m3eCanvasDocuments.title,
updatedAt: m3eCanvasDocuments.updatedAt,
})
.from(m3eCanvasDocuments)
.where(eq(m3eCanvasDocuments.userId, userId))
.orderBy(desc(m3eCanvasDocuments.updatedAt));
// The list never carries the documents themselves. A `Doc` with a few screens of parts is large,
// and a sidebar that ships every design to draw a list of titles gets slower with every design.
return ctx.json(rows);
});
/** What the canvas opens when no `?doc=` is named — the last thing worked on. */
router.get('/documents/latest', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const rows = await db
.select()
.from(m3eCanvasDocuments)
.where(eq(m3eCanvasDocuments.userId, userId))
.orderBy(desc(m3eCanvasDocuments.updatedAt))
.limit(1);
return ctx.json(rows[0] ?? null);
});
router.get('/documents/:id', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'not a document id' }, 400);
const rows = await db
.select()
.from(m3eCanvasDocuments)
// The userId is in the WHERE, not checked after the read: another person's design is "not found"
// rather than "forbidden", so the id space cannot be probed.
.where(and(eq(m3eCanvasDocuments.id, id), eq(m3eCanvasDocuments.userId, userId)))
.limit(1);
const row = rows[0];
return row ? ctx.json(row) : ctx.json({ error: 'no such design' }, 404);
});
type Body = { title?: string; doc?: unknown; ui?: unknown };
router.post('/documents', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const body = (await ctx.req.json().catch(() => null)) as Body | null;
if (!body?.doc) return ctx.json({ error: 'doc is required' }, 400);
const [row] = await db
.insert(m3eCanvasDocuments)
.values({
userId,
title: (body.title ?? 'Untitled').slice(0, 200),
doc: body.doc,
ui: body.ui ?? null,
})
.returning();
return ctx.json(row, 201);
});
router.put('/documents/:id', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'not a document id' }, 400);
const body = (await ctx.req.json().catch(() => null)) as Body | null;
if (!body?.doc) return ctx.json({ error: 'doc is required' }, 400);
const [row] = await db
.update(m3eCanvasDocuments)
.set({
title: (body.title ?? 'Untitled').slice(0, 200),
doc: body.doc,
ui: body.ui ?? null,
updatedAt: new Date(),
})
.where(and(eq(m3eCanvasDocuments.id, id), eq(m3eCanvasDocuments.userId, userId)))
.returning();
return row ? ctx.json(row) : ctx.json({ error: 'no such design' }, 404);
});
router.delete('/documents/:id', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const id = Number(ctx.req.param('id'));
if (!Number.isInteger(id)) return ctx.json({ error: 'not a document id' }, 400);
const rows = await db
.delete(m3eCanvasDocuments)
.where(and(eq(m3eCanvasDocuments.id, id), eq(m3eCanvasDocuments.userId, userId)))
.returning({ id: m3eCanvasDocuments.id });
return rows[0] ? ctx.json({ deleted: rows[0].id }) : ctx.json({ error: 'no such design' }, 404);
});
// ── the handoff ───────────────────────────────────────────────────────────────────────────────────
/**
* Where a generated prompt goes instead of the clipboard.
*
* **This is the reason the plugin exists, and it is the one part that is not finished.** What is here
* takes the prompt and holds it; what is missing is the platform hook that starts a chat session with
* it already in the composer.
*
* The reason it is missing rather than merely unwritten: `/api/chat` has **no session-creation route**.
* A session is created by the browser opening `/api/chat/ws` and sending its first message, so there is
* nothing a plugin can POST to. Making this work needs one small platform change — a way for
* `/chat/new` to pick up a prompt by reference — and that is a platform decision rather than a
* plugin's to invent. `docs/handoff.md` states the shape it would need.
*
* Until then this route is the seam: it validates, it holds the prompt in memory against a one-time
* id, and the panel falls back to the clipboard so the feature is degraded rather than absent.
*
* Owner-only by the manifest — starting an agent runs as the owner and writes to the owner's disk,
* which is not a thing to hand out with a drawing tool.
*/
type PendingHandoff = { prompt: string; title: string; userId: number; at: number };
const pending = new Map<string, PendingHandoff>();
const HANDOFF_TTL_MS = 10 * 60 * 1000;
/** Kept small and swept on write: this is a hand-off desk, not storage. */
const sweep = () => {
const cutoff = Date.now() - HANDOFF_TTL_MS;
for (const [id, held] of pending) if (held.at < cutoff) pending.delete(id);
};
router.post('/_handoff', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const body = (await ctx.req.json().catch(() => null)) as { prompt?: string; title?: string } | null;
const prompt = body?.prompt?.trim();
if (!prompt) return ctx.json({ error: 'prompt is required' }, 400);
sweep();
const id = crypto.randomUUID();
pending.set(id, { prompt, title: body?.title ?? 'Untitled', userId, at: Date.now() });
return ctx.json({
id,
// The panel navigates here. Nothing reads the parameter yet — see docs/handoff.md.
chatUrl: `/chat/new?handoff=${id}`,
ready: false,
});
});
/** How the chat side would collect a held prompt, once something calls it. */
router.get('/_handoff/:id', async (ctx) => {
const userId = userIdOf(ctx);
if (!userId) return ctx.json({ error: 'unauthenticated' }, 401);
const held = pending.get(ctx.req.param('id'));
// A handoff belongs to whoever made it; another user asking is told nothing.
if (!held || held.userId !== userId) return ctx.json({ error: 'no such handoff' }, 404);
pending.delete(ctx.req.param('id')); // one-time: collected once, then gone
return ctx.json({ prompt: held.prompt, title: held.title });
});
+53
View File
@@ -0,0 +1,53 @@
import { pgTable, serial, text, integer, timestamp, jsonb, uniqueIndex } from 'drizzle-orm/pg-core';
/**
* A design, and the editor state that goes with it.
*
* ── Why the document is `jsonb` and not columns ──
*
* A `Doc` belongs to the vendored editor, not to Officer. Upstream adds fields to it constantly —
* `theme`, `platform` and `dynamicColor` all arrived after the first release — and every one of those
* would be a migration here for a shape we do not own and cannot influence.
*
* So it is stored whole. Officer never reads inside it except to show a title, which means an upstream
* field addition is a re-vendor rather than a schema change. The cost is honest: no querying by
* anything inside a design, which nothing wants to do.
*
* ── Per user, and enforced here ──
*
* `userId` is on the row and every query filters by it. That is the multi-user answer being a property
* of the schema rather than a filter somebody has to remember on each new endpoint — the same reasoning
* `service_connections` uses, and the mistake `dashboards.id` made by being a global primary key fed by
* `slugify(name)`, so that two members naming a thing the same way collide.
*/
export const m3eCanvasDocuments = pgTable(
'm3e_canvas_documents',
{
id: serial('id').primaryKey(),
userId: integer('user_id').notNull(),
/** Lifted out of the document so a list does not have to parse every design to draw itself. */
title: text('title').notNull().default('Untitled'),
/** The whole upstream `Doc`. Officer does not look inside it. */
doc: jsonb('doc').notNull(),
/**
* Panel widths, favourites, which language — upstream's `UI_KEY` state.
*
* Separate from the document because it is not the work: losing it costs a person their panel
* sizes, and losing a design costs them the design. Different things deserve different care.
*/
ui: jsonb('ui'),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(),
},
(table) => ({
// Composite via uniqueIndex, never `unique().on(...)` — drizzle-kit mis-diffs named composite
// unique CONSTRAINTS and re-creates them on every push. `databases/CLAUDE.md`, "Composite keys".
byUser: uniqueIndex('m3e_canvas_documents_user_title').on(table.userId, table.title),
}),
);
export type M3eCanvasDocument = typeof m3eCanvasDocuments.$inferSelect;
+74
View File
@@ -0,0 +1,74 @@
# The handoff, and the one platform hook it needs
This plugin exists for a single step: **the generated prompt should not reach a clipboard.** It should
open a chat session with the agent, already containing the prompt, pointed at a directory on this
machine.
That step is not finished, and the reason is worth writing down precisely, because it is a platform
gap rather than an unwritten function.
---
## Why a plugin cannot do this today
**`/api/chat` has no session-creation route.** Reading `src/servers/api/chat/chat.ts`, the surface is
`GET /sessions`, `GET /sessions/:id`, `DELETE /sessions/:id`, the task verbs and the usage and model
readers. There is no `POST /sessions`.
A session is created by the **browser** opening `/api/chat/ws` and sending its first message. The
socket is the constructor. So there is nothing a plugin's server can POST to in order to start one,
and nothing it can hand a prompt to.
That is a reasonable design — it keeps session lifetime tied to a live connection — but it means
"start a session containing this text" is not expressible from a plugin.
## What is built
`POST /api/m3e-canvas/_handoff` takes `{ prompt, title }`, holds it in memory against a one-time id
with a ten-minute expiry, and answers:
```json
{ "id": "…", "chatUrl": "/chat/new?handoff=…", "ready": false }
```
`GET /api/m3e-canvas/_handoff/:id` collects it once and forgets it. A handoff belongs to whoever made
it; another user asking is told it does not exist rather than that it is theirs.
`ready: false` is the honest part of the response. **Nothing reads `?handoff=` yet.**
Meanwhile `onExport` returns `false`, so the editor still writes the clipboard. The feature is
degraded rather than absent: you get the prompt, you just paste it yourself — which is exactly what
upstream does, so nothing is worse than before.
## What would finish it
One small change, in the platform rather than here. `/chat/new` reads a `handoff` search parameter, and
when present fetches `/api/m3e-canvas/_handoff/:id` and puts the prompt in the composer before the
first send.
That is a few lines in `SessionListPage`, and it is deliberately **not** written here for two reasons.
It is a platform change, so it belongs to whoever owns the chat screen rather than arriving as a side
effect of installing a drawing tool. And a plugin reaching into another surface's composer is the kind
of coupling that is easy to add and unpleasant to find later.
### The shape worth arguing about first
If this is worth doing at all, it is probably worth doing **generically** — `?handoff=<pluginPrefix>/<id>`,
resolved against the plugin that minted it, so the next plugin that wants to hand work to the agent
does not add a second parameter. `todo/sendoff/` is already circling the same idea from another
direction.
Until that is decided, the seam here is small enough to throw away and specific enough to be honest
about what it does not do.
## The other half, unstarted
Even with the composer prefilled, "the agent writes the app" needs somewhere to write it. A design has
no notion of a project directory, and picking one is a product decision:
- a directory the user names when handing off
- a per-design directory remembered on the row
- whatever the chat session's own working directory already is
The third is the least work and probably the right default, since a session started from a dashboard
already has a directory. It is not implemented, and the row has no column for it.
+51
View File
@@ -0,0 +1,51 @@
import type { PluginManifest } from '@@/plugins/manifest';
// Sketch a Material 3 screen, and hand the prompt to the agent.
//
// ── What this is, and what it is not ──
//
// The editor is [M3E Canvas](https://github.com/lnkiai/m3e-canvas), MIT, vendored under `web/vendor/`
// and re-copied by `scripts/vendor.sh`. Officer contributes none of the drawing, none of the Material
// 3 vocabulary and none of the prompt wording — that is upstream's work and there is no wish to fork
// its direction.
//
// What Officer contributes is the last step. Upstream's export button writes the prompt to the
// clipboard for you to paste into a coding tool. Here it opens a chat session with the prompt already
// in it, pointed at a directory on this machine, and the agent writes the app. That is the whole
// reason this plugin exists; everything else is hosting.
//
// ── Why the permission is grantable ──
//
// A design is a document, like a note or a dashboard. There is no credential involved, nothing here
// reaches another machine, and a member sketching a screen is exactly the case this should serve. So
// it is grantable, and documents are scoped per user by the schema rather than by a filter someone
// has to remember.
//
// The one owner-only corner is `/_handoff`, which starts an agent session. Spawning an agent runs as
// the owner and can write to the owner's disk, so it is not a thing to hand out with a drawing tool.
export const manifest: PluginManifest = {
publisher: 'officerdev',
version: '0.1.0',
platform: '>=1.0.0',
label: 'Canvas',
summary: 'Sketch a Material 3 screen, and hand the prompt to the agent',
icon: 'PenTool',
color: '#6750A4', // Material 3's own primary, since the whole editor is drawn in that vocabulary
permissions: [
{
key: 'm3e-canvas',
label: 'Canvas',
description: 'Sketch Material 3 screens and turn them into prompts',
ownerOnly: ['/_handoff'],
},
],
// `motion` is the only thing the platform does not already have — `html-to-image` is present at the
// same version upstream asks for, and React is React. These are INTERSECTED across every installed
// plugin into one `plugins/node_modules/`, so the range is upstream's own rather than tightened.
npmDependencies: {
motion: '^13.1.1',
},
};
+5
View File
@@ -42,6 +42,11 @@ cp "$WORK/src/app/page.tsx" "$DEST/Canvas.tsx"
cp "$WORK/src/app/globals.css" "$DEST/globals.css" cp "$WORK/src/app/globals.css" "$DEST/globals.css"
cp "$WORK/src/LICENSE" "$DEST/LICENSE" cp "$WORK/src/LICENSE" "$DEST/LICENSE"
# Upstream's own tests are not taken. They import `vitest`, which this plugin has no runner for and
# no reason to install — and a test that cannot run is worse than no test, because it reads as
# coverage. Upstream runs them in its own CI, which is where they belong.
find "$DEST" \( -name '*.test.ts' -o -name '*.test.tsx' -o -name '*.spec.ts' -o -name '*.spec.tsx' \) -delete
# lib/ai.ts and components/AiPanel.tsx ARE vendored, despite being the parts Officer least needs. # lib/ai.ts and components/AiPanel.tsx ARE vendored, despite being the parts Officer least needs.
# #
# The first attempt deleted them — they keep model-provider API keys in localStorage and call the # The first attempt deleted them — they keep model-provider API keys in localStorage and call the
+162
View File
@@ -0,0 +1,162 @@
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useSearchParams } from "react-router";
import { useClient } from "hooks/useClient";
import { Canvas, HostProvider, effectivePrompt, titleOf, type Doc, type HostAdapter } from "./adapter";
import "./vendor/globals.css";
/**
* The editor, hosted.
*
* This file is the whole of Officer's contribution to the drawing half: it hands the vendored editor
* a place to keep documents and somewhere for the prompt to go, and otherwise stays out of the way.
*
* ── Saving ──
*
* Upstream calls `saveDoc` on every change — every drag, every colour, every keystroke in a label. That
* is right for `localStorage` and wrong for a database, so writes are debounced and the newest state is
* held in a ref rather than in the dependency list. Putting the document in a dependency array would
* rebuild the adapter on every stroke, and the editor takes the adapter from context at mount.
*
* ── Which document ──
*
* `?doc=<id>` in the URL, per `docs/navigation-audit.md`: selection is addressable, survives a reload,
* and can be linked to. Not a channel — a channel would make "which design am I looking at" a thing you
* cannot bookmark.
*/
const SAVE_DEBOUNCE_MS = 1200;
type DocumentRow = { id: number; title: string; doc: Partial<Doc>; ui: unknown | null };
export const CanvasPanel = () => {
const client = useClient();
// useClient() rebuilds its verbs every render, so it must never sit in a dependency list.
const clientRef = useRef(client);
clientRef.current = client;
const [params, setParams] = useSearchParams();
const requested = params.get("doc");
const [row, setRow] = useState<DocumentRow | null>(null);
const [error, setError] = useState<string | null>(null);
const [ready, setReady] = useState(false);
/** The newest document, outside React state — see the note above on why. */
const latest = useRef<{ doc: Partial<Doc> | null; ui: unknown }>({ doc: null, ui: null });
const saveTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
const rowId = useRef<number | null>(null);
// ── load, or start a new one ────────────────────────────────────────────────────────────────────
useEffect(() => {
let cancelled = false;
setReady(false);
(async () => {
try {
const found = requested
? await clientRef.current.get<DocumentRow>(`/m3e-canvas/documents/${requested}`)
: await clientRef.current.get<DocumentRow | null>("/m3e-canvas/documents/latest");
if (cancelled) return;
rowId.current = found?.id ?? null;
latest.current = { doc: found?.doc ?? null, ui: found?.ui ?? null };
setRow(found ?? null);
setError(null);
} catch {
if (!cancelled) setError("Could not open that design.");
} finally {
// `ready` gates the editor's first render: mounting it before the document has arrived would
// let it start empty and then immediately save that emptiness over the real one.
if (!cancelled) setReady(true);
}
})();
return () => {
cancelled = true;
};
}, [requested]);
// ── saving ──────────────────────────────────────────────────────────────────────────────────────
const flush = useCallback(async () => {
const { doc, ui } = latest.current;
if (!doc) return;
const body = { title: titleOf(doc), doc, ui };
try {
if (rowId.current === null) {
const created = await clientRef.current.post<DocumentRow>("/m3e-canvas/documents", body);
rowId.current = created.id;
// Put the new id in the URL so a reload returns to this design rather than to "latest".
setParams((p) => { p.set("doc", String(created.id)); return p; }, { replace: true });
} else {
await clientRef.current.put(`/m3e-canvas/documents/${rowId.current}`, body);
}
setError(null);
} catch {
setError("Changes are not being saved.");
}
}, [setParams]);
const schedule = useCallback(() => {
if (saveTimer.current) clearTimeout(saveTimer.current);
saveTimer.current = setTimeout(() => void flush(), SAVE_DEBOUNCE_MS);
}, [flush]);
// A pending save must not be lost to an unmount — closing the panel is exactly when the last edit
// is most likely to be sitting in the timer.
useEffect(
() => () => {
if (saveTimer.current) clearTimeout(saveTimer.current);
void flush();
},
[flush],
);
// ── the handoff, which is the point of the whole plugin ─────────────────────────────────────────
const handoff = useCallback((prompt: string, doc: Doc | null) => {
// Fire and forget: the export must not wait on a session starting, and a failure here should not
// stop the clipboard from being a working fallback. Returning false does exactly that.
void clientRef.current
.post("/m3e-canvas/_handoff", { prompt, title: titleOf(doc ?? latest.current.doc) })
.catch(() => setError("Could not hand that to the agent — it is on your clipboard instead."));
return false;
}, []);
// Built once. The editor reads this from context at mount, so an identity change mid-edit would
// remount it and lose the in-progress design.
const host = useMemo<HostAdapter>(
() => ({
loadDoc: () => latest.current.doc,
saveDoc: (doc) => {
latest.current.doc = doc;
schedule();
},
loadUi: () => latest.current.ui,
saveUi: (ui) => {
latest.current.ui = ui;
schedule();
},
onExport: handoff,
}),
[schedule, handoff],
);
if (!ready) return <Centred>Opening…</Centred>;
return (
<div className="relative size-full">
{error ? (
<div className="absolute inset-x-0 top-0 z-50 bg-destructive/90 px-3 py-1.5 text-center text-xs text-destructive-foreground">
{error}
</div>
) : null}
<HostProvider value={host}>
<Canvas key={row?.id ?? "new"} />
</HostProvider>
</div>
);
};
const Centred = ({ children }: { children: React.ReactNode }) => (
<div className="flex size-full items-center justify-center text-sm text-muted-foreground">{children}</div>
);
+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>
);
+58
View File
@@ -0,0 +1,58 @@
/**
* The seam between Officer and the vendored editor.
*
* **Every import of vendored code goes through this file.** Nothing else in the plugin may reach into
* `web/vendor/`, and nothing inside `web/vendor/` may know Officer exists.
*
* That rule is the entire maintenance strategy. Upstream is days old and committing constantly —
* between two commits one day apart it renamed an import, re-indented a 3,000-line component and added
* a new guard. When it renames `Inspector` or changes `buildPrompt`'s signature, the repair is this one
* file rather than every panel. `scripts/vendor.sh` re-copies, `git diff` shows what really changed,
* and any breakage surfaces here.
*
* Upstream's maintainer reviewed this adapter's shape on lnkiai/m3e-canvas#56, called it sound, and
* declined to carry it for now. So it is ours, and it is carried where it costs least.
*/
import type { Doc } from './vendor/lib/tokens';
/** The editor itself — upstream's `app/page.tsx`, vendored as `Canvas.tsx`. */
export { default as Canvas } from './vendor/Canvas';
/**
* The export engine: two pure functions, a `Doc` in and a prompt string out.
*
* This is the reason the plugin exists. Officer's entire addition is what happens to that string —
* upstream writes it to the clipboard, we hand it to an agent.
*/
export { buildPrompt, effectivePrompt } from './vendor/lib/prompt';
/** A document is plain serialisable JSON, which is what makes server-side persistence free. */
export type { Doc, Frame, Group, Item, Palette, Theme } from './vendor/lib/tokens';
export type { Lang } from './vendor/lib/i18n';
export { isLang, setGlobalLang } from './vendor/lib/i18n';
/**
* The host hooks, wired into the vendored editor by `scripts/apply-host.py`.
*
* One correction from upstream's review is load-bearing and worth repeating at the point of use:
* **branch on whether a hook was supplied, never on what it returned.** A host returning null means
* "this user has no saved document", which must start empty — not fall back to whatever the standalone
* app left in this browser and then save that into the user's account.
*/
export { HostProvider, HostContext, useHost } from './vendor/lib/host';
export type { HostAdapter } from './vendor/lib/host';
/** Upstream's stylesheet, imported once by the panel so the editor is drawn in its own vocabulary. */
export const VENDOR_STYLESHEET = './vendor/globals.css';
/**
* The title Officer shows in a document list.
*
* Officer does not otherwise read inside a `Doc` — the schema stores it whole precisely so upstream can
* add fields without a migration here. This one reach-in is the exception, and it is in the adapter so
* it is the only place that has to change if `title` moves.
*/
export const titleOf = (doc: Partial<Doc> | null | undefined): string =>
(doc && typeof doc.title === 'string' && doc.title.trim()) || 'Untitled';
+14
View File
@@ -0,0 +1,14 @@
import type { LayoutNode } from 'officerdev';
// The canvas takes almost everything. Upstream's editor carries its own rails, palette and inspector
// inside that pane, so a narrow list beside it is all Officer needs to add — and the list is the only
// thing upstream has no concept of, because it assumes one document in one browser.
export const defaultLayout: LayoutNode = {
type: 'group',
id: 'm3e-root',
direction: 'horizontal',
children: [
{ node: { type: 'panel', id: 'm3e-designs', appType: 'm3e-designs' }, size: 18 },
{ node: { type: 'panel', id: 'm3e-canvas', appType: 'm3e-canvas' }, size: 82 },
],
};
+13
View File
@@ -0,0 +1,13 @@
import { PenTool, Files } from 'lucide-react';
import type { AppRegistryMeta } from 'officerdev';
import { CanvasPanel } from './CanvasPanel';
import { DesignsPanel } from './DesignsPanel';
// Two panels. The canvas is the plugin; the list is how you get between designs.
//
// `availableOnPanel: false` — PluginScreen renders the workspace locked, so offering these in the
// generic picker would be a lie.
export const appRegistryMetas: AppRegistryMeta[] = [
{ key: 'm3e-designs', name: 'Designs', icon: Files, component: DesignsPanel, availableOnPanel: false },
{ key: 'm3e-canvas', name: 'Canvas', icon: PenTool, component: CanvasPanel, availableOnPanel: false },
];
-79
View File
@@ -1,79 +0,0 @@
import { describe, it, expect } from "vitest";
import { hexToRgb, isHex, onColorFor, rgbToHex, rgbToLab, schemeFromSeed } from "./color";
/** perceptual lightness of a hex color, for readable-contrast assertions */
const toneOf = (hex: string) => rgbToLab(...hexToRgb(hex)!).L;
describe("isHex", () => {
it("accepts a six-digit hex color with a leading #", () => {
expect(isHex("#6750A4")).toBe(true);
expect(isHex("#ff0088")).toBe(true);
});
it("rejects shorthand, missing #, and non-hex input", () => {
expect(isHex("#fff")).toBe(false);
expect(isHex("6750A4")).toBe(false);
expect(isHex("#GGGGGG")).toBe(false);
});
});
describe("hexToRgb / rgbToHex", () => {
it("parses a hex color into channels", () => {
expect(hexToRgb("#6750A4")).toEqual([103, 80, 164]);
expect(hexToRgb("6750a4")).toEqual([103, 80, 164]);
});
it("round-trips any channel values through both directions", () => {
for (const [r, g, b] of [[0, 0, 0], [255, 255, 255], [103, 80, 164], [18, 250, 199]] as const) {
expect(hexToRgb(rgbToHex(r, g, b))).toEqual([r, g, b]);
}
});
it("clamps channels outside 0..255 when formatting", () => {
expect(rgbToHex(300, -4, 7.6)).toBe("#FF0008");
});
it("returns null for input that is not a hex color", () => {
expect(hexToRgb("#XYZ123")).toBeNull();
expect(hexToRgb("#ABC")).toBeNull();
});
});
describe("schemeFromSeed", () => {
const seed = "#6750A4";
it("honours the dark option: surfaces go dark, text on them light", () => {
const light = schemeFromSeed(seed);
const dark = schemeFromSeed(seed, "Custom", { dark: true });
expect(toneOf(dark.surface)).toBeLessThan(toneOf(light.surface));
expect(toneOf(dark.onSurface)).toBeGreaterThan(toneOf(light.onSurface));
});
it("honours the contrast option: high contrast pushes the accent further from the surface", () => {
const standard = schemeFromSeed(seed);
const high = schemeFromSeed(seed, "Custom", { contrast: "high" });
expect(high.primary).not.toBe(standard.primary);
expect(Math.abs(toneOf(high.primary) - toneOf(high.surface))).toBeGreaterThan(
Math.abs(toneOf(standard.primary) - toneOf(standard.surface)),
);
});
});
describe("onColorFor", () => {
it("picks a dark on-color on a light background", () => {
const on = onColorFor("#FFFFFF");
expect(isHex(on)).toBe(true);
expect(toneOf(on)).toBeLessThan(40);
});
it("picks a light on-color on a dark background", () => {
const on = onColorFor("#000000");
expect(isHex(on)).toBe(true);
expect(toneOf(on)).toBeGreaterThan(90);
});
it("falls back to black when the background cannot be parsed", () => {
expect(onColorFor("nope")).toBe("#000000");
});
});
-14
View File
@@ -1,14 +0,0 @@
import { describe, it, expect } from "vitest";
import { isLang, LANGS } from "./i18n";
describe("isLang", () => {
it("accepts every language the UI offers", () => {
for (const { key } of LANGS) expect(isLang(key)).toBe(true);
});
it("rejects anything else", () => {
expect(isLang("fr")).toBe(false);
expect(isLang(undefined)).toBe(false);
});
});
-111
View File
@@ -1,111 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { Lang, setGlobalLang } from "./i18n";
import { buildPrompt } from "./prompt";
import { BACK_TARGET, Doc, Item, Platform, defaultTabs, makeItem } from "./tokens";
const LANGS: Lang[] = ["ja", "en", "zh", "ko"];
/* Section headings in the order buildPrompt must emit them. */
const SECTIONS: Record<Lang, string[]> = {
ja: ["## カラー", "## 形・文字・動き", "## 画面構成", "## 振る舞いと画面遷移", "## 各部品のスタイル", "## 全体の指針"],
en: ["## Colors", "## Shape, type and motion", "## Layout", "## Behavior and navigation", "## Component styles", "## General guidance"],
zh: ["## 配色", "## 形状、字体与动效", "## 屏幕结构", "## 行为与屏幕跳转", "## 各组件的样式", "## 整体原则"],
ko: ["## 색상", "## 모양, 글꼴 및 모션", "## 화면 구성", "## 동작 및 화면 전환", "## 부품별 스타일", "## 전체 지침"],
};
const PLATFORM_LINE: Record<Lang, Record<Platform, string>> = {
ja: { android: "実装先は Android(ネイティブアプリ)です。", web: "実装先は Web(ブラウザで動くアプリ)です。" },
en: { android: "Build it for Android, as a native app.", web: "Build it for the web, as an app that runs in the browser." },
zh: { android: "实现目标是 Android(原生应用)。", web: "实现目标是 Web(在浏览器中运行的应用)。" },
ko: { android: "Android 네이티브 앱으로 구현한다.", web: "브라우저에서 실행되는 웹 앱으로 구현한다." },
};
/* One phone screen with a top app bar, a connected pair of buttons (one with a
* tap action) and a navigation bar. makeItem / defaultTabs fill in the defaults;
* module-level language is set first so those defaults follow the test. */
function fixture(platform: Platform = "android", extraItems: Item[] = []): Doc {
const bar: Item = { ...makeItem("topAppBar"), id: "bar", label: "Home" };
const save: Item = { ...makeItem("button"), id: "save", label: "Save", action: { to: BACK_TARGET, transition: "fade" } };
const cancel: Item = { ...makeItem("button"), id: "cancel", label: "Cancel", variant: "text" };
const nav: Item = { ...makeItem("bottomNav"), id: "nav", tabs: defaultTabs() };
return {
groups: [
{ id: "g-bar", x: 16, y: 24, axis: "x", items: [bar] },
{ id: "g-row", x: 16, y: 400, axis: "x", items: [save, cancel, ...extraItems] },
{ id: "g-nav", x: 16, y: 812, axis: "x", items: [nav] },
],
frames: [{ id: "f-home", name: "Home", x: 0, y: 0 }],
paletteKey: "purple",
frame: "phone",
platform,
title: "Notes",
brief: "",
};
}
/* Set the module-level language for the fixture helpers, then build explicitly
* in that language — nothing is left to ambient state. */
function build(lang: Lang, platform: Platform = "android", extraItems: Item[] = []) {
setGlobalLang(lang);
return buildPrompt(fixture(platform, extraItems), {}, undefined, lang);
}
const lines = (prompt: string) => prompt.split("\n");
const headings = (prompt: string) => lines(prompt).filter((l) => l.startsWith("## "));
/* bullet lines between the style heading and the closing guidance heading */
function styleBullets(prompt: string, lang: Lang) {
const ls = lines(prompt);
const style = ls.indexOf(SECTIONS[lang][4]);
const general = ls.indexOf(SECTIONS[lang][5]);
return ls.slice(style + 1, general).filter((l) => l.startsWith("- "));
}
const QUOTED: Record<Lang, { label: string; others: string[] }> = {
ja: { label: "「Save」", others: ['"Save"', "“Save”"] },
en: { label: '"Save"', others: ["「Save」", "“Save”"] },
zh: { label: "“Save”", others: ["「Save」", '"Save"'] },
ko: { label: '"Save"', others: ["「Save」", "“Save”"] },
};
describe("buildPrompt structure", () => {
afterEach(() => setGlobalLang("ja")); // restore the module default
it.each(LANGS)("orders its sections the same way in %s", (lang) => {
expect(headings(build(lang))).toEqual(SECTIONS[lang]);
});
it.each(LANGS)("names the requested platform on the intro lines in %s", (lang) => {
const android = lines(build(lang, "android"));
const web = lines(build(lang, "web"));
expect(android[2]).toBe(PLATFORM_LINE[lang].android);
expect(web[2]).toBe(PLATFORM_LINE[lang].web);
});
it("names Android when the doc picks no platform", () => {
setGlobalLang("en");
const { platform, ...doc } = fixture();
expect(lines(buildPrompt(doc, {}, undefined, "en"))[2]).toBe(PLATFORM_LINE.en.android);
});
it.each(LANGS)("writes one style note per part kind in use in %s", (lang) => {
expect(styleBullets(build(lang), lang)).toHaveLength(3); // topAppBar, button, bottomNav
const chip: Item = { ...makeItem("chip"), id: "chip" };
expect(styleBullets(build(lang, "android", [chip]), lang)).toHaveLength(4);
});
it.each(LANGS)("quotes labels with %s punctuation", (lang) => {
const prompt = build(lang);
expect(prompt).toContain(QUOTED[lang].label);
for (const other of QUOTED[lang].others) expect(prompt).not.toContain(other);
});
it("follows its lang argument regardless of ambient module state", () => {
setGlobalLang("en");
const doc = fixture();
setGlobalLang("zh");
const prompt = buildPrompt(doc, {}, undefined, "ja");
expect(headings(prompt)).toEqual(SECTIONS.ja);
expect(prompt).toContain("「Save」");
});
});
-99
View File
@@ -1,99 +0,0 @@
import { describe, expect, it } from "vitest";
import { DURATION_PER_SHAPE_MS, LoadingAnimator, SHAPE_COUNT, Spring, getShapes, morphedShape } from "./shapes";
describe("getShapes", () => {
it("samples every shape in the sequence with the same point count", () => {
const shapes = getShapes();
expect(shapes).toHaveLength(SHAPE_COUNT);
const n = shapes[0].length;
expect(n).toBeGreaterThan(0);
for (const s of shapes) expect(s).toHaveLength(n);
});
it("normalizes every shape into the unit box, up to resampling drift", () => {
for (const shape of getShapes()) {
let extreme = 0;
for (const [x, y] of shape) {
expect(Math.abs(x)).toBeLessThanOrEqual(1 + 1e-9);
expect(Math.abs(y)).toBeLessThanOrEqual(1 + 1e-9);
extreme = Math.max(extreme, Math.abs(x), Math.abs(y));
}
/* resampling along the arc can step just past the exact extreme vertex */
expect(extreme).toBeGreaterThan(0.99);
}
});
it("caches the sampled shapes", () => {
expect(getShapes()).toBe(getShapes());
});
});
describe("morphedShape", () => {
it("returns a shape exactly at every whole fraction", () => {
const shapes = getShapes();
for (let i = 0; i < SHAPE_COUNT; i++) expect(morphedShape(i)).toEqual(shapes[i]);
});
it("interpolates halfway between neighbours at a half fraction", () => {
const [a, b] = getShapes();
const mid = morphedShape(0.5);
expect(mid[0]).toEqual([(a[0][0] + b[0][0]) / 2, (a[0][1] + b[0][1]) / 2]);
});
it("wraps around the sequence, past both ends", () => {
expect(morphedShape(SHAPE_COUNT)).toEqual(getShapes()[0]);
const shapes = getShapes();
const [last, first] = [shapes[SHAPE_COUNT - 1], shapes[0]];
const mid = morphedShape(-0.5);
expect(mid[0]).toEqual([(last[0][0] + first[0][0]) / 2, (last[0][1] + first[0][1]) / 2]);
});
});
describe("Spring", () => {
it("settles at its target", () => {
const s = new Spring(200, 0.6);
s.target = 1;
for (let i = 0; i < 600; i++) s.step(1 / 60);
expect(s.pos).toBeCloseTo(1, 2);
expect(Math.abs(s.vel)).toBeLessThan(0.01);
});
it("overshoots when underdamped, not when critically damped", () => {
const peakAfter = (damping: number) => {
const s = new Spring(200, damping);
s.target = 1;
let peak = 0;
for (let i = 0; i < 120; i++) {
s.step(1 / 60);
peak = Math.max(peak, s.pos);
}
return peak;
};
expect(peakAfter(0.6)).toBeGreaterThan(1);
expect(peakAfter(1)).toBeLessThanOrEqual(1.001);
});
});
describe("LoadingAnimator", () => {
it("advances the morph with time while keeping rotation inside one turn", () => {
const a = new LoadingAnimator();
for (let ts = 0; ts <= 3 * DURATION_PER_SHAPE_MS; ts += 50) {
a.update(ts);
expect(a.rotation).toBeGreaterThanOrEqual(0);
expect(a.rotation).toBeLessThan(360);
}
expect(a.morph).toBeGreaterThan(0);
});
it("is deterministic for identical frame sequences", () => {
const a = new LoadingAnimator();
const b = new LoadingAnimator();
for (let ts = 0; ts <= 2000; ts += 33) {
a.update(ts);
b.update(ts);
}
expect(a.morph).toBe(b.morph);
expect(a.rotation).toBe(b.rotation);
});
});
-52
View File
@@ -1,52 +0,0 @@
import { describe, expect, it } from "vitest";
import { tidyFrame } from "./tidy";
import { Frame, Group, Item, Kind, NAV_BAR_H, PHONE_H, PHONE_MARGIN, PHONE_W, makeItem } from "./tokens";
const frame: Frame = { id: "f1", name: "Home", x: 0, y: 0 };
const frames = [frame];
const grp = (id: string, x: number, y: number, items: Item[]): Group => ({ id, x, y, axis: "x", items });
const part = (kind: Kind, id: string): Item => ({ ...makeItem(kind), id });
describe("tidyFrame", () => {
it("snaps the app bar to the top edge and the navigation bar to the bottom", () => {
const groups = [grp("g-bar", 40, 300, [part("topAppBar", "bar")]), grp("g-nav", 40, 100, [part("bottomNav", "nav")])];
const out = tidyFrame(groups, frame, frames, {});
const bar = out!.find((g) => g.id === "g-bar")!;
const nav = out!.find((g) => g.id === "g-nav")!;
expect([bar.x, bar.y]).toEqual([0, 0]);
expect([nav.x, nav.y]).toEqual([0, PHONE_H - (80 + NAV_BAR_H)]);
});
it("moves a FAB to the bottom-right corner, one margin in", () => {
const out = tidyFrame([grp("g-fab", 40, 100, [part("fab", "fab")])], frame, frames, {});
expect([out![0].x, out![0].y]).toEqual([PHONE_W - PHONE_MARGIN - 56, PHONE_H - PHONE_MARGIN - 56]);
});
it("joins neighbouring buttons into one connected run in reading order", () => {
const out = tidyFrame([grp("g1", 16, 300, [part("button", "b1")]), grp("g2", 150, 300, [part("button", "b2")])], frame, frames, {});
expect(out).toHaveLength(1);
expect(out![0].items.map((it) => it.id)).toEqual(["b1", "b2"]);
});
it("joins buttons dropped onto each other, in reading order", () => {
const out = tidyFrame([grp("g1", 150, 300, [part("button", "b1")]), grp("g2", 100, 304, [part("button", "b2")])], frame, frames, {});
expect(out).toHaveLength(1);
expect(out![0].items.map((it) => it.id)).toEqual(["b2", "b1"]);
});
it("joins neighbouring list items into one connected column", () => {
const out = tidyFrame([grp("g1", 16, 100, [part("listItem", "l1")]), grp("g2", 16, 180, [part("listItem", "l2")])], frame, frames, {});
expect(out).toHaveLength(1);
expect(out![0].axis).toBe("y");
expect(out![0].items.map((it) => it.id)).toEqual(["l1", "l2"]);
});
it("returns null for a frame that is already tidy", () => {
const messy = [grp("g-bar", 40, 300, [part("topAppBar", "bar")]), grp("g-fab", 10, 10, [part("fab", "fab")])];
const once = tidyFrame(messy, frame, frames, {});
expect(once).not.toBeNull();
expect(tidyFrame(once!, frame, frames, {})).toBeNull();
});
});
-69
View File
@@ -1,69 +0,0 @@
import { afterEach, describe, expect, it } from "vitest";
import { DEFAULT_THEME, R_FULL, baseRadii, makeItem, normalizeTheme, runCorners, scaleR, setGlobalShape } from "./tokens";
afterEach(() => setGlobalShape("rounded")); // restore the module default
describe("setGlobalShape / scaleR", () => {
it("shrinks radii for the square scale and grows them for full", () => {
setGlobalShape("square");
expect(scaleR(R_FULL)).toBe(Math.round(R_FULL * 0.35));
setGlobalShape("full");
expect(scaleR(R_FULL)).toBe(Math.round(R_FULL * 1.6));
setGlobalShape("rounded");
expect(scaleR(R_FULL)).toBe(R_FULL);
});
it("flows into a part's default corners", () => {
setGlobalShape("rounded");
const rounded = baseRadii(makeItem("button")).tl;
setGlobalShape("square");
expect(baseRadii(makeItem("button")).tl).toBeLessThan(rounded);
setGlobalShape("full");
expect(baseRadii(makeItem("button")).tl).toBeGreaterThan(rounded);
});
it("keeps a radius the author typed in, whatever the scale", () => {
const card = { ...makeItem("card"), radiusTop: 12 };
setGlobalShape("full");
expect(baseRadii(card)).toEqual({ tl: 12, tr: 12, bl: 12, br: 12 });
});
});
describe("runCorners", () => {
const outer = 28;
const inner = 8;
it("puts the outer corners at the ends of a horizontal run, inner between parts", () => {
expect(runCorners("x", true, false, outer, inner)).toEqual({ tl: outer, bl: outer, tr: inner, br: inner });
expect(runCorners("x", false, true, outer, inner)).toEqual({ tl: inner, bl: inner, tr: outer, br: outer });
});
it("puts the outer corners at the ends of a vertical run, inner between parts", () => {
expect(runCorners("y", true, false, outer, inner)).toEqual({ tl: outer, tr: outer, bl: inner, br: inner });
expect(runCorners("y", false, true, outer, inner)).toEqual({ tl: inner, tr: inner, bl: outer, br: outer });
});
it("rounds a lone part all over and a middle part nowhere", () => {
expect(runCorners("x", true, true, outer, inner)).toEqual({ tl: outer, tr: outer, bl: outer, br: outer });
expect(runCorners("y", false, false, outer, inner)).toEqual({ tl: inner, tr: inner, bl: inner, br: inner });
});
});
describe("normalizeTheme", () => {
it("returns the defaults untouched for undefined or empty input", () => {
expect(normalizeTheme(undefined)).toEqual(DEFAULT_THEME);
expect(normalizeTheme({})).toEqual(DEFAULT_THEME);
});
it("keeps the valid fields of a partial theme and fills in the rest", () => {
expect(normalizeTheme({ dark: true, shape: "full" })).toEqual({ ...DEFAULT_THEME, dark: true, shape: "full" });
});
it("replaces unknown option values with the default", () => {
const t = normalizeTheme({ contrast: "blaring" as never, font: "papyrus" as never, shape: "pointy" as never });
expect(t.contrast).toBe(DEFAULT_THEME.contrast);
expect(t.font).toBe(DEFAULT_THEME.font);
expect(t.shape).toBe(DEFAULT_THEME.shape);
});
});