From 8c4d665173f1a9d81e4a20db0bd725016de4330c Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 13 Nov 2026 19:15:47 +0330 Subject: [PATCH 1/5] feat(ui): add bounded presentation contract validation --- .../runtime/presentation/ui/presentation.lua | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 lua/cdin/runtime/presentation/ui/presentation.lua diff --git a/lua/cdin/runtime/presentation/ui/presentation.lua b/lua/cdin/runtime/presentation/ui/presentation.lua new file mode 100644 index 0000000..264cb37 --- /dev/null +++ b/lua/cdin/runtime/presentation/ui/presentation.lua @@ -0,0 +1,227 @@ +local errors = require("cdin.runtime.core.error") + +local P = { + MAX_STYLE_BYTES = 32768, MAX_STYLE_ROLES = 256, MAX_PANELS = 16, + MAX_WIDGETS_PER_PANEL = 128, MAX_WIDGET_DEPTH = 16, MAX_TOTAL_WIDGETS = 1024, + MAX_TEXT_BYTES = 16384, MAX_PRESENTATION_BYTES = 131072, + DEFAULT_INPUT_BYTES = 4096, MAX_INPUT_BYTES = 16384, +} + +local attributes = { + foreground = "rgba", background = "rgba", bold = "bool", italic = "bool", + underline = "bool", strikethrough = "bool", dim = "bool", inverse = "bool", +} + +local function invalid(field, message, details) + details = details or {}; details.field = field + errors.raise("INVALID_ARGUMENT", message, details) +end + +local function text(value, field, limit, empty) + if type(value) ~= "string" or (not empty and value == "") then + invalid(field, field .. " must be " .. (empty and "text" or "non-empty text")) + end + if #value > limit then invalid(field, field .. " exceeds its byte limit", { limit = limit, actual = #value }) end + local _, position = utf8.len(value) + if position then invalid(field, field .. " must contain valid UTF-8", { byte = position }) end + return true +end + +local function bool(value, field) + if value ~= nil and type(value) ~= "boolean" then invalid(field, field .. " must be boolean") end +end + +local function integer(value, field, minimum, maximum) + if type(value) ~= "number" or value % 1 ~= 0 or value < minimum or value > maximum then + invalid(field, field .. " is outside its integer range", { minimum = minimum, maximum = maximum }) + end +end + +local function array(value, field) + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid(field, field .. " must be an array") end + local count, maximum = 0, 0 + for key in pairs(value) do + if type(key) ~= "number" or key < 1 or key % 1 ~= 0 then invalid(field, field .. " must be a dense array") end + count = count + 1; if key > maximum then maximum = key end + end + if count ~= maximum then invalid(field, field .. " must be a dense array") end + return count +end + +local function estimate(value, depth) + depth = depth or 0 + if depth > 24 then invalid("presentation", "presentation data is too deeply nested") end + local kind = type(value) + if kind == "nil" then return 1 end + if kind == "boolean" or kind == "number" then return 8 end + if kind == "string" then return #value + 8 end + if kind ~= "table" or getmetatable(value) ~= nil then invalid("presentation", "presentation data must be serializable") end + local total = 8 + for key, item in pairs(value) do + if type(key) ~= "string" and (type(key) ~= "number" or key < 1 or key % 1 ~= 0) then + invalid("presentation", "presentation map keys must be text") + end + if type(key) == "string" then total = total + #key end + total = total + estimate(item, depth + 1) + if total > P.MAX_PRESENTATION_BYTES then errors.raise("LIMIT_EXCEEDED", "presentation snapshot exceeds its byte limit", { limit = P.MAX_PRESENTATION_BYTES }) end + end + return total +end + +function P.validate_style_role(name) + text(name, "style role", 64, false) + if not name:match("^[A-Za-z0-9][A-Za-z0-9._%-]*$") then invalid("style role", "invalid style-role identifier") end +end + +local function rgba(value, field) + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid(field, field .. " must be an RGBA map") end + for _, channel in ipairs({ "r", "g", "b", "a" }) do integer(value[channel], field .. "." .. channel, 0, 255) end + for key in pairs(value) do + if key ~= "r" and key ~= "g" and key ~= "b" and key ~= "a" then invalid(field, "unknown RGBA channel", { channel = tostring(key) }) end + end +end + +function P.validate_style(style, field) + field = field or "style" + if type(style) ~= "table" or getmetatable(style) ~= nil then invalid(field, "style must be a map") end + for key, value in pairs(style) do + local expected = attributes[key] + if not expected then invalid(field, "unsupported style attribute", { attribute = tostring(key) }) end + if expected == "rgba" then rgba(value, field .. "." .. key) else bool(value, field .. "." .. key) end + end +end + +function P.validate_style_table(styles) + if type(styles) ~= "table" or getmetatable(styles) ~= nil then invalid("styles", "style table must be a map") end + local count = 0 + for role, style in pairs(styles) do + count = count + 1; P.validate_style_role(role); P.validate_style(style, "styles." .. role) + end + if count > P.MAX_STYLE_ROLES then errors.raise("LIMIT_EXCEEDED", "style-role count exceeds its limit", { limit = P.MAX_STYLE_ROLES }) end + if estimate(styles) > P.MAX_STYLE_BYTES then errors.raise("LIMIT_EXCEEDED", "style table exceeds its byte limit", { limit = P.MAX_STYLE_BYTES }) end +end + +local function action(value, field, reserve_value) + if type(value) ~= "table" or getmetatable(value) ~= nil then invalid(field, "action must be a map") end + text(value.command, field .. ".command", 128, false) + if value.arguments ~= nil then + if type(value.arguments) ~= "table" or getmetatable(value.arguments) ~= nil then invalid(field .. ".arguments", "arguments must be a map") end + for key in pairs(value.arguments) do if type(key) ~= "string" then invalid(field .. ".arguments", "argument keys must be text") end end + if reserve_value and value.arguments.value ~= nil then invalid(field .. ".arguments.value", "input actions reserve the value argument") end + estimate(value.arguments) + end +end + +local function common(widget, field, state) + if type(widget) ~= "table" or getmetatable(widget) ~= nil then invalid(field, "widget must be a map") end + text(widget.id, field .. ".id", 128, false); text(widget.kind, field .. ".kind", 64, false) + bool(widget.enabled, field .. ".enabled"); bool(widget.visible, field .. ".visible") + if widget.accessible_label ~= nil then text(widget.accessible_label, field .. ".accessible_label", P.MAX_TEXT_BYTES, true) end + if state.ids[widget.id] then invalid(field .. ".id", "duplicate widget ID", { id = widget.id }) end + state.ids[widget.id] = true; state.total = state.total + 1 +end + +local function tree_node(node, field, depth, state, seen) + if depth > P.MAX_WIDGET_DEPTH then errors.raise("LIMIT_EXCEEDED", "tree widget nesting exceeds its limit", { limit = P.MAX_WIDGET_DEPTH }) end + if type(node) ~= "table" or getmetatable(node) ~= nil then invalid(field, "tree node must be a map") end + text(node.id, field .. ".id", 128, false); text(node.label, field .. ".label", P.MAX_TEXT_BYTES, true) + if seen[node.id] then invalid(field .. ".id", "duplicate tree-node ID", { id = node.id }) end + seen[node.id] = true; state.total = state.total + 1 + bool(node.expanded, field .. ".expanded"); bool(node.enabled, field .. ".enabled") + if node.activate ~= nil then action(node.activate, field .. ".activate") end + if node.children ~= nil then + local count = array(node.children, field .. ".children") + for i = 1, count do tree_node(node.children[i], field .. ".children[" .. i .. "]", depth + 1, state, seen) end + end +end + +local function widget(value, field, depth, state) + if depth > P.MAX_WIDGET_DEPTH then errors.raise("LIMIT_EXCEEDED", "widget nesting exceeds its limit", { limit = P.MAX_WIDGET_DEPTH }) end + common(value, field, state) + local kind = value.kind + if kind == "label" then + text(value.text, field .. ".text", P.MAX_TEXT_BYTES, true) + if value.role ~= nil then P.validate_style_role(value.role) end + elseif kind == "text" then + local count = array(value.runs, field .. ".runs") + for i = 1, count do + local run = value.runs[i] + if type(run) ~= "table" or getmetatable(run) ~= nil then invalid(field .. ".runs", "text run must be a map") end + text(run.text, field .. ".runs[" .. i .. "].text", P.MAX_TEXT_BYTES, true) + if run.role ~= nil then P.validate_style_role(run.role) end + end + elseif kind == "list" then + local count = array(value.items, field .. ".items"); local seen = {} + for i = 1, count do + local item, name = value.items[i], field .. ".items[" .. i .. "]" + if type(item) ~= "table" or getmetatable(item) ~= nil then invalid(name, "list item must be a map") end + text(item.id, name .. ".id", 128, false); text(item.label, name .. ".label", P.MAX_TEXT_BYTES, true) + if seen[item.id] then invalid(name .. ".id", "duplicate list item ID") end + seen[item.id] = true + if item.detail ~= nil then text(item.detail, name .. ".detail", P.MAX_TEXT_BYTES, true) end + bool(item.enabled, name .. ".enabled"); if item.activate ~= nil then action(item.activate, name .. ".activate") end + state.total = state.total + 1 + end + if value.selected_id ~= nil then text(value.selected_id, field .. ".selected_id", 128, false) end + elseif kind == "tree" then + local count = array(value.nodes, field .. ".nodes"); local seen = {} + for i = 1, count do tree_node(value.nodes[i], field .. ".nodes[" .. i .. "]", 1, state, seen) end + elseif kind == "button" then + text(value.label, field .. ".label", P.MAX_TEXT_BYTES, true); action(value.activate, field .. ".activate") + elseif kind == "input" then + text(value.value, field .. ".value", P.MAX_TEXT_BYTES, true) + if value.placeholder ~= nil then text(value.placeholder, field .. ".placeholder", P.MAX_TEXT_BYTES, true) end + local limit = value.max_bytes or P.DEFAULT_INPUT_BYTES + integer(limit, field .. ".max_bytes", 1, P.MAX_INPUT_BYTES) + if #value.value > limit then invalid(field .. ".value", "input value exceeds max_bytes", { limit = limit }) end + bool(value.multiline, field .. ".multiline"); action(value.submit, field .. ".submit", true) + else + -- Unknown kinds are preserved for the Frontend's static, non-interactive fallback. + if value.label ~= nil then text(value.label, field .. ".label", P.MAX_TEXT_BYTES, true) end + if value.text ~= nil then text(value.text, field .. ".text", P.MAX_TEXT_BYTES, true) end + end + if state.total > P.MAX_TOTAL_WIDGETS then errors.raise("LIMIT_EXCEEDED", "presentation widget count exceeds its limit", { limit = P.MAX_TOTAL_WIDGETS }) end +end + +local function widgets(list, field, state) + local count = array(list, field) + if count > P.MAX_WIDGETS_PER_PANEL then errors.raise("LIMIT_EXCEEDED", field .. " exceeds its widget limit", { limit = P.MAX_WIDGETS_PER_PANEL }) end + for i = 1, count do widget(list[i], field .. "[" .. i .. "]", 1, state) end +end + +function P.validate_panel(panel_id, panel) + text(panel_id, "panel_id", 128, false) + if type(panel) ~= "table" or getmetatable(panel) ~= nil then invalid("panel", "panel must be a map") end + if panel.id ~= nil and panel.id ~= panel_id then invalid("panel.id", "panel.id must match its outer ID") end + text(panel.kind, "panel.kind", 64, false) -- unknown kinds degrade to content in compatible Frontends + text(panel.title, "panel.title", P.MAX_TEXT_BYTES, true) + bool(panel.visible, "panel.visible"); bool(panel.focusable, "panel.focusable"); bool(panel.scrollable, "panel.scrollable") + if panel.selected_widget_id ~= nil then text(panel.selected_widget_id, "panel.selected_widget_id", 128, false) end + local state = { total = 0, ids = {} }; widgets(panel.widgets, "panel.widgets", state) + estimate(panel) +end + +function P.validate_prompt(prompt) + if prompt == nil then return end + if type(prompt) ~= "table" or getmetatable(prompt) ~= nil then invalid("prompt", "prompt must be a map") end + text(prompt.id, "prompt.id", 128, false); text(prompt.title, "prompt.title", P.MAX_TEXT_BYTES, true) + if prompt.message ~= nil then text(prompt.message, "prompt.message", P.MAX_TEXT_BYTES, true) end + bool(prompt.visible, "prompt.visible"); if prompt.cancel ~= nil then action(prompt.cancel, "prompt.cancel") end + local state = { total = 0, ids = {} }; widgets(prompt.widgets, "prompt.widgets", state) + estimate(prompt) +end + +function P.validate_snapshot(panels, prompt, styles) + local count, total = 0, 0 + for id, panel in pairs(panels or {}) do + count = count + 1; if count > P.MAX_PANELS then errors.raise("LIMIT_EXCEEDED", "panel count exceeds its limit", { limit = P.MAX_PANELS }) end + P.validate_panel(id, panel) + local _, bytes = pcall(estimate, panel) + if type(bytes) == "number" then total = total + bytes end + end + P.validate_prompt(prompt); P.validate_style_table(styles or {}) + total = total + estimate(prompt) + estimate(styles or {}) + if total > P.MAX_PRESENTATION_BYTES then errors.raise("LIMIT_EXCEEDED", "presentation exceeds its aggregate byte limit", { limit = P.MAX_PRESENTATION_BYTES }) end +end + +return P From 37468fd6ecf1a912bb31af7b05c2bee06a9bc138 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 13 Nov 2026 19:22:00 +0330 Subject: [PATCH 2/5] feat(runtime): add owner-scoped declarative extension UI --- docs/contracts/extension-api.md | 37 ++++++-- lua/cdin/runtime/application.lua | 16 ++-- lua/cdin/runtime/extension/manager.lua | 58 +++++++++++- .../runtime/presentation/style/registry.lua | 5 +- .../runtime/presentation/theme/registry.lua | 8 +- lua/cdin/runtime/presentation/ui/model.lua | 89 ++++++++++++++++++- .../runtime/presentation/ui/presentation.lua | 29 +++--- tests/integration/run.lua | 75 +++++++++++++++- tests/unit/run.lua | 12 ++- 9 files changed, 288 insertions(+), 41 deletions(-) diff --git a/docs/contracts/extension-api.md b/docs/contracts/extension-api.md index 6bb4919..bf02815 100644 --- a/docs/contracts/extension-api.md +++ b/docs/contracts/extension-api.md @@ -1,6 +1,6 @@ # Extension API -**Contract version:** `1.0` (compatibility target; host-side legacy parity is incomplete) +**Contract version:** `1.0` (Registry SemVer `1.0.0`; documented stable extension surface) Extensions run inside a dedicated Lua environment and receive the capabilities explicitly exposed by the Runtime. The API preserves the basic module shape used @@ -51,7 +51,7 @@ the runtime is closing. ## Capability facade -The current manager exposes limited capabilities: +The current manager exposes owner-scoped capabilities. Contributions are validated before they enter the semantic UI model: - `core.commands:register(name, handler, options)` and `remove(name)` for extension-owned commands. An extension cannot replace a command owned by @@ -68,12 +68,33 @@ The current manager exposes limited capabilities: must be flat strings, numbers or booleans, and extensions never touch a logger, `io` or the console directly. - `core.unload_plugin(name)` for explicit unload. -- `core.ui:publish(model)` is currently a narrow provisional passthrough and is - expected to be replaced by validated extension UI contributions before a - stable release. - -The full legacy `system`, `themes`, package path and UI extension API has not -been migrated. It must not be assumed available based on the old CDIN tree. +- `core.ui:register_panel(panel_id, panel)` and + `core.ui:update_panel(panel_id, panel)` add or replace one panel owned by + the calling extension. `core.ui:remove_panel(panel_id)` removes only that + extension's panel. +- `core.ui:set_prompt(prompt)` sets the Runtime-owned prompt when no other + extension owns it; `core.ui:clear_prompt()` clears only the caller's prompt. + Panel and prompt data use the accepted + [UI presentation contract v1](https://github.com/cdin-editor/spec/blob/main/docs/proposals/ui-presentation-contract-v1.md) + schema: bounded `label`, `text`, `list`, `tree`, `button` and `input` + widgets, stable IDs, typed actions and validated text/style fields. +- Panel and prompt ownership is transactional with extension lifecycle. + Contributions are removed after failed initialization, explicit unload, or + Runtime shutdown. An extension cannot overwrite or remove another owner's + panel/prompt. +- `core.ui:publish(model)` remains a low-level transitional passthrough for + trusted embedders. It bypasses Runtime's owner-scoped semantic model and is + **not part of the stable Extension Contract**; extensions must use the + contribution methods above instead. + +The full legacy `system`, package path and direct renderer API has not been +migrated. It must not be assumed available based on the old CDIN tree. +Extensions express ordinary UI through the bounded declarative schema; +free-form drawing belongs to the separately negotiated optional `canvas` +capability and is unavailable unless the embedding Frontend explicitly accepts +it. A Frontend without `ui-presentation/1` may render static fallback text but +must not activate widget actions. Runtime does not advertise Frontend +capabilities itself. ## Resolver-backed lifecycle diff --git a/lua/cdin/runtime/application.lua b/lua/cdin/runtime/application.lua index 15cb512..b34c665 100644 --- a/lua/cdin/runtime/application.lua +++ b/lua/cdin/runtime/application.lua @@ -13,6 +13,7 @@ local ThemeRegistry = require("cdin.runtime.presentation.theme.registry") local SyntaxRegistry = require("cdin.runtime.presentation.syntax.registry") local Highlighter = require("cdin.runtime.presentation.syntax.highlighter") local UIModel = require("cdin.runtime.presentation.ui.model") +local UIPresentation = require("cdin.runtime.presentation.ui.presentation") local View = require("cdin.runtime.presentation.ui.view") local DefaultCommands = require("cdin.runtime.command.defaults") local Table = require("cdin.runtime.util.tablex") @@ -56,7 +57,9 @@ function Runtime.new(options) events = self.events, config = self.config, logger = self.log:child("extension"), - ui = host.ui, + ui_model = self.ui, + host_ui = host.ui, + publish_ui = function() self:_publish_ui() end, styles = self.styles, themes = self.themes, syntax = self.syntax, @@ -99,11 +102,11 @@ function Runtime:start() self.styles:define("syntax.string", { description = "Language string", category = "syntax" }) self.styles:define("syntax.comment", { description = "Language comment", category = "syntax" }) self.themes:register("default", { - ["text.default"] = { role = "text.default" }, - ["text.selection"] = { role = "text.selection" }, - ["syntax.keyword"] = { role = "syntax.keyword" }, - ["syntax.string"] = { role = "syntax.string" }, - ["syntax.comment"] = { role = "syntax.comment" }, + ["text.default"] = {}, + ["text.selection"] = {}, + ["syntax.keyword"] = {}, + ["syntax.string"] = {}, + ["syntax.comment"] = {}, }) self.syntax:register("plain", require("cdin.runtime.presentation.syntax.plain")) DefaultCommands.register(self) @@ -354,6 +357,7 @@ function Runtime:_publish_ui() end snapshot.theme = self.themes.active snapshot.styles = self.themes:snapshot().styles + UIPresentation.validate_snapshot(snapshot.panels, snapshot.prompt, snapshot.styles) local failures = self.events:emit("ui.model_changed", Table.copy(snapshot)) for _, failure in ipairs(failures) do diff --git a/lua/cdin/runtime/extension/manager.lua b/lua/cdin/runtime/extension/manager.lua index e1f2ab8..600a570 100644 --- a/lua/cdin/runtime/extension/manager.lua +++ b/lua/cdin/runtime/extension/manager.lua @@ -15,7 +15,9 @@ function Manager.new(options) events = options.events, config = options.config, logger = options.logger or Log.null(), - ui = options.ui, + ui_model = options.ui_model, + host_ui = options.host_ui, + publish_ui = options.publish_ui, styles = options.styles, themes = options.themes, syntax = options.syntax, @@ -77,8 +79,43 @@ function Manager:_facade(name) return manager:unload(plugin_name) end, ui = { + register_panel = function(_, panel_id, panel) + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:set_panel(panel_id, panel, name) + end, + update_panel = function(_, panel_id, panel) + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:set_panel(panel_id, panel, name) + end, + remove_panel = function(_, panel_id) + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:remove_panel(panel_id, name) + end, + set_prompt = function(_, prompt) + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:set_prompt(prompt, name) + end, + clear_prompt = function() + if not manager.ui_model then + errors.raise("UNSUPPORTED", "Runtime UI contributions are unavailable") + end + return manager.ui_model:clear_prompt(name) + end, + -- Transitional low-level passthrough retained for trusted embedders only. + -- It is deliberately not part of the stable Extension Contract. publish = function(_, model) - return manager.ui:publish(model) + if type(manager.host_ui) ~= "table" or type(manager.host_ui.publish) ~= "function" then + errors.raise("UNSUPPORTED", "direct UI publication is unavailable") + end + return manager.host_ui:publish(model) end, }, styles = { @@ -109,6 +146,17 @@ function Manager:_facade(name) } end +function Manager:_publish_ui() + if type(self.publish_ui) ~= "function" then return true end + local ok, failure = pcall(self.publish_ui) + if not ok then + local normalized = errors.normalize(failure, "INTERNAL", "publish extension UI") + self.logger:error("extension UI snapshot publication failed", { code = normalized.code }) + return false, normalized + end + return true +end + function Manager:load(name, source, config, options) options = options or {} if self.closed then @@ -140,6 +188,8 @@ function Manager:load(name, source, config, options) if self.themes then self.themes:remove_owner(name) end if self.syntax then self.syntax:remove_owner(name) end if self.styles then self.styles:remove_owner(name) end + local ui_changed = self.ui_model and self.ui_model:remove_owner(name) + if ui_changed then self:_publish_ui() end local failure = errors.normalize(module, "BAD_EXTENSION", "load plugin:" .. name) self.logger:child(name):error("extension failed to load", { code = failure.code }) error(failure, 0) @@ -154,6 +204,7 @@ function Manager:load(name, source, config, options) } self.plugins[name] = record self.logger:child(name):info("extension loaded", { source = record.source }) + self:_publish_ui() return record end @@ -232,7 +283,9 @@ function Manager:unload(name) if self.themes then self.themes:remove_owner(name) end if self.syntax then self.syntax:remove_owner(name) end if self.styles then self.styles:remove_owner(name) end + if self.ui_model then self.ui_model:remove_owner(name) end self.plugins[name] = nil + self:_publish_ui() if not ok then local normalized = errors.normalize(failure, "BAD_EXTENSION", "plugin unload:" .. name) @@ -270,6 +323,7 @@ function Manager:shutdown() if self.themes then self.themes:remove_owner(name) end if self.syntax then self.syntax:remove_owner(name) end if self.styles then self.styles:remove_owner(name) end + if self.ui_model then self.ui_model:remove_owner(name) end end self.plugins = {} end diff --git a/lua/cdin/runtime/presentation/style/registry.lua b/lua/cdin/runtime/presentation/style/registry.lua index 52e6db4..e0ff324 100644 --- a/lua/cdin/runtime/presentation/style/registry.lua +++ b/lua/cdin/runtime/presentation/style/registry.lua @@ -1,5 +1,6 @@ local errors = require("cdin.runtime.core.error") local tablex = require("cdin.runtime.util.tablex") +local Presentation = require("cdin.runtime.presentation.ui.presentation") local Registry = {} Registry.__index = Registry @@ -13,9 +14,7 @@ end function Registry:define(name, options) options = options or {} - if type(name) ~= "string" or name == "" then - errors.raise("INVALID_ARGUMENT", "style role name is required") - end + Presentation.validate_style_role(name) if self.roles[name] then errors.raise("DUPLICATE_REGISTRATION", "style role already exists: " .. name) end diff --git a/lua/cdin/runtime/presentation/theme/registry.lua b/lua/cdin/runtime/presentation/theme/registry.lua index 50fef0e..2262523 100644 --- a/lua/cdin/runtime/presentation/theme/registry.lua +++ b/lua/cdin/runtime/presentation/theme/registry.lua @@ -1,5 +1,6 @@ local errors = require("cdin.runtime.core.error") local tablex = require("cdin.runtime.util.tablex") +local Presentation = require("cdin.runtime.presentation.ui.presentation") local Registry = {} Registry.__index = Registry @@ -23,16 +24,13 @@ function Registry:register(name, theme, options) local copied = tablex.copy(theme) for role_name, style in pairs(copied) do + Presentation.validate_style_role(role_name) if not self.styles:has(role_name) then errors.raise("INVALID_ARGUMENT", "theme references unknown style role", { role = role_name, }) end - if type(style) ~= "table" then - errors.raise("INVALID_ARGUMENT", "theme style role must map to a table", { - role = role_name, - }) - end + Presentation.validate_style(style, "theme." .. role_name) end self.themes[name] = { diff --git a/lua/cdin/runtime/presentation/ui/model.lua b/lua/cdin/runtime/presentation/ui/model.lua index f085b78..7bb7c1e 100644 --- a/lua/cdin/runtime/presentation/ui/model.lua +++ b/lua/cdin/runtime/presentation/ui/model.lua @@ -1,6 +1,7 @@ local errors = require("cdin.runtime.core.error") local tablex = require("cdin.runtime.util.tablex") local PaneTree = require("cdin.runtime.presentation.ui.pane_tree") +local Presentation = require("cdin.runtime.presentation.ui.presentation") -- Model the editor state shared with a frontend. This is semantic state only: -- it contains no pixel positions, rendering objects or document text. @@ -16,6 +17,8 @@ function Model.new() revision = 0, views = {}, panels = {}, + panel_owners = {}, + prompt_owner = nil, focus = nil, prompt = nil, status = {}, @@ -143,24 +146,101 @@ function Model:set_status(key, value) self:_touch() end -function Model:set_prompt(prompt) - self.prompt = prompt and tablex.copy(prompt) or nil +function Model:set_prompt(prompt, owner) + owner = owner or "core" + if prompt == nil then + return self:clear_prompt(owner) + end + if self.prompt ~= nil and self.prompt_owner ~= owner then + errors.raise("PERMISSION_DENIED", "prompt is owned by another extension", { + owner = self.prompt_owner, requester = owner, + }) + end + Presentation.validate_prompt(prompt) + self.prompt = tablex.copy(prompt) + self.prompt_owner = owner self:_touch() + return true end -function Model:set_panel(name, panel) +function Model:clear_prompt(owner) + owner = owner or "core" + if self.prompt == nil then return false end + if self.prompt_owner ~= owner then + errors.raise("PERMISSION_DENIED", "extension cannot clear another owner's prompt", { + owner = self.prompt_owner, requester = owner, + }) + end + self.prompt = nil + self.prompt_owner = nil + self:_touch() + return true +end + +function Model:set_panel(name, panel, owner) + owner = owner or "core" if type(name) ~= "string" or name == "" then errors.raise("INVALID_ARGUMENT", "panel name is required") end + local current_owner = self.panel_owners[name] + if current_owner ~= nil and current_owner ~= owner then + errors.raise("PERMISSION_DENIED", "extension cannot replace another owner's panel", { + panel_id = name, owner = current_owner, requester = owner, + }) + end if panel ~= nil and self.panels[name] == nil then local panel_count = 0 for _ in pairs(self.panels) do panel_count = panel_count + 1 end if panel_count >= Model.MAX_PANELS then errors.raise("LIMIT_EXCEEDED", "UI model panel limit reached", { limit = Model.MAX_PANELS }) end + Presentation.validate_panel(name, panel) + self.panels[name] = tablex.copy(panel) + self.panel_owners[name] = owner + self:_touch() + return true end - self.panels[name] = panel and tablex.copy(panel) or nil + if panel == nil then + return self:remove_panel(name, owner) + end + Presentation.validate_panel(name, panel) + self.panels[name] = tablex.copy(panel) + self.panel_owners[name] = owner + self:_touch() + return true +end + +function Model:remove_panel(name, owner) + owner = owner or "core" + if self.panels[name] == nil then return false end + local current_owner = self.panel_owners[name] + if current_owner ~= owner then + errors.raise("PERMISSION_DENIED", "extension cannot remove another owner's panel", { + panel_id = name, owner = current_owner, requester = owner, + }) + end + self.panels[name] = nil + self.panel_owners[name] = nil self:_touch() + return true +end + +function Model:remove_owner(owner) + local changed = false + for panel_id, panel_owner in pairs(self.panel_owners) do + if panel_owner == owner then + self.panel_owners[panel_id] = nil + self.panels[panel_id] = nil + changed = true + end + end + if self.prompt ~= nil and self.prompt_owner == owner then + self.prompt = nil + self.prompt_owner = nil + changed = true + end + if changed then self:_touch() end + return changed end function Model:set_theme(name) @@ -173,6 +253,7 @@ function Model:_touch() end function Model:snapshot() + Presentation.validate_snapshot(self.panels, self.prompt, {}) local views = {} for view_id, view in pairs(self.views) do views[view_id] = view:snapshot() diff --git a/lua/cdin/runtime/presentation/ui/presentation.lua b/lua/cdin/runtime/presentation/ui/presentation.lua index 264cb37..3dc9b2a 100644 --- a/lua/cdin/runtime/presentation/ui/presentation.lua +++ b/lua/cdin/runtime/presentation/ui/presentation.lua @@ -171,7 +171,7 @@ local function widget(value, field, depth, state) elseif kind == "input" then text(value.value, field .. ".value", P.MAX_TEXT_BYTES, true) if value.placeholder ~= nil then text(value.placeholder, field .. ".placeholder", P.MAX_TEXT_BYTES, true) end - local limit = value.max_bytes or P.DEFAULT_INPUT_BYTES + local limit = value.max_bytes == nil and P.DEFAULT_INPUT_BYTES or value.max_bytes integer(limit, field .. ".max_bytes", 1, P.MAX_INPUT_BYTES) if #value.value > limit then invalid(field .. ".value", "input value exceeds max_bytes", { limit = limit }) end bool(value.multiline, field .. ".multiline"); action(value.submit, field .. ".submit", true) @@ -207,21 +207,30 @@ function P.validate_prompt(prompt) text(prompt.id, "prompt.id", 128, false); text(prompt.title, "prompt.title", P.MAX_TEXT_BYTES, true) if prompt.message ~= nil then text(prompt.message, "prompt.message", P.MAX_TEXT_BYTES, true) end bool(prompt.visible, "prompt.visible"); if prompt.cancel ~= nil then action(prompt.cancel, "prompt.cancel") end - local state = { total = 0, ids = {} }; widgets(prompt.widgets, "prompt.widgets", state) + state = state or { total = 0, ids = {} }; widgets(prompt.widgets, "prompt.widgets", state) estimate(prompt) + return state end function P.validate_snapshot(panels, prompt, styles) - local count, total = 0, 0 + local count, total_widgets, total_bytes = 0, 0, 0 for id, panel in pairs(panels or {}) do - count = count + 1; if count > P.MAX_PANELS then errors.raise("LIMIT_EXCEEDED", "panel count exceeds its limit", { limit = P.MAX_PANELS }) end - P.validate_panel(id, panel) - local _, bytes = pcall(estimate, panel) - if type(bytes) == "number" then total = total + bytes end + count = count + 1 + if count > P.MAX_PANELS then errors.raise("LIMIT_EXCEEDED", "panel count exceeds its limit", { limit = P.MAX_PANELS }) end + local state = P.validate_panel(id, panel, { total = total_widgets, ids = {} }) + total_widgets = state.total + total_bytes = total_bytes + estimate(panel) + end + local prompt_state = P.validate_prompt(prompt, { total = total_widgets, ids = {} }) + total_widgets = prompt_state and prompt_state.total or total_widgets + P.validate_style_table(styles or {}) + total_bytes = total_bytes + estimate(prompt) + estimate(styles or {}) + if total_widgets > P.MAX_TOTAL_WIDGETS then + errors.raise("LIMIT_EXCEEDED", "presentation widget count exceeds its limit", { limit = P.MAX_TOTAL_WIDGETS }) + end + if total_bytes > P.MAX_PRESENTATION_BYTES then + errors.raise("LIMIT_EXCEEDED", "presentation exceeds its aggregate byte limit", { limit = P.MAX_PRESENTATION_BYTES }) end - P.validate_prompt(prompt); P.validate_style_table(styles or {}) - total = total + estimate(prompt) + estimate(styles or {}) - if total > P.MAX_PRESENTATION_BYTES then errors.raise("LIMIT_EXCEEDED", "presentation exceeds its aggregate byte limit", { limit = P.MAX_PRESENTATION_BYTES }) end end return P diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 9f5ec3f..6890b8f 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -363,18 +363,85 @@ A.falsy(runtime.commands:get("plugin.hello")) -- RT-013 | Reference: https://github.com/cdin-editor/spec -- Extension-owned presentation contributions must be removed on unload. local owned_plugin = [[return { init = function(core) + core.commands:register("plugin.panel.activate", function() return "activated" end) core.styles:define("plugin.sample", { category = "test" }) - core.themes:register("plugin.sample", { ["plugin.sample"] = { role = "plugin.sample" } }) + core.themes:register("plugin.sample", { + ["plugin.sample"] = { + foreground = { r = 180, g = 190, b = 200, a = 255 }, + bold = true, + }, + }) core.syntax:register("plugin-sample", { extensions = { "psamp" }, tokenize = function() return {} end }) + core.ui:register_panel("plugin.sample.panel", { + kind = "content", + title = "Plugin sample", + widgets = { + { id = "hello", kind = "label", text = "Hello" }, + { id = "activate", kind = "button", label = "Activate", + activate = { command = "plugin.panel.activate" } }, + { id = "search", kind = "input", value = "", max_bytes = 128, + submit = { command = "plugin.panel.activate", arguments = {} } }, + }, + }) + core.ui:set_prompt({ + id = "plugin.sample.prompt", + title = "Plugin prompt", + widgets = { + { id = "prompt-label", kind = "text", runs = { { text = "Enter a value" } } }, + }, + }) end }]] runtime.plugins:load("presentation-sample", owned_plugin, {}) A.truthy(runtime.styles:has("plugin.sample")) A.truthy(runtime.themes.themes["plugin.sample"] ~= nil) A.truthy(runtime.syntax:get("plugin-sample") ~= nil) -runtime.plugins:unload("presentation-sample") +A.truthy(runtime.ui.panels["plugin.sample.panel"] ~= nil) +A.equal(runtime.ui.prompt.id, "plugin.sample.prompt") +A.equal(host.published[#host.published].panels["plugin.sample.panel"].widgets[2].activate.command, + "plugin.panel.activate") +A.truthy(runtime.plugins:unload("presentation-sample")) A.falsy(runtime.styles:has("plugin.sample")) A.falsy(runtime.themes.themes["plugin.sample"] ~= nil) A.falsy(runtime.syntax:get("plugin-sample") ~= nil) +A.falsy(runtime.ui.panels["plugin.sample.panel"]) +A.equal(runtime.ui.prompt, nil) +A.falsy(runtime.commands:get("plugin.panel.activate")) + +local invalid_presentation_plugin = [[return { init = function(core) + core.ui:register_panel("plugin.invalid.panel", { + kind = "content", title = "Invalid", + widgets = { + { id = "duplicate", kind = "label", text = "one" }, + { id = "duplicate", kind = "button", label = "two", + activate = { command = "plugin.panel.activate" } }, + }, + }) +end }]] +A.error_code(function() + runtime.plugins:load("invalid-presentation", invalid_presentation_plugin, {}) +end, "INVALID_ARGUMENT") +A.falsy(runtime.ui.panels["plugin.invalid.panel"]) + +local invalid_style_plugin = [[return { init = function(core) + core.styles:define("plugin.invalid-style", { category = "test" }) + core.themes:register("plugin.invalid-style", { + ["plugin.invalid-style"] = { foreground = { r = 256, g = 0, b = 0, a = 255 } }, + }) +end }]] +A.error_code(function() + runtime.plugins:load("invalid-style", invalid_style_plugin, {}) +end, "INVALID_ARGUMENT") +A.falsy(runtime.styles:has("plugin.invalid-style")) + +local panel_conflict = [[return { init = function(core) + core.ui:register_panel("plugin.sample.panel", { + kind = "content", title = "Takeover", widgets = {}, + }) +end }]] +local conflict_error = A.error_code(function() + runtime.plugins:load("panel-takeover", panel_conflict, {}) +end, "PERMISSION_DENIED") +A.equal(conflict_error.details.panel_id, "plugin.sample.panel") local command_takeover = [[return { init = function(core) core.commands:register("document.undo", function() return "hijacked" end, { overwrite = true }) end }]] @@ -386,10 +453,14 @@ A.truthy(runtime.commands:get("document.undo")) A.equal(runtime.commands:get("document.undo").owner, "core") local failed_plugin = [[return { init = function(core) core.commands:register("plugin.partial", function() return true end) + core.ui:register_panel("plugin.partial.panel", { + kind = "content", title = "Partial", widgets = {}, + }) error("intentional init failure") end, unload = function() error("must not run after init failure") end }]] A.error_code(function() runtime.plugins:load("broken", failed_plugin, {}) end, "BAD_EXTENSION") A.falsy(runtime.commands:get("plugin.partial")) +A.falsy(runtime.ui.panels["plugin.partial.panel"]) -- Resolver-backed extension lifecycle is available only to trusted Runtime -- embedders. It is deliberately not registered as a Frontend Protocol command. diff --git a/tests/unit/run.lua b/tests/unit/run.lua index 2671775..f6e3d1d 100644 --- a/tests/unit/run.lua +++ b/tests/unit/run.lua @@ -187,9 +187,19 @@ A.equal(ratio_model.revision, prior_ratio_revision + 1) A.equal(ratio_model:snapshot().layout.root.ratio, 0.65) local styles = Styles.new(); styles:define("text.default") -local themes = Themes.new(styles); themes:register("default", { ["text.default"] = { foreground = "#ffffff" } }) +local themes = Themes.new(styles); themes:register("default", { + ["text.default"] = { + foreground = { r = 255, g = 255, b = 255, a = 255 }, + bold = false, + }, +}) A.equal(themes:activate("default").name, "default") A.error_code(function() themes:register("bad", { missing = {} }) end, "INVALID_ARGUMENT") +A.error_code(function() + themes:register("invalid-color", { + ["text.default"] = { foreground = { r = 256, g = 0, b = 0, a = 255 } }, + }) +end, "INVALID_ARGUMENT") local syntax = Syntax.new(); syntax:register("toy", { extensions = { "toy" }, tokenize = function(text) return { { start = 0, finish = #text, role = "syntax.keyword" } } end }) A.error_code(function() syntax:register("other-toy", { extensions = { "toy" }, tokenize = function() return {} end }) From 19fb8ec14887306e9e083799d41a61ce39b2e5d2 Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 13 Nov 2026 19:23:22 +0330 Subject: [PATCH 3/5] fix(ui): preserve aggregate validator state --- lua/cdin/runtime/presentation/ui/presentation.lua | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/lua/cdin/runtime/presentation/ui/presentation.lua b/lua/cdin/runtime/presentation/ui/presentation.lua index 3dc9b2a..d622a0f 100644 --- a/lua/cdin/runtime/presentation/ui/presentation.lua +++ b/lua/cdin/runtime/presentation/ui/presentation.lua @@ -189,7 +189,7 @@ local function widgets(list, field, state) for i = 1, count do widget(list[i], field .. "[" .. i .. "]", 1, state) end end -function P.validate_panel(panel_id, panel) +function P.validate_panel(panel_id, panel, state) text(panel_id, "panel_id", 128, false) if type(panel) ~= "table" or getmetatable(panel) ~= nil then invalid("panel", "panel must be a map") end if panel.id ~= nil and panel.id ~= panel_id then invalid("panel.id", "panel.id must match its outer ID") end @@ -197,12 +197,14 @@ function P.validate_panel(panel_id, panel) text(panel.title, "panel.title", P.MAX_TEXT_BYTES, true) bool(panel.visible, "panel.visible"); bool(panel.focusable, "panel.focusable"); bool(panel.scrollable, "panel.scrollable") if panel.selected_widget_id ~= nil then text(panel.selected_widget_id, "panel.selected_widget_id", 128, false) end - local state = { total = 0, ids = {} }; widgets(panel.widgets, "panel.widgets", state) + state = state or { total = 0, ids = {} } + widgets(panel.widgets, "panel.widgets", state) estimate(panel) + return state end -function P.validate_prompt(prompt) - if prompt == nil then return end +function P.validate_prompt(prompt, state) + if prompt == nil then return state end if type(prompt) ~= "table" or getmetatable(prompt) ~= nil then invalid("prompt", "prompt must be a map") end text(prompt.id, "prompt.id", 128, false); text(prompt.title, "prompt.title", P.MAX_TEXT_BYTES, true) if prompt.message ~= nil then text(prompt.message, "prompt.message", P.MAX_TEXT_BYTES, true) end From bf892ba96d2c0cc366c67f2ca3018b948e9d859e Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 13 Nov 2026 19:29:30 +0330 Subject: [PATCH 4/5] test(ui): check panel ownership before unloading owner --- tests/integration/run.lua | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 6890b8f..49fe010 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -399,6 +399,15 @@ A.truthy(runtime.ui.panels["plugin.sample.panel"] ~= nil) A.equal(runtime.ui.prompt.id, "plugin.sample.prompt") A.equal(host.published[#host.published].panels["plugin.sample.panel"].widgets[2].activate.command, "plugin.panel.activate") +local panel_conflict = [[return { init = function(core) + core.ui:register_panel("plugin.sample.panel", { + kind = "content", title = "Takeover", widgets = {}, + }) +end }]] +local conflict_error = A.error_code(function() + runtime.plugins:load("panel-takeover", panel_conflict, {}) +end, "PERMISSION_DENIED") +A.equal(conflict_error.details.panel_id, "plugin.sample.panel") A.truthy(runtime.plugins:unload("presentation-sample")) A.falsy(runtime.styles:has("plugin.sample")) A.falsy(runtime.themes.themes["plugin.sample"] ~= nil) @@ -433,15 +442,6 @@ A.error_code(function() end, "INVALID_ARGUMENT") A.falsy(runtime.styles:has("plugin.invalid-style")) -local panel_conflict = [[return { init = function(core) - core.ui:register_panel("plugin.sample.panel", { - kind = "content", title = "Takeover", widgets = {}, - }) -end }]] -local conflict_error = A.error_code(function() - runtime.plugins:load("panel-takeover", panel_conflict, {}) -end, "PERMISSION_DENIED") -A.equal(conflict_error.details.panel_id, "plugin.sample.panel") local command_takeover = [[return { init = function(core) core.commands:register("document.undo", function() return "hijacked" end, { overwrite = true }) end }]] From 1b42779cfc3c78035fa182a7ca928d2d7e85809d Mon Sep 17 00:00:00 2001 From: Genix Date: Fri, 13 Nov 2026 19:39:06 +0330 Subject: [PATCH 5/5] fix(ui): gate legacy extension publication behind trusted opt-in --- docs/contracts/extension-api.md | 11 +++++++---- lua/cdin/runtime/application.lua | 1 + lua/cdin/runtime/extension/manager.lua | 23 ++++++++++++++--------- tests/integration/run.lua | 5 +++++ 4 files changed, 27 insertions(+), 13 deletions(-) diff --git a/docs/contracts/extension-api.md b/docs/contracts/extension-api.md index bf02815..48ebbb5 100644 --- a/docs/contracts/extension-api.md +++ b/docs/contracts/extension-api.md @@ -82,10 +82,13 @@ The current manager exposes owner-scoped capabilities. Contributions are validat Contributions are removed after failed initialization, explicit unload, or Runtime shutdown. An extension cannot overwrite or remove another owner's panel/prompt. -- `core.ui:publish(model)` remains a low-level transitional passthrough for - trusted embedders. It bypasses Runtime's owner-scoped semantic model and is - **not part of the stable Extension Contract**; extensions must use the - contribution methods above instead. +- `core.ui:publish` is **not exposed to extensions by default**. A trusted + embedding application may explicitly set Runtime construction option + `allow_legacy_extension_ui_publish = true` to expose the old low-level + passthrough during migration. It bypasses the owner-scoped semantic model, + is outside the stable Extension Contract, and must never be enabled for + untrusted extension sources. Extensions should use the contribution methods + above instead. The full legacy `system`, package path and direct renderer API has not been migrated. It must not be assumed available based on the old CDIN tree. diff --git a/lua/cdin/runtime/application.lua b/lua/cdin/runtime/application.lua index b34c665..7a9ef5a 100644 --- a/lua/cdin/runtime/application.lua +++ b/lua/cdin/runtime/application.lua @@ -59,6 +59,7 @@ function Runtime.new(options) logger = self.log:child("extension"), ui_model = self.ui, host_ui = host.ui, + allow_legacy_ui_publish = options.allow_legacy_extension_ui_publish == true, publish_ui = function() self:_publish_ui() end, styles = self.styles, themes = self.themes, diff --git a/lua/cdin/runtime/extension/manager.lua b/lua/cdin/runtime/extension/manager.lua index 600a570..69e8671 100644 --- a/lua/cdin/runtime/extension/manager.lua +++ b/lua/cdin/runtime/extension/manager.lua @@ -17,6 +17,7 @@ function Manager.new(options) logger = options.logger or Log.null(), ui_model = options.ui_model, host_ui = options.host_ui, + allow_legacy_ui_publish = options.allow_legacy_ui_publish == true, publish_ui = options.publish_ui, styles = options.styles, themes = options.themes, @@ -33,7 +34,7 @@ function Manager:_facade(name) -- another one: the logger owns the `component` field. local logger = self.logger:child(name) - return { + local facade = { commands = { register = function(_, command_name, handler, options) options = options or {} @@ -109,14 +110,6 @@ function Manager:_facade(name) end return manager.ui_model:clear_prompt(name) end, - -- Transitional low-level passthrough retained for trusted embedders only. - -- It is deliberately not part of the stable Extension Contract. - publish = function(_, model) - if type(manager.host_ui) ~= "table" or type(manager.host_ui.publish) ~= "function" then - errors.raise("UNSUPPORTED", "direct UI publication is unavailable") - end - return manager.host_ui:publish(model) - end, }, styles = { define = function(_, role, options) @@ -144,6 +137,18 @@ function Manager:_facade(name) end, }, } + + -- Not visible to extensions by default. Only a trusted embedding + -- application may explicitly enable this migration-only escape hatch. + if manager.allow_legacy_ui_publish then + facade.ui.publish = function(_, model) + if type(manager.host_ui) ~= "table" or type(manager.host_ui.publish) ~= "function" then + errors.raise("UNSUPPORTED", "direct UI publication is unavailable") + end + return manager.host_ui:publish(model) + end + end + return facade end function Manager:_publish_ui() diff --git a/tests/integration/run.lua b/tests/integration/run.lua index 49fe010..86aabc5 100644 --- a/tests/integration/run.lua +++ b/tests/integration/run.lua @@ -364,6 +364,7 @@ A.falsy(runtime.commands:get("plugin.hello")) -- Extension-owned presentation contributions must be removed on unload. local owned_plugin = [[return { init = function(core) core.commands:register("plugin.panel.activate", function() return "activated" end) + core.commands:register("plugin.panel.legacy-publish-hidden", function() return core.ui.publish == nil end) core.styles:define("plugin.sample", { category = "test" }) core.themes:register("plugin.sample", { ["plugin.sample"] = { @@ -396,6 +397,9 @@ A.truthy(runtime.styles:has("plugin.sample")) A.truthy(runtime.themes.themes["plugin.sample"] ~= nil) A.truthy(runtime.syntax:get("plugin-sample") ~= nil) A.truthy(runtime.ui.panels["plugin.sample.panel"] ~= nil) +local hidden_ok, publish_is_hidden = runtime.commands:execute("plugin.panel.legacy-publish-hidden", {}, {}) +A.truthy(hidden_ok) +A.truthy(publish_is_hidden) A.equal(runtime.ui.prompt.id, "plugin.sample.prompt") A.equal(host.published[#host.published].panels["plugin.sample.panel"].widgets[2].activate.command, "plugin.panel.activate") @@ -415,6 +419,7 @@ A.falsy(runtime.syntax:get("plugin-sample") ~= nil) A.falsy(runtime.ui.panels["plugin.sample.panel"]) A.equal(runtime.ui.prompt, nil) A.falsy(runtime.commands:get("plugin.panel.activate")) +A.falsy(runtime.commands:get("plugin.panel.legacy-publish-hidden")) local invalid_presentation_plugin = [[return { init = function(core) core.ui:register_panel("plugin.invalid.panel", {