diff --git a/apps/web/src/components/plugins/PluginPanelChrome.tsx b/apps/web/src/components/plugins/PluginPanelChrome.tsx
new file mode 100644
index 00000000..c2a7b36f
--- /dev/null
+++ b/apps/web/src/components/plugins/PluginPanelChrome.tsx
@@ -0,0 +1,109 @@
+import type { PluginPanelAction, PluginPanelActionVariant, PluginPanelChrome, PluginPanelToolbarItem } from "@edgeever/plugin-api";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
+import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group";
+
+
+const actionVariant = (variant?: PluginPanelActionVariant): "outline" | "solid" | "ghost" => {
+ if (variant === "primary") return "solid";
+ if (variant === "ghost") return "ghost";
+ return "outline";
+};
+
+const ActionButton = ({ action, onAction }: { action: PluginPanelAction; onAction?: (id: string) => void }) => (
+
+);
+
+const ToolbarItem = ({ item, onAction, onChange }: {
+ item: PluginPanelToolbarItem;
+ onAction?: (id: string) => void;
+ onChange?: (key: string, value: string) => void;
+}) => {
+ if (item.type === "search") {
+ return (
+ onChange?.(item.key, event.target.value)}
+ />
+ );
+ }
+ if (item.type === "tabs") {
+ return (
+ { if (value) onChange?.(item.key, value); }}
+ >
+ {item.options.map((option) => (
+
+ {option.label}
+
+ ))}
+
+ );
+ }
+ if (item.type === "select") {
+ return (
+
+ );
+ }
+ return ;
+};
+
+export const PluginPanelHeaderActions = ({ chrome }: { chrome: PluginPanelChrome }) => {
+ const actions = chrome.header?.actions ?? [];
+ if (!actions.length) return null;
+ return (
+
+ {actions.map((action) =>
)}
+
+ );
+};
+
+export const PluginPanelToolbar = ({ chrome }: { chrome: PluginPanelChrome }) => {
+ if (!chrome.toolbar?.length) return null;
+ return (
+
+ {chrome.toolbar.map((item) => (
+
+ ))}
+
+ );
+};
+
+export const PluginPanelEmpty = ({ chrome }: { chrome: PluginPanelChrome }) => {
+ if (!chrome.empty) return null;
+ return (
+
+
{chrome.empty.title}
+ {chrome.empty.description ?
{chrome.empty.description}
: null}
+ {chrome.empty.action ?
: null}
+
+ );
+};
diff --git a/apps/web/src/components/plugins/PluginPanelDialog.tsx b/apps/web/src/components/plugins/PluginPanelDialog.tsx
index d8a58568..00040a53 100644
--- a/apps/web/src/components/plugins/PluginPanelDialog.tsx
+++ b/apps/web/src/components/plugins/PluginPanelDialog.tsx
@@ -1,7 +1,8 @@
import { useEffect, useState } from "react";
-import type { PluginPanelCloseDecision, PluginPanelOpenOptions } from "@edgeever/plugin-api";
+import type { PluginPanelChrome, PluginPanelCloseDecision, PluginPanelOpenOptions } from "@edgeever/plugin-api";
import { useTranslation } from "react-i18next";
import { AppConfirmDialog } from "@/components/dialogs/ConfirmDialogs";
+import { PluginPanelEmpty, PluginPanelHeaderActions, PluginPanelToolbar } from "@/components/plugins/PluginPanelChrome";
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import type { EdgeEverPluginHost, RegisteredPluginPanel } from "@/lib/plugins/plugin-host";
import { cn } from "@/lib/utils";
@@ -14,12 +15,19 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
}) => {
const { t } = useTranslation();
const [container, setContainer] = useState(null);
+ const [chrome, setChrome] = useState({});
+ const [chromeEnabled, setChromeEnabled] = useState(false);
const [mountError, setMountError] = useState(null);
const [closeConfirmation, setCloseConfirmation] = useState | null>(null);
const [checkingClose, setCheckingClose] = useState(false);
const panelPluginId = panel?.pluginId ?? null;
const panelId = panel?.id ?? null;
+ useEffect(() => {
+ setChrome({});
+ setChromeEnabled(false);
+ }, [panelPluginId, panelId]);
+
useEffect(() => {
// Container is reactive so delayed portal attachment mounts the plugin.
if (!container || !panelPluginId || !panelId) return;
@@ -27,7 +35,13 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
let disposePanel: (() => void) | null = null;
setMountError(null);
container.replaceChildren();
- void host.mountPanel(panelPluginId, panelId, container, options, requestClose).then((dispose) => {
+ void host.mountPanel(panelPluginId, panelId, container, options, requestClose, {
+ set(next) {
+ if (disposed) return;
+ setChromeEnabled(true);
+ setChrome(next);
+ },
+ }).then((dispose) => {
if (disposed) dispose();
else disposePanel = dispose;
}).catch((error: unknown) => {
@@ -54,6 +68,10 @@ export const PluginPanelDialog = ({ host, panel, options, onClose }: {
}
};
+ const title = chrome.header?.title || panel?.title || t("plugins.panel");
+ const hideDescription = chromeEnabled && chrome.header?.description === null;
+ const description = chrome.header?.description || t("plugins.panelDescription");
+
return (
<>
diff --git a/apps/web/src/lib/plugins/plugin-host-capabilities.fixture.mjs b/apps/web/src/lib/plugins/plugin-host-capabilities.fixture.mjs
index c20c7362..21bac1b8 100644
--- a/apps/web/src/lib/plugins/plugin-host-capabilities.fixture.mjs
+++ b/apps/web/src/lib/plugins/plugin-host-capabilities.fixture.mjs
@@ -53,9 +53,11 @@ export default {
mount(container, panelContext) {
container.panelState = panelContext.state;
container.requestPanelClose = panelContext.requestClose;
+ container.hasPanelShell = typeof panelContext.shell?.set === "function";
return () => {
delete container.panelState;
delete container.requestPanelClose;
+ delete container.hasPanelShell;
};
},
beforeClose() {
diff --git a/apps/web/src/lib/plugins/plugin-host.test.mjs b/apps/web/src/lib/plugins/plugin-host.test.mjs
index e44a1d1d..6bec9672 100644
--- a/apps/web/src/lib/plugins/plugin-host.test.mjs
+++ b/apps/web/src/lib/plugins/plugin-host.test.mjs
@@ -555,6 +555,7 @@ describe("EdgeEverPluginHost", () => {
() => { requestedPanelClose = true; },
);
expect(panelContainer.panelState).toEqual({ resourceId: "resource-1" });
+ expect(panelContainer.hasPanelShell).toBe(true);
await panelContainer.requestPanelClose();
expect(requestedPanelClose).toBe(true);
disposePanel();
diff --git a/apps/web/src/lib/plugins/plugin-host.ts b/apps/web/src/lib/plugins/plugin-host.ts
index 147183e2..3a1f2873 100644
--- a/apps/web/src/lib/plugins/plugin-host.ts
+++ b/apps/web/src/lib/plugins/plugin-host.ts
@@ -17,8 +17,10 @@ import {
type PluginNoteSummary,
type PluginPanel,
type PluginOpenNoteOptions,
+ type PluginPanelChrome,
type PluginPanelCloseDecision,
type PluginPanelOpenOptions,
+ normalizePluginPanelChrome,
type PluginPermission,
type PluginApiErrorCode,
type PluginResource,
@@ -156,6 +158,10 @@ export interface PluginNavigationAdapter {
openNote(noteId: string, notebookId: string, options?: PluginOpenNoteOptions): void | Promise;
}
+export interface PluginPanelChromeAdapter {
+ set(chrome: PluginPanelChrome): void;
+}
+
export interface PluginPanelAdapter {
openPanel(pluginId: string, panelId: string, options?: PluginPanelOpenOptions): void | Promise;
}
@@ -722,6 +728,7 @@ export class EdgeEverPluginHost {
container: HTMLElement,
options?: PluginPanelOpenOptions,
onRequestClose?: () => void | Promise,
+ chromeAdapter?: PluginPanelChromeAdapter,
) {
const key = `${pluginId}:${panelId}`;
const panel = this.panels.get(key);
@@ -733,6 +740,11 @@ export class EdgeEverPluginHost {
requestClose: async () => {
await onRequestClose?.();
},
+ shell: {
+ set(chrome) {
+ chromeAdapter?.set(normalizePluginPanelChrome(chrome));
+ },
+ },
});
if (this.panels.get(key) !== panel) {
if (typeof pluginDispose === "function") pluginDispose();
diff --git a/docs/plugin-development.md b/docs/plugin-development.md
index df23b806..085cf2a8 100644
--- a/docs/plugin-development.md
+++ b/docs/plugin-development.md
@@ -452,6 +452,45 @@ Every API v2 panel must declare one business purpose: `workflow`, `dashboard`, `
`presentation` accepts `dialog` (the default) or `fullscreen`. `panels.open()` can only open a panel registered by the calling plugin; its optional JSON state is limited to 64 KiB and is delivered through the mount context. `beforeClose()` may return `true` to close, `false` to stay open, or confirmation copy for a host-rendered dialog. The mount context's `requestClose()` follows the same guard.
+### Panel chrome
+
+Use `mount` context `shell.set()` for system chrome: title, description, header actions, search, tabs, selects, and empty states. EdgeEver renders those controls with the same components as the rest of the app. The `container` argument remains the plugin body — lists, canvases, and forms stay in plugin DOM.
+
+```js
+mount(container, { shell, requestClose }) {
+ const list = document.createElement("div");
+ container.append(list);
+ const render = (query) => {
+ const tasks = queryTasks(query);
+ shell.set({
+ header: {
+ title: "Tasks",
+ description: `${tasks.length} open`,
+ actions: [{ id: "refresh", label: "Refresh" }],
+ },
+ toolbar: [
+ { type: "tabs", key: "view", value: query.view, options: [
+ { value: "open", label: "Open" },
+ { value: "done", label: "Done" },
+ ] },
+ { type: "search", key: "q", placeholder: "Search tasks", value: query.q },
+ { type: "select", key: "priority", label: "Priority", value: query.priority, options: [
+ { value: "all", label: "All" },
+ { value: "high", label: "High" },
+ ] },
+ ],
+ empty: tasks.length ? null : { title: "No matching tasks" },
+ onAction(id) { if (id === "refresh") render(query); },
+ onChange(key, value) { render({ ...query, [key]: value }); },
+ });
+ list.replaceChildren(...tasks.map(renderRow));
+ };
+ render({ view: "open", q: "", priority: "all" });
+}
+```
+
+Passing `header.description: null` hides the default “provided by a trusted plugin” line from the visible header (it remains available to assistive technology). Plugins that never call `shell.set()` keep the previous nested card layout. Chrome callbacks are in-memory only and are not stored in panel `state`.
+
## Desktop plugin entry
After a plugin is enabled, a unified puzzle button appears in the desktop workspace shortcuts on the left. Its menu groups commands and panels by plugin, keeps recently used actions at the top, and links directly to extension management. Individual plugins do not each consume a toolbar icon.
diff --git a/docs/plugin-development.zh-CN.md b/docs/plugin-development.zh-CN.md
index 7c5d9a7b..703430f0 100644
--- a/docs/plugin-development.zh-CN.md
+++ b/docs/plugin-development.zh-CN.md
@@ -452,6 +452,45 @@ await context.ui.panels.open("dashboard", { state: { resourceId } });
`presentation` 可以使用 `dialog`(默认)或 `fullscreen`。`panels.open()` 只能打开调用插件自己注册的面板;可选 JSON 状态上限为 64 KiB,并通过挂载上下文传入。`beforeClose()` 可以返回 `true` 关闭、返回 `false` 保持打开,或返回由宿主显示确认框所需的文案。挂载上下文中的 `requestClose()` 同样会经过这项保护。
+### 面板系统控件
+
+用 `mount` 上下文里的 `shell.set()` 描述标题、说明、页头按钮、搜索、分段选项、下拉框和空状态。EdgeEver 用与应用其余部分相同的组件来渲染这些控件。`container` 仍是插件内容区——列表、画布和表单继续由插件自己的 DOM 负责。
+
+```js
+mount(container, { shell, requestClose }) {
+ const list = document.createElement("div");
+ container.append(list);
+ const render = (query) => {
+ const tasks = queryTasks(query);
+ shell.set({
+ header: {
+ title: "待办任务",
+ description: `${tasks.length} 项未完成`,
+ actions: [{ id: "refresh", label: "刷新" }],
+ },
+ toolbar: [
+ { type: "tabs", key: "view", value: query.view, options: [
+ { value: "open", label: "未完成" },
+ { value: "done", label: "已完成" },
+ ] },
+ { type: "search", key: "q", placeholder: "搜索任务", value: query.q },
+ { type: "select", key: "priority", label: "优先级", value: query.priority, options: [
+ { value: "all", label: "全部" },
+ { value: "high", label: "高" },
+ ] },
+ ],
+ empty: tasks.length ? null : { title: "没有符合条件的任务" },
+ onAction(id) { if (id === "refresh") render(query); },
+ onChange(key, value) { render({ ...query, [key]: value }); },
+ });
+ list.replaceChildren(...tasks.map(renderRow));
+ };
+ render({ view: "open", q: "", priority: "all" });
+}
+```
+
+将 `header.description` 设为 `null` 会隐藏默认的「由受信任插件提供」说明(辅助技术仍可读取)。从不调用 `shell.set()` 的插件保持原来的内嵌卡片布局。控件回调只存在于内存中,不会写入面板 `state`。
+
## 桌面端插件入口
启用插件后,桌面端左侧工作区快捷栏会显示统一的拼图入口。菜单按插件分组展示命令和面板,并在顶部保留最近使用的操作;“管理插件与主题”会直接打开独立插件市场页面。插件不会各自在工具栏占用一个图标。
diff --git a/packages/plugin-api/README.md b/packages/plugin-api/README.md
index d850412f..9b15813e 100644
--- a/packages/plugin-api/README.md
+++ b/packages/plugin-api/README.md
@@ -44,6 +44,8 @@ await context.ui.openNote(noteId, { search: "New task" }); // requires ui:naviga
Bulk-indexing plugins can page through `context.notes.queryContent()`. The API also exposes workspace templates, full live-editor reads and range edits, template mutation events, and programmatic opening of a plugin's own registered panels.
+Dashboard and workflow panels can ask EdgeEver to render system chrome (header actions, search, tabs, selects, empty states) through `mount` context `shell.set()`, while the plugin keeps owning the body DOM.
+
Canvas-style plugins can read and conflict-safely replace resource bytes, open full-screen guarded panels with JSON state, and register constrained block embed renderers:
```ts
diff --git a/packages/plugin-api/dist/index.d.ts b/packages/plugin-api/dist/index.d.ts
index 74ada4d9..dc2673a0 100644
--- a/packages/plugin-api/dist/index.d.ts
+++ b/packages/plugin-api/dist/index.d.ts
@@ -319,9 +319,69 @@ export type PluginPanelPurpose = "workflow" | "dashboard" | "preview" | "onboard
export interface PluginPanelOpenOptions {
state?: PluginJsonValue;
}
+export type PluginPanelActionVariant = "default" | "primary" | "ghost";
+export interface PluginPanelAction {
+ id: string;
+ label: string;
+ variant?: PluginPanelActionVariant;
+ disabled?: boolean;
+}
+export interface PluginPanelSelectOption {
+ value: string;
+ label: string;
+}
+export type PluginPanelToolbarItem = {
+ type: "search";
+ key: string;
+ placeholder?: string;
+ value?: string;
+} | {
+ type: "tabs";
+ key: string;
+ value?: string;
+ options: PluginPanelSelectOption[];
+} | {
+ type: "select";
+ key: string;
+ label?: string;
+ value?: string;
+ options: PluginPanelSelectOption[];
+} | {
+ type: "button";
+ key: string;
+ label: string;
+ variant?: PluginPanelActionVariant;
+ disabled?: boolean;
+};
+export interface PluginPanelEmptyState {
+ title: string;
+ description?: string;
+ action?: PluginPanelAction;
+}
+/**
+ * Host-rendered panel chrome. Plugins describe intent; EdgeEver owns layout and controls.
+ * Callbacks stay in-memory and are not serialized with panel open state.
+ */
+export interface PluginPanelChrome {
+ header?: {
+ title?: string;
+ /** Pass `null` to hide the host's default panel description. */
+ description?: string | null;
+ actions?: PluginPanelAction[];
+ };
+ toolbar?: PluginPanelToolbarItem[];
+ empty?: PluginPanelEmptyState | null;
+ onAction?: (id: string) => void;
+ onChange?: (key: string, value: string) => void;
+}
+export interface PluginPanelShell {
+ set(chrome: PluginPanelChrome): void;
+}
export interface PluginPanelMountContext {
state: PluginJsonValue | null;
requestClose(): Promise;
+ /** Host-rendered header, toolbar, and empty state. `set` is a no-op when the host has no chrome adapter. */
+ shell: PluginPanelShell;
}
export type PluginPanelCloseDecision = boolean | {
title: string;
@@ -497,6 +557,8 @@ export interface EdgeEverPlugin {
}
export declare const definePlugin: (plugin: T) => T;
export declare const defineTheme: (theme: T) => T;
+/** Strips unknown fields and clamps sizes so host chrome rendering stays bounded. */
+export declare const normalizePluginPanelChrome: (value: PluginPanelChrome | null | undefined) => PluginPanelChrome;
export declare const parseExtensionManifest: (value: unknown) => ExtensionManifest;
export declare const parseMarketplaceRegistry: (value: unknown) => MarketplaceRegistry;
export {};
diff --git a/packages/plugin-api/dist/index.js b/packages/plugin-api/dist/index.js
index 9a903c72..ec4e47a6 100644
--- a/packages/plugin-api/dist/index.js
+++ b/packages/plugin-api/dist/index.js
@@ -52,6 +52,100 @@ var defineTheme = (theme) => theme;
var ID_PATTERN = /^[a-z0-9]+(?:[._-][a-z0-9]+)+$/;
var VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
var isRecord = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
+var PANEL_CHROME_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
+var PANEL_ACTION_VARIANTS = new Set(["default", "primary", "ghost"]);
+var clipChromeText = (value, fallback = "", max = 200) => {
+ if (typeof value !== "string")
+ return fallback;
+ const trimmed = value.trim();
+ return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
+};
+var normalizePanelAction = (value) => {
+ if (!isRecord(value) || typeof value.id !== "string" || !PANEL_CHROME_ID.test(value.id))
+ return null;
+ const label = clipChromeText(value.label);
+ if (!label)
+ return null;
+ const variant = PANEL_ACTION_VARIANTS.has(value.variant) ? value.variant : undefined;
+ return { id: value.id, label, ...variant ? { variant } : {}, ...value.disabled === true ? { disabled: true } : {} };
+};
+var normalizePanelOptions = (value) => {
+ if (!Array.isArray(value))
+ return [];
+ const options = [];
+ for (const item of value.slice(0, 24)) {
+ if (!isRecord(item) || typeof item.value !== "string" || !item.value || item.value.length > 64)
+ continue;
+ const label = clipChromeText(item.label, item.value);
+ options.push({ value: item.value, label });
+ }
+ return options;
+};
+var normalizeToolbarItem = (value) => {
+ if (!isRecord(value) || typeof value.key !== "string" || !PANEL_CHROME_ID.test(value.key))
+ return null;
+ if (value.type === "search") {
+ return {
+ type: "search",
+ key: value.key,
+ ...typeof value.placeholder === "string" ? { placeholder: clipChromeText(value.placeholder, "", 80) } : {},
+ ...typeof value.value === "string" ? { value: value.value.slice(0, 200) } : {}
+ };
+ }
+ if (value.type === "tabs" || value.type === "select") {
+ const options = normalizePanelOptions(value.options);
+ if (!options.length)
+ return null;
+ const selected = typeof value.value === "string" && options.some((option) => option.value === value.value) ? value.value : options[0].value;
+ return {
+ type: value.type,
+ key: value.key,
+ value: selected,
+ options,
+ ...value.type === "select" && typeof value.label === "string" ? { label: clipChromeText(value.label, "", 40) } : {}
+ };
+ }
+ if (value.type === "button") {
+ const action = normalizePanelAction({ ...value, id: value.key });
+ if (!action)
+ return null;
+ return { type: "button", key: value.key, label: action.label, ...action.variant ? { variant: action.variant } : {}, ...action.disabled ? { disabled: true } : {} };
+ }
+ return null;
+};
+var normalizePluginPanelChrome = (value) => {
+ if (!isRecord(value))
+ return {};
+ const chrome = {};
+ if (isRecord(value.header)) {
+ const actions = Array.isArray(value.header.actions) ? value.header.actions.map(normalizePanelAction).filter((action) => Boolean(action)).slice(0, 8) : [];
+ chrome.header = {
+ ...typeof value.header.title === "string" ? { title: clipChromeText(value.header.title, "", 80) } : {},
+ ...value.header.description === null ? { description: null } : typeof value.header.description === "string" ? { description: clipChromeText(value.header.description, "", 200) } : {},
+ ...actions.length ? { actions } : {}
+ };
+ }
+ if (Array.isArray(value.toolbar)) {
+ chrome.toolbar = value.toolbar.map(normalizeToolbarItem).filter((item) => Boolean(item)).slice(0, 16);
+ }
+ if (value.empty === null)
+ chrome.empty = null;
+ else if (isRecord(value.empty)) {
+ const title = clipChromeText(value.empty.title, "", 80);
+ if (title) {
+ chrome.empty = {
+ title,
+ ...typeof value.empty.description === "string" ? { description: clipChromeText(value.empty.description) } : {},
+ ...normalizePanelAction(value.empty.action) ? { action: normalizePanelAction(value.empty.action) } : {}
+ };
+ }
+ }
+ if (typeof value.onAction === "function")
+ chrome.onAction = value.onAction;
+ if (typeof value.onChange === "function")
+ chrome.onChange = value.onChange;
+ return chrome;
+};
var COLOR_THEME_TOKENS = new Set([
"color.background",
"color.surface",
@@ -340,6 +434,7 @@ var parseMarketplaceRegistry = (value) => {
export {
parseMarketplaceRegistry,
parseExtensionManifest,
+ normalizePluginPanelChrome,
defineTheme,
definePlugin,
THEME_TOKEN_NAMES,
diff --git a/packages/plugin-api/src/index.test.mjs b/packages/plugin-api/src/index.test.mjs
index cf52b2eb..4ff56054 100644
--- a/packages/plugin-api/src/index.test.mjs
+++ b/packages/plugin-api/src/index.test.mjs
@@ -1,5 +1,5 @@
import { describe, expect, test } from "bun:test";
-import { parseExtensionManifest, parseMarketplaceRegistry } from "./index.ts";
+import { normalizePluginPanelChrome, parseExtensionManifest, parseMarketplaceRegistry } from "./index.ts";
describe("extension manifests", () => {
test("normalizes a plugin manifest", () => {
@@ -244,3 +244,32 @@ describe("marketplace registry", () => {
expect(() => parseMarketplaceRegistry({ registryVersion: "1", updatedAt: "2026-08-16T00:00:00Z", entries: [entry, entry] })).toThrow("Duplicate");
});
});
+
+describe("panel chrome", () => {
+ test("keeps known chrome fields and drops unknown toolbar items", () => {
+ const onAction = () => {};
+ const chrome = normalizePluginPanelChrome({
+ header: { title: "Tasks", description: null, actions: [{ id: "refresh", label: "Refresh", variant: "primary" }] },
+ toolbar: [
+ { type: "search", key: "q", placeholder: "Search", value: "ship" },
+ { type: "tabs", key: "view", value: "open", options: [{ value: "open", label: "Open" }, { value: "done", label: "Done" }] },
+ { type: "weird", key: "nope" },
+ ],
+ empty: { title: "Nothing here", description: "Create a task" },
+ onAction,
+ });
+ expect(chrome.header).toEqual({ title: "Tasks", description: null, actions: [{ id: "refresh", label: "Refresh", variant: "primary" }] });
+ expect(chrome.toolbar).toEqual([
+ { type: "search", key: "q", placeholder: "Search", value: "ship" },
+ { type: "tabs", key: "view", value: "open", options: [{ value: "open", label: "Open" }, { value: "done", label: "Done" }] },
+ ]);
+ expect(chrome.empty).toEqual({ title: "Nothing here", description: "Create a task" });
+ expect(chrome.onAction).toBe(onAction);
+ });
+
+ test("falls back to the first tab option when the value is unknown", () => {
+ expect(normalizePluginPanelChrome({
+ toolbar: [{ type: "tabs", key: "view", value: "missing", options: [{ value: "open", label: "Open" }] }],
+ }).toolbar).toEqual([{ type: "tabs", key: "view", value: "open", options: [{ value: "open", label: "Open" }] }]);
+ });
+});
diff --git a/packages/plugin-api/src/index.ts b/packages/plugin-api/src/index.ts
index 24a5cf58..02c6739e 100644
--- a/packages/plugin-api/src/index.ts
+++ b/packages/plugin-api/src/index.ts
@@ -352,9 +352,58 @@ export interface PluginPanelOpenOptions {
state?: PluginJsonValue;
}
+export type PluginPanelActionVariant = "default" | "primary" | "ghost";
+
+export interface PluginPanelAction {
+ id: string;
+ label: string;
+ variant?: PluginPanelActionVariant;
+ disabled?: boolean;
+}
+
+export interface PluginPanelSelectOption {
+ value: string;
+ label: string;
+}
+
+export type PluginPanelToolbarItem =
+ | { type: "search"; key: string; placeholder?: string; value?: string }
+ | { type: "tabs"; key: string; value?: string; options: PluginPanelSelectOption[] }
+ | { type: "select"; key: string; label?: string; value?: string; options: PluginPanelSelectOption[] }
+ | { type: "button"; key: string; label: string; variant?: PluginPanelActionVariant; disabled?: boolean };
+
+export interface PluginPanelEmptyState {
+ title: string;
+ description?: string;
+ action?: PluginPanelAction;
+}
+
+/**
+ * Host-rendered panel chrome. Plugins describe intent; EdgeEver owns layout and controls.
+ * Callbacks stay in-memory and are not serialized with panel open state.
+ */
+export interface PluginPanelChrome {
+ header?: {
+ title?: string;
+ /** Pass `null` to hide the host's default panel description. */
+ description?: string | null;
+ actions?: PluginPanelAction[];
+ };
+ toolbar?: PluginPanelToolbarItem[];
+ empty?: PluginPanelEmptyState | null;
+ onAction?: (id: string) => void;
+ onChange?: (key: string, value: string) => void;
+}
+
+export interface PluginPanelShell {
+ set(chrome: PluginPanelChrome): void;
+}
+
export interface PluginPanelMountContext {
state: PluginJsonValue | null;
requestClose(): Promise;
+ /** Host-rendered header, toolbar, and empty state. `set` is a no-op when the host has no chrome adapter. */
+ shell: PluginPanelShell;
}
export type PluginPanelCloseDecision = boolean | {
@@ -508,6 +557,97 @@ const VERSION_PATTERN = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/;
const isRecord = (value: unknown): value is Record =>
typeof value === "object" && value !== null && !Array.isArray(value);
+const PANEL_CHROME_ID = /^[a-zA-Z][a-zA-Z0-9_-]{0,63}$/;
+const PANEL_ACTION_VARIANTS = new Set(["default", "primary", "ghost"]);
+
+const clipChromeText = (value: unknown, fallback = "", max = 200) => {
+ if (typeof value !== "string") return fallback;
+ const trimmed = value.trim();
+ return trimmed.length > max ? trimmed.slice(0, max) : trimmed;
+};
+
+const normalizePanelAction = (value: unknown): PluginPanelAction | null => {
+ if (!isRecord(value) || typeof value.id !== "string" || !PANEL_CHROME_ID.test(value.id)) return null;
+ const label = clipChromeText(value.label);
+ if (!label) return null;
+ const variant = PANEL_ACTION_VARIANTS.has(value.variant as PluginPanelActionVariant) ? value.variant as PluginPanelActionVariant : undefined;
+ return { id: value.id, label, ...(variant ? { variant } : {}), ...(value.disabled === true ? { disabled: true } : {}) };
+};
+
+const normalizePanelOptions = (value: unknown): PluginPanelSelectOption[] => {
+ if (!Array.isArray(value)) return [];
+ const options: PluginPanelSelectOption[] = [];
+ for (const item of value.slice(0, 24)) {
+ if (!isRecord(item) || typeof item.value !== "string" || !item.value || item.value.length > 64) continue;
+ const label = clipChromeText(item.label, item.value);
+ options.push({ value: item.value, label });
+ }
+ return options;
+};
+
+const normalizeToolbarItem = (value: unknown): PluginPanelToolbarItem | null => {
+ if (!isRecord(value) || typeof value.key !== "string" || !PANEL_CHROME_ID.test(value.key)) return null;
+ if (value.type === "search") {
+ return {
+ type: "search",
+ key: value.key,
+ ...(typeof value.placeholder === "string" ? { placeholder: clipChromeText(value.placeholder, "", 80) } : {}),
+ ...(typeof value.value === "string" ? { value: value.value.slice(0, 200) } : {}),
+ };
+ }
+ if (value.type === "tabs" || value.type === "select") {
+ const options = normalizePanelOptions(value.options);
+ if (!options.length) return null;
+ const selected = typeof value.value === "string" && options.some((option) => option.value === value.value) ? value.value : options[0].value;
+ return {
+ type: value.type,
+ key: value.key,
+ value: selected,
+ options,
+ ...(value.type === "select" && typeof value.label === "string" ? { label: clipChromeText(value.label, "", 40) } : {}),
+ };
+ }
+ if (value.type === "button") {
+ const action = normalizePanelAction({ ...value, id: value.key });
+ if (!action) return null;
+ return { type: "button", key: value.key, label: action.label, ...(action.variant ? { variant: action.variant } : {}), ...(action.disabled ? { disabled: true } : {}) };
+ }
+ return null;
+};
+
+/** Strips unknown fields and clamps sizes so host chrome rendering stays bounded. */
+export const normalizePluginPanelChrome = (value: PluginPanelChrome | null | undefined): PluginPanelChrome => {
+ if (!isRecord(value)) return {};
+ const chrome: PluginPanelChrome = {};
+ if (isRecord(value.header)) {
+ const actions = Array.isArray(value.header.actions)
+ ? value.header.actions.map(normalizePanelAction).filter((action): action is PluginPanelAction => Boolean(action)).slice(0, 8)
+ : [];
+ chrome.header = {
+ ...(typeof value.header.title === "string" ? { title: clipChromeText(value.header.title, "", 80) } : {}),
+ ...(value.header.description === null ? { description: null } : typeof value.header.description === "string" ? { description: clipChromeText(value.header.description, "", 200) } : {}),
+ ...(actions.length ? { actions } : {}),
+ };
+ }
+ if (Array.isArray(value.toolbar)) {
+ chrome.toolbar = value.toolbar.map(normalizeToolbarItem).filter((item): item is PluginPanelToolbarItem => Boolean(item)).slice(0, 16);
+ }
+ if (value.empty === null) chrome.empty = null;
+ else if (isRecord(value.empty)) {
+ const title = clipChromeText(value.empty.title, "", 80);
+ if (title) {
+ chrome.empty = {
+ title,
+ ...(typeof value.empty.description === "string" ? { description: clipChromeText(value.empty.description) } : {}),
+ ...(normalizePanelAction(value.empty.action) ? { action: normalizePanelAction(value.empty.action)! } : {}),
+ };
+ }
+ }
+ if (typeof value.onAction === "function") chrome.onAction = value.onAction as PluginPanelChrome["onAction"];
+ if (typeof value.onChange === "function") chrome.onChange = value.onChange as PluginPanelChrome["onChange"];
+ return chrome;
+};
+
const COLOR_THEME_TOKENS = new Set([
"color.background", "color.surface", "color.surfaceMuted", "color.text", "color.textMuted",
"color.border", "color.accent", "color.accentForeground", "color.success", "color.warning", "color.danger",