Imperal Docs
Recipes

Center-overlay panel

Full-bleed editor that mounts in the center via auto_action — Vikunja-class pattern done right

Hardcoded allowlist — read before naming your panel

The center-overlay slot is not driven by slot= on the decorator. It is driven by a hardcoded the center-overlay routing rule function in the frontend that matches specific panel_id strings:

  • "editor" — when note_id kwarg is present
  • "compose" — always
  • "email_viewer" — when message_id kwarg is present

Any other panel_id will NOT open as a center overlay, regardless of what slot= you declare. It will instead call the left-slot mount / the right-slot mount for a side slot. If you choose panel_id="editor" (the Notes extension uses this name), the the center-overlay routing rule allowlist applies and you get center-overlay behavior. If you choose panel_id="my_editor", you do not — a frontend code change is required to add a new panel_id to the allowlist.

This is the exact behavior that tripped the Vikunja extension developer: ui.Call("__panel__editor", note_id="board") worked (because "editor" is in the allowlist) but the custom panel_id they originally tried did not. See + Q10.

When you want a focused, full-bleed editor that appears over the main content area, declare panel_id="editor" (one of the three allowlisted IDs) and use auto_action on the sidebar root to open it immediately after discovery. The slot="center" declaration is correct but the actual center-overlay routing is determined entirely by panel_id + kwarg combination in the frontend, not by the slot= field.


panels.py — center-overlay complete example
from __future__ import annotations

from imperal_sdk import Extension, ui

ext = Extension(
    "my-ext",
    display_name="My Extension",
    description="An extension with a full-bleed center-overlay editor.",
    actions_explicit=True,
)

# ── Inline stub data — replace with your real data fetch in production ─────
_NOTES = [
    {"id": "note-1", "title": "First note",  "body": "Body of note one."},
    {"id": "note-2", "title": "Second note", "body": "Body of note two."},
]


def _get_note(note_id: str) -> dict | None:
    return next((n for n in _NOTES if n["id"] == note_id), None)


# ── Left sidebar — discovery-safe, sets auto_action to open overlay ────────

@ext.panel(
    "sidebar",
    slot="left",
    title="Notes",
    icon="🗒️",
    default_width=280,
    min_width=200,
    max_width=500,
)
async def sidebar(
    ctx: object,
    active_note_id: str = "",
    view: str = "notes",
    **kwargs: object,
) -> object:
    notes = _NOTES  # replace with: notes = await fetch_notes(ctx)

    items = [
        ui.ListItem(
            id=note["id"],
            title=note["title"],
            selected=(note["id"] == active_note_id),
            # panel_id "editor" + note_id kwarg → the center-overlay routing rule returns true
            # → the host calls the center-overlay mount(node) instead of the left-slot mount/the right-slot mount
            on_click=ui.Call("__panel__editor", note_id=note["id"]),
        )
        for note in notes
    ]

    root = ui.Stack(
        children=[ui.List(items=items)],
        gap=2,
    )

    # auto_action: open the most-recent note on first load.
    # Conditional: only when no note is already active and we are in the
    # normal view (not trash). The host fires this once after discovery
    # (the discovery-once guard). Navigating away and returning to
    # the same extension page does NOT re-fire auto_action within the
    # same session.
    if notes and not active_note_id and view != "trash":
        root.props["auto_action"] = ui.Call(
            "__panel__editor", note_id=notes[0]["id"]
        )

    return root


# ── Center-overlay editor ──────────────────────────────────────────────────
# panel_id MUST be "editor" to hit the center-overlay routing rule. Passing note_id kwarg
# is also required — the center-overlay routing rule checks: pid === "editor" && !!p.note_id.
# Declaring slot="center" is correct but the frontend ignores the slot field
# entirely for routing purposes; routing is purely panel_id + kwarg driven.

@ext.panel(
    "editor",
    slot="center",
    center_overlay=True,
    title="Editor",
    icon="📝",
)
async def editor(ctx: object, note_id: str = "", **kwargs: object) -> object:
    # At batch discovery the center panel is NOT called (only left/right are
    # called at init). This handler is only reached via an explicit
    # ui.Call or auto_action. When called without note_id, return ui.Empty()
    # rather than None — ui.Empty() is the correct "no content yet" signal.
    if not note_id:
        return ui.Empty(message="Select a note to edit", icon="📄")

    note = _get_note(note_id)
    if note is None:
        return ui.Error(message="Note not found")

    return ui.Stack(
        children=[
            ui.Text(note["title"], variant="h2"),
            ui.RichEditor(
                content=note["body"],
                on_change=ui.Call("save_note", note_id=note_id),
            ),
        ],
        gap=4,
    )

Walk-through

Why panel_id="editor" specifically? The the center-overlay routing rule function in is a hardcoded allowlist. It returns true for pid === "editor" && !!p.note_id. When the host dispatches ui.Call("__panel__editor", note_id="..."), it routes to the center-overlay mount(node) rather than the left-slot mount/the right-slot mount. Any other panel_id string routes to a side slot. slot="center" on the @ext.panel decorator has no effect on this routing decision — the frontend reads panel_id + params, not the slot field, to decide overlay vs. side slot.

Why does the center panel handler never see batch-discovery calls? Batch discovery only calls the panels registered in config.panels.left and config.panels.right. Center-overlay panels are not in that config. The editor handler is only reached when an explicit ui.Call or auto_action dispatches it. This means you do not need a return None guard for discovery — but you do need the if not note_id: return ui.Empty() guard for the case where auto_action or a ui.Call arrives without a valid note_id.

Why auto_action on the sidebar root and not on the editor panel itself? The host reads auto_action only from leftPanel.props.auto_action. Setting it anywhere else — on the editor's own root, on a right-panel root, on a child node — has no effect. The sidebar owns the auto_action prop; the editor is the target of the action.

The three-condition guard for auto_action. if notes and not active_note_id and view != "trash" mirrors the production pattern from notes/panels.py. The conditions are: (1) there is content to open — do not claim center for an empty list; (2) no note is already active — avoid re-claiming center when the sidebar refreshes while an editor is open; (3) not in the trash view — the trash view has different semantics and should not auto-open a note.


On this page