pastilhas 80529dd9e7 six defects, and a typecheck the repo could not pass
Found by reviewing the plugin rather than running it. Nothing was live — the
table does not exist and the plugin is not installed — so all of these were
waiting for the first person to use it.

── The second unnamed design could not be saved ──

`uniqueIndex(userId, title)`, and every design starts life called "Untitled":
`titleOf` returns it for a document with no title and the API defaults to it. So
the first saved, the second collided, and the insert threw under a panel that
could only say "Changes are not being saved." Renaming one design to match
another did the same.

Two designs may share a name, as files and notes and dashboards do. Nothing looks
a design up by title, so uniqueness bought nothing. The index is now ordinary and
on the columns the list query actually uses.

The lesson from `dashboards.id` that the schema comment cites is that a key must
be scoped to its owner. `userId` on the row does that; making the pair unique as
well imported the collision it was citing, one account at a time.

── New did not start a new design ──

It cleared `?doc=`, and no parameter already means "open what I had last",
answered with `/documents/latest`. So New handed back the design on screen and
pointed the next save at its id. There is a `new` sentinel now, in its own module
because both panels read it and neither owns it.

── Two ways the load effect raced the debounced save ──

Changing `?doc=` does not unmount the panel, so the unmount flush never ran for
it: an edit made within the debounce and navigated away from was written after
`latest.current` had been replaced, which is to say never.

And the first save of a new design puts its id in the URL, which lands in the
same effect — re-reading the row overwrote in-memory state with the copy the
server had just been given, discarding whatever was drawn during the round trip.

The effect now flushes what is pending before it reads, and does not re-read a
document it already holds. The editor is keyed on a load generation rather than
on `row?.id`, which could not express "blank again" and left the previous design
on screen with `rowId` cleared underneath it.

── The typecheck ──

354 of the 355 errors were upstream's tree measured against this platform's
settings, which it was never written for. `bunx tsgo` is how this repo is kept
clean and it could not be clean while this plugin existed. `exclude` does not
help: adapter.ts imports the tree back in. So vendor.sh marks each vendored file
unchecked in the same deterministic pass that rewrites its imports. Types still
resolve; only the errors inside those files are silenced, which is what the
adapter already says the seam is for. Whole repo: 0.

── PROVENANCE.md understated what is done to the copy ──

It said "the one edit vendor.sh makes" and described the import rewrite. It also
runs apply-host.py, which places our own lib/host.ts and wires three export
paths. Four files are not upstream and the document whose job is making the copy
trustworthy did not say so. It lists all four now.

Also: web/host.ts imported a path only correct at its destination, so the file
could never typecheck where it is edited — apply-host.py rewrites that line when
placing it, and re-marks the placed copy, which it overwrites after vendor.sh has
marked it. PUT is no longer full-replace: omitting `title` renamed a design to
"Untitled" and omitting `ui` nulled it, a trap set for the second caller. And a
document is bounded at 4 MB, measured in bytes, where the title was capped at 200
and the document at nothing.
2026-09-05 19:52:27 +01:00

Canvas

Sketch a Material 3 screen, and hand the prompt to the agent.

The editor is 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 and PR #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
S
Description
Sketch a Material 3 screen, hand the prompt to the agent
Readme
277 KiB
Languages
TypeScript 66.3%
Python 18.9%
Shell 14.8%