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"— whennote_idkwarg is present"compose"— always"email_viewer"— whenmessage_idkwarg 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.
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.
Cross-links
- Build a panel layout — center-overlay-editor — full guide including the Vikunja debugging walkthrough.
- Troubleshoot: center overlay does not open — step-by-step diagnostic for the most common center-overlay failure.
- Concepts: slot ownership — explains why
slot=is not the routing mechanism.