Panels and windows

Give an Athanor plugin a window with ui.html, open it as a panel or from a command, and pass messages between the window and main.js.

What a plugin window is

A plugin with a user interface has two parts that never touch:

  • The logic, main.js, runs in the plugin's sandbox. It can call the athanor API (read the page, apply changes) but has no page to draw on.
  • The window, ui.html, is an ordinary HTML page in a locked-down frame. It can draw anything, but it cannot reach the document, the app, your account or the network (unless the plugin holds Access the internet).

The two talk only by messages, which Athanor relays and checks.

Athanor always draws the window's frame: the title bar with the plugin's name, an Official badge where it applies, and the close button. A plugin draws only inside it, so it can never pass off its window as one of Athanor's dialogs.

Plugin windows float over the canvas by default, and a person can dock one beside the canvas like Athanor's own panels.

There are two ways to get a window:

Way Declare Opened by
A panel contributes.panels and ui in the manifest The person: Main menu › Object › Plugins › Open panel title, Ctrl+K, or Open in the Plugins window
A window from code ui in the manifest Your logic, with athanor.ui.show()

A minimal panel

This is the Panel template from New plugin: Panel template…. It shows how many structures are on the page and adds benzene when you press the button.

manifest.json:

{
  "id": "local.my-panel",
  "name": "My panel",
  "version": "0.1.0",
  "api": "1.0.0",
  "author": "You",
  "description": "My panel, made from the panel template.",
  "main": "main.js",
  "ui": "ui.html",
  "permissions": ["document.read", "document.edit"],
  "contributes": {
    "panels": [{ "key": "main", "title": "My panel", "defaultDock": "right" }]
  }
}

main.js:

// @ts-check
/// <reference path="./athanor.d.ts" />
 
// A panel: ui.html is its window, docked like Athanor's own panels. This file is its logic.
async function tell() {
  const page = await athanor.snapshot();
  athanor.ui.postMessage({ type: 'count', text: `${page.structures.length} on the page` });
}
athanor.ui.onmessage = async (m) => {
  const msg = /** @type {{type: string}} */ (m);
  if (msg.type === 'hello') await tell();
  if (msg.type === 'add') await athanor.apply({ label: 'Add benzene', ops: [{ op: 'addStructure', smiles: 'c1ccccc1' }] });
};
// The page changed: keep the window current.
athanor.on('documentchange', () => { void tell(); });

ui.html:

<div class="stack" style="padding:12px">
  <h2>Structures on the page</h2>
  <p id="out" class="hint">Draw something.</p>
  <div class="row"><button id="add" class="primary">Add benzene</button></div>
</div>
<script>
  // The page talks only to the plugin's main.js, through athanor.postMessage / athanor.onmessage.
  document.getElementById('add').onclick = () => athanor.postMessage({ type: 'add' });
  athanor.onmessage = (m) => { if (m.type === 'count') document.getElementById('out').textContent = m.text; };
  athanor.postMessage({ type: 'hello' });
</script>

The window says hello as soon as it loads; that first message is also what starts the plugin's logic. The logic answers with the count, and documentchange keeps the count current as the person draws.

Messages, on each side

In main.js (the logic) In ui.html (the window)
athanor.ui.postMessage(data) sends to the window athanor.postMessage(data) sends to the logic
athanor.ui.onmessage = (data) => { } receives from the window athanor.onmessage = (data) => { } receives from the logic
athanor.addEventListener('message', fn) adds another listener

data must be plain data that survives JSON: objects, arrays, strings, numbers, booleans and null. Setting athanor.onmessage in the window replaces every earlier listener.

A window from a command

Declare ui and a command, then open the window from the command:

athanor.commands.on('open', () => {
  athanor.ui.show({ width: 360, height: 280, title: 'Molar masses' });
});
Call What it does
athanor.ui.show({ width, height, title }) Opens the plugin's window, or updates it if it is open. Width defaults to 320 and is held between 160 and 1200; height defaults to 240 and is held between 120 and 900. The title defaults to the plugin's name and is cut at 60 characters
athanor.ui.resize(width, height) Resizes the open window, within the same bounds
athanor.ui.hide() Closes it
athanor.closePlugin(message?) Shows the optional message (up to 100 characters) and closes the window

The person moves the window by its title bar and closes it with the close button.

Rules and limits

  • ui.html is one self-contained page. Put your CSS in a <style> element and your script in <script> elements. Nothing loads from outside: no script, stylesheet, font or image URLs. Images and fonts can be data: or blob: URLs.
  • The frame has no storage and no cookies. Keep state in the logic, or in settings.
  • The frame cannot submit forms, open popups, navigate the app or start downloads. To give the person a file, add an exporter.
  • Panels need a ui file; a manifest with panels and no ui is refused.
  • Up to 50 panels per plugin. A panel key follows the same rule as a command key. title is required, at most 40 characters. icon names an icon from Athanor's own set (at most 40 characters).
  • defaultDock ("right", "left" or "float") is accepted and stored. API 1 does not place the panel by it yet.
  • If the window crashes or goes blank, it shows "This plugin's window stopped working. Your work is safe." with Reload and Close. The app is untouched.
  • If the plugin is turned off, its panel says so and offers Turn back on.

For how to make the window look like Athanor without writing CSS, see Theming.

Is this helpful?

Last updated Nov 11, 2026