Runtime Boundaries Are Product Features

The confusing bug was not a broken button.
It was a feature that worked exactly where the architecture allowed it, and nowhere the user expected it.
A Figma plugin can look like one small product: pick a node, generate some code, maybe refine it, maybe copy it. Under the hood, it can be two products with different powers. One context can read the design document and export a vector. Another can render a preview and talk to the network. One mode can answer a selection change immediately. Another can keep a full UI open and operate on several frames.
If that distinction stays in main.ts, users meet it as a mystery.
“Why did the import work but not affect this preview?”
“Why can I refine this output here but not there?”
“Why did this feature quietly disappear?”
The software is technically correct. The product contract is not visible.
One plugin, two capability maps
The first useful question is not “Which screen should contain this button?”
It is “Which runtime owns the capability?”
In the recorded plugin architecture, the sandbox owns document access. It can inspect selection and export vector data. The iframe owns a visual UI and can perform network-backed work. They do not share a global object, a DOM, or a convenient escape hatch. They communicate through messages.
That message boundary is not plumbing. It decides where a feature may exist.
The same is true for a plugin mode boundary. A code-generation surface is excellent at a tight, deterministic inspect-and-copy loop. A persistent run surface is excellent at preview, batch work, and optional refinement. Trying to make both surfaces promise every capability is how a simple architecture turns into a haunted kitchen drawer full of special cases. (Every home has one.)
Instead, write the capability map down.
- Which context owns the source data?
- Which context owns UI state?
- Which operations require a network?
- Which operations must remain deterministic?
- Which mode is allowed to offer each operation?
Those answers belong in product copy, empty states, and tests, not only in an architecture note.
Make the boundary a typed contract
There is a practical way to keep two contexts from slowly inventing different realities: define each message once on the side that owns the capability, then import that contract from the UI.
That sounds fussy until the first message changes.
Without one shared contract, the UI can keep sending a payload the host no longer understands. The host can produce an error shape the UI never renders. Both sides still compile. The feature merely develops a very private disagreement with itself.
Typed handlers do not remove the runtime boundary. They make it honest.
The contract should carry both success and failure as deliberate messages. If an operation is unavailable in a mode, the UI should say so before the user spends time configuring it. An unavailable feature is not an error state. It is a capability decision that needs a sentence.
Keep host calls at the edge
The vector-export pattern offers a second lesson. When a host-only API produces data that a pure transformation pipeline needs, call the API at the host edge and inject the resulting data into the pipeline.
Do not let the pure module reach back into the host environment just because the data is inconvenient.
That move keeps the hard dependency in one place. The rest of the conversion pipeline can operate on plain data, be tested with fixtures, and be reused by both plugin modes. The boundary becomes smaller instead of infecting every transform with a runtime it cannot reproduce in a normal test.
This is the useful pattern:
host capability → enriched data → pure transform → rendered result
It is boring in the best way. The test does not need a Figma document. The transform does not need to know where its SVG came from. The host call remains easy to audit.
Feature parity is not always the goal
It is tempting to treat every capability split as unfinished parity. Sometimes it is.
But a deterministic single-node path and an optional multi-frame refinement path can be two good products with different trade-offs. The failure is not the split. The failure is acting as if the split does not exist.
Before adding a cross-context feature, I now ask a smaller question:
If this feature is unavailable here, will the user understand why without reading the source code?
If the answer is no, the missing work may be a label, an explanation, or a routed action rather than another layer of abstraction.
Runtime boundaries shape the product. Treat them like product features, and users stop discovering architecture through disappointment.
Get the next post.
If you made it to the end, meet the next post in your inbox or RSS reader.