diff --git a/README.md b/README.md new file mode 100644 index 0000000..1b15959 --- /dev/null +++ b/README.md @@ -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=` 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 +``` diff --git a/api/router.ts b/api/router.ts new file mode 100644 index 0000000..3e95672 --- /dev/null +++ b/api/router.ts @@ -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(); +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 }); +}); diff --git a/db/schema.ts b/db/schema.ts new file mode 100644 index 0000000..68020a2 --- /dev/null +++ b/db/schema.ts @@ -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; diff --git a/docs/handoff.md b/docs/handoff.md new file mode 100644 index 0000000..d0b213e --- /dev/null +++ b/docs/handoff.md @@ -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=/`, +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. diff --git a/manifest.ts b/manifest.ts new file mode 100644 index 0000000..8699407 --- /dev/null +++ b/manifest.ts @@ -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', + }, +}; diff --git a/scripts/vendor.sh b/scripts/vendor.sh index 7a6a9bb..ee7c1cf 100755 --- a/scripts/vendor.sh +++ b/scripts/vendor.sh @@ -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/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. # # The first attempt deleted them — they keep model-provider API keys in localStorage and call the diff --git a/web/CanvasPanel.tsx b/web/CanvasPanel.tsx new file mode 100644 index 0000000..7dc7468 --- /dev/null +++ b/web/CanvasPanel.tsx @@ -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=` 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; 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(null); + const [error, setError] = useState(null); + const [ready, setReady] = useState(false); + + /** The newest document, outside React state — see the note above on why. */ + const latest = useRef<{ doc: Partial | null; ui: unknown }>({ doc: null, ui: null }); + const saveTimer = useRef | null>(null); + const rowId = useRef(null); + + // ── load, or start a new one ──────────────────────────────────────────────────────────────────── + useEffect(() => { + let cancelled = false; + setReady(false); + + (async () => { + try { + const found = requested + ? await clientRef.current.get(`/m3e-canvas/documents/${requested}`) + : await clientRef.current.get("/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("/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( + () => ({ + 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 Opening…; + + return ( +
+ {error ? ( +
+ {error} +
+ ) : null} + + + +
+ ); +}; + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
{children}
+); diff --git a/web/DesignsPanel.tsx b/web/DesignsPanel.tsx new file mode 100644 index 0000000..35ea892 --- /dev/null +++ b/web/DesignsPanel.tsx @@ -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=` 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(null); + const [error, setError] = useState(null); + const [params, setParams] = useSearchParams(); + const selected = params.get("doc"); + + const load = useCallback(async () => { + try { + setRows(await clientRef.current.get("/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 ( +
+
+ + +
+ + {error ?

{error}

: null} + + {rows === null ? ( + Loading… + ) : rows.length === 0 ? ( + No designs yet. Draw something and it saves itself. + ) : ( +
    + {rows.map((row) => ( +
  • + + +
  • + ))} +
+ )} +
+ ); +}; + +const Centred = ({ children }: { children: React.ReactNode }) => ( +
+ {children} +
+); diff --git a/web/adapter.ts b/web/adapter.ts new file mode 100644 index 0000000..4213099 --- /dev/null +++ b/web/adapter.ts @@ -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 | null | undefined): string => + (doc && typeof doc.title === 'string' && doc.title.trim()) || 'Untitled'; diff --git a/web/layout.ts b/web/layout.ts new file mode 100644 index 0000000..f4361a2 --- /dev/null +++ b/web/layout.ts @@ -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 }, + ], +}; diff --git a/web/panels.ts b/web/panels.ts new file mode 100644 index 0000000..f404857 --- /dev/null +++ b/web/panels.ts @@ -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 }, +]; diff --git a/web/vendor/lib/color.test.ts b/web/vendor/lib/color.test.ts deleted file mode 100644 index 228bfc3..0000000 --- a/web/vendor/lib/color.test.ts +++ /dev/null @@ -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"); - }); -}); diff --git a/web/vendor/lib/i18n.test.ts b/web/vendor/lib/i18n.test.ts deleted file mode 100644 index 6d5ffcc..0000000 --- a/web/vendor/lib/i18n.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/web/vendor/lib/prompt.test.ts b/web/vendor/lib/prompt.test.ts deleted file mode 100644 index af2602a..0000000 --- a/web/vendor/lib/prompt.test.ts +++ /dev/null @@ -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 = { - ja: ["## カラー", "## 形・文字・動き", "## 画面構成", "## 振る舞いと画面遷移", "## 各部品のスタイル", "## 全体の指針"], - en: ["## Colors", "## Shape, type and motion", "## Layout", "## Behavior and navigation", "## Component styles", "## General guidance"], - zh: ["## 配色", "## 形状、字体与动效", "## 屏幕结构", "## 行为与屏幕跳转", "## 各组件的样式", "## 整体原则"], - ko: ["## 색상", "## 모양, 글꼴 및 모션", "## 화면 구성", "## 동작 및 화면 전환", "## 부품별 스타일", "## 전체 지침"], -}; - -const PLATFORM_LINE: Record> = { - 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 = { - 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」"); - }); -}); diff --git a/web/vendor/lib/shapes.test.ts b/web/vendor/lib/shapes.test.ts deleted file mode 100644 index 0ac8652..0000000 --- a/web/vendor/lib/shapes.test.ts +++ /dev/null @@ -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); - }); -}); diff --git a/web/vendor/lib/tidy.test.ts b/web/vendor/lib/tidy.test.ts deleted file mode 100644 index fe9df61..0000000 --- a/web/vendor/lib/tidy.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/web/vendor/lib/tokens.test.ts b/web/vendor/lib/tokens.test.ts deleted file mode 100644 index 2435a64..0000000 --- a/web/vendor/lib/tokens.test.ts +++ /dev/null @@ -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); - }); -});