Screens
A panel screen written as a template cannot be changed from outside: a slot lets you put something into a place that was foreseen, but it does not let you drop a card, reorder the tabs or swap a field for another kind. Projects do exactly that. So a screen is not written — it is described: a tree of nodes in JSON, every node with a stable id. The module ships the tree, the project lays a patch over it, and @webx-ui/schema draws the result with the core components.
Try it: the left editor is what a settings module would ship, the right one is what a project adds on top. Both are live.
The model
{
"general.project-name": {
"en": "Acme",
"uk": "Акме"
},
"seo.robots-txt": "User-agent: *\nDisallow: /cms"
}Two things to notice. The tree carries a wx-media node — a type this page's registry does not know, because module-media registers it when it is installed — and the patch removes it. Delete that first operation and a loud placeholder appears where the picker would be: a type nobody registered is an error you can see, not an empty space. And the "Show the office on a map" switch controls whether the address field below it renders at all.
A node
{
"id": "robots",
"type": "wx-textarea",
"name": "seo.robots-txt",
"label": "trans::webx-settings::screen.robots",
"help": "trans::webx-settings::screen.robots-help",
"localized": false,
"props": { "rows": 8 },
"children": [],
"slot": null,
"visible": true,
"can": null
}| Key | Required | Meaning |
|---|---|---|
id | yes | Unique within the screen. It is the address patches use, and a public contract: renaming one is a breaking change. |
type | yes | A key in the registry. |
name | for fields | Key in the model. A literal string — the dots in seo.robots-txt are part of the key, not a path. |
label | no | Text. For a field it goes on the form item; for a card it is the title; see the table below for the rest. |
help | no | Hint under a field. |
localized | no | The field is edited per content language; the value is a record keyed by locale. Needs provideLocales above. |
required | no | For fields: always required — an asterisk at the label. Set by the server from the field's rules (below). |
default | no | For fields: drawn, and read by the site, while nothing is stored. Checked by the type's rules on the server. |
props | no | Passed to the component as they are. The renderer does not interpret them. |
children | no | Nested nodes. |
slot | no | A named slot of the parent to land in — sidebar on a card, extra on a header. Without it, the parent's default slot. |
visible | no | true, false, or a condition on the model. A hidden node is not rendered and its value is not touched. |
can | no | A permission such as settings.manage. Without it the node is not rendered. |
There are no other keys. The set is closed on purpose — validateScreen and the JSON schema both reject childrens — so a typo is an error, not a silently empty tab.
A default. A field with nothing stored — no key, or null — is drawn with its default, and the site reads the same: a switch that should be on until somebody turns it off is { "type": "wx-switch", "name": "popup.enabled", "default": true }, and settings('popup.enabled') answers true before anything is saved. The default is only drawn, never written: the renderer fills it into what the fields show and writes edits into the real model, so an untouched field is not sent and nothing is stored for it. A localized field takes one plain value for every language (the form shows it in the main one). The server checks a default with the rules of its type, once the tree is patched — one the type would refuse from an editor breaks the screen like any other mistake in it.
The asterisk. A field whose rules always require it — a bare required — is drawn with an asterisk at its label, and the label says nothing of it itself. The server works it out when it hands the screen out: the rules of the field's type, plus the rules the module checks the screen by on its own write path, told with Screens::rules('orders.show', ['customer.first_name' => ['required', …]]); the field comes out "required": true, and the renderer passes it on to WxFormItem. A field required only beside another — required_without, required_if — gets no asterisk: «a phone or an e-mail» marked on both says neither may be left out. Such a pair gets a hint under it instead. A description writes required itself only for a field its project checks; a form that is not a screen passes the same required prop to <wx-form-item>.
Translatable strings. Any string in a node that starts with trans:: is a dictionary key: trans::<namespace>::<key>. The marker works in label, help and anywhere inside props, options[].label included. The renderer hands <namespace>::<key> to its translate function; inside a panel that is the panel's dictionary, on this page it is a six-line object. Without a dictionary the key itself shows, which is the honest thing to do. A string without the marker is shown as it is — a project that publishes in one language writes «Телефон» straight into the patch.
A patch
An ordered list of operations. Targets are bare ids: an id is unique in the screen, and that is enough.
| Operation | Fields | What it does |
|---|---|---|
add | target, node, position | Inserts node into the children of target. position: first, last (default), before:<id>, after:<id> |
remove | target | Drops the node and everything under it. |
replace | target, node | Swaps the node for another one. The new id may differ. |
move | target, position, to? | Reorders among siblings, or moves into another parent to. |
set | target + node keys | Changes keys shallowly: props are merged key by key, everything else is replaced. id cannot be set. |
An operation that cannot be applied — its target does not exist, its anchor is missing, the node it adds carries an id that is already taken — is skipped and reported, and the operations after it still run. applyPatch returns the errors; WxScreenRenderer logs them and emits patch-error. A project patch that survived a rename in the module must not silence the rest.
applyPatch never mutates its input: it returns a new tree.
import { applyPatch } from '@webx-ui/schema'
const { root, errors } = applyPatch(screen.root, [
{ op: 'remove', target: 'project-logo' },
{ op: 'set', target: 'robots', props: { rows: 16 } },
])Rendering
<script setup lang="ts">
import { ref } from 'vue'
import { WxScreenRenderer } from '@webx-ui/schema'
const values = ref({})
const errors = ref({})
</script>
<template>
<wx-card>
<template #extra>
<wx-button type="primary" @click="save">Save</wx-button>
</template>
<wx-screen-renderer :root="screen.root" :patch="patch" v-model="values" :errors="errors" />
</wx-card>
</template>The renderer is a pure function of the tree and the model. It does not fetch anything and does not know how to save: the page that opens it loads the values, passes them in, and on Save sends them back and drops a 422 into errors — which land under the fields they name, exactly as they do in a WxForm. The buttons are the page's, not the screen's: there is no action node type.
| Prop | Type | Description |
|---|---|---|
root | ScreenNode[] | The tree, as the module ships it or as the server hands it back |
patch | Patch | Client-side operations applied on top |
modelValue | Record<string, unknown> | Values keyed by node name; v-model receives a new object on every change |
errors | Record<string, string[]> | Server validation errors by field name |
types | TypeRegistry | Project types, merged over the core ones |
translate | (key: string) => string | Resolves trans:: strings |
can | (permission: string) => boolean | Decides can; everything is allowed without it |
disabled | boolean | Disables every field |
labelPosition | 'top' | 'left' | Passed to the form |
labelWidth | string | Passed to the form |
size | 'sm' | 'md' | 'lg' | Passed to the form |
Events: update:modelValue, patch-error (PatchError[]). Exposed: tree — the tree after the patch, what is actually on screen.
Inside a panel you will not use the renderer directly: WxScreen from @webx-ui/module-admin fetches the screen by name, lays the project's patch from createAdmin({ screens }) over it, and passes the panel's dictionary, registry and permissions — <wx-screen name="settings.index" v-model="values" :errors="errors" />. Settings is the first page built on it.
The registry
A type is the full component name — wx-input, wx-card — so the name says it is the UI kit. The renderer treats a type by its kind:
- layout — a container. Children go into its slots; it never touches the model.
- field — bound to the model by
name, wrapped in awx-form-itemwith thelabelandhelp, shows the 422 message for its own name. Withlocalizedit gets the language selector. - display — draws and edits nothing.
The core types, generated from the registry (a test fails when this table is stale):
| Type | Kind | Component | label goes to |
|---|---|---|---|
wx-tabs | layout | WxScreenTabs | — |
wx-tab | layout | WxTab | prop label |
wx-card | layout | WxCard | prop title |
wx-row | layout | WxRow | — |
wx-col | layout | WxCol | — |
wx-divider | layout | WxDivider | prop label |
wx-input | field | WxInput | form item |
wx-textarea | field | WxTextarea | form item |
wx-input-number | field | WxInputNumber | form item |
wx-select | field | WxSelect | form item |
wx-switch | field | WxSwitch | form item |
wx-checkbox | field | WxCheckbox | form item |
wx-radio-group | field | WxRadioGroup | form item |
wx-date-picker | field | WxDatePicker | form item |
wx-color-picker | field | WxColorPicker | form item |
wx-checkbox-group | field | WxCheckboxGroup | form item |
wx-segmented | field | WxSegmented | form item |
wx-slider | field | WxSlider | form item |
wx-rate | field | WxRate | form item |
wx-time-picker | field | WxTimePicker | form item |
wx-date-time-picker | field | WxDateTimePicker | form item |
wx-date-range-picker | field | WxDateRangePicker | form item |
wx-tags-input | field | WxTagsInput | form item |
wx-autocomplete | field | WxAutocomplete | form item |
wx-icon-picker | field | WxIconPicker | form item |
wx-cascader | field | WxCascader | form item |
wx-tree-select | field | WxTreeSelect | form item |
wx-transfer | field | WxTransfer | form item |
wx-code-editor | field | WxCodeEditor | form item |
wx-repeater | field | WxScreenRepeater | form item |
wx-heading | display | WxHeading | default slot |
wx-text | display | WxText | default slot |
wx-alert | display | WxAlert | prop title |
What a choice, a number or a date keeps, as module-admin checks and stores it on the server. A list emptied to [] and a date cleared to '' are kept as null:
| Type | Value |
|---|---|
wx-select, wx-radio-group, wx-segmented | one of props.options |
wx-checkbox-group | a list of props.options values; props.min / max count them |
wx-transfer | a list of props.items values |
wx-cascader | the path of values from the root; the last one alone with emitPath: false |
wx-tree-select | a key from props.nodes; a list of keys with multiple |
wx-slider | a number, 0–100 unless props say; [from, to] with range |
wx-rate | a number from 0 to props.max (5), halves with allowHalf |
wx-date-picker | YYYY-MM-DD |
wx-date-range-picker | [start, end] |
wx-time-picker | HH:mm, or HH:mm:ss with seconds |
wx-date-time-picker | ISO 8601 with the offset, moved into the application's timezone |
wx-tags-input | a list of strings; only props.suggestions with allowCreate: false |
wx-autocomplete, wx-icon-picker | a string |
wx-code-editor | a string |
wx-date-time-picker always writes the offset: the registry binds its valueFormat, because a wall clock without a zone is read by the server in its timezone and by the browser in the reader's, and the same value shows two different hours.
wx-input holds a format when props.type names one. The type goes to the native <input>, so the browser checks it and a phone opens the matching keyboard, and the server holds the same line: email must be an address, url an http or https address, and tel is lenient on purpose — digits, spaces and + ( ) - ., at least three digits. An empty value is still allowed. Inside a wx-repeater every row is checked the same way.
{ "id": "email", "type": "wx-input", "name": "contacts.email", "props": { "type": "email" } }The panel's own frame comes with module-admin and is in every screen it draws:
| Type | From | Kind | What it is |
|---|---|---|---|
wx-list | module-admin | layout | the frame a section's list is drawn in |
wx-rich-text | module-admin | field | a document, kept as HTML |
wx-list is one shape for every list in the panel: the section's name on its own line, the one action the section exists for beside it, the views of the list as tabs under that, and a card holding nothing but the rows. The search stays inside the table — it narrows the rows, not the screen. label becomes its title; props take views ({ value, label, icon? }[]), card, padding, fill and collapseBelow, and children go inside the card:
{
"id": "list",
"type": "wx-list",
"label": "trans::webx-pages::module.title",
"props": {
"views": [
{ "value": "", "label": "All" },
{ "value": "draft", "label": "Drafts" }
]
},
"children": [{ "id": "rows", "type": "wx-pages-table" }]
}The card has no heading of its own: the tab says which view it is and the line above says which section, so a second one would be the same words twice. A heading inside a card is for a group of fields on a form, where there are two or more of them on a screen.
wx-rich-text is the editor — WxRichText — as a field of a screen. The value is an HTML string, it takes localized, and everything the node puts in props (placeholder, minHeight, tools) reaches the editor untouched:
{
"id": "body",
"type": "wx-rich-text",
"name": "body",
"label": "Body",
"localized": true,
"props": { "minHeight": "320px", "placeholder": "Write the article…" }
}The model
{
"title": {
"en": "Release notes",
"de": "Neuerungen"
},
"body": {
"en": "<h2>What changed</h2><p>Two languages, one editor.</p>",
"de": "<p>Zwei Sprachen.</p>"
}
}It is module-admin's and not the schema package's for two reasons, and both are things the editor does not know about. The first is language: a component of the design system carries English defaults and is translated by whoever opens it, so the toolbar's words come from the panel's own dictionary (webx-admin::rich-text.*). A module that draws an editor of its own hands it the same words with useRichTextLabels(). The second is pictures. The image button needs a library, the library is module-media, and module-admin cannot depend on it — the dependency runs the other way. So a module offers one:
export function media(): AdminModule {
return {
id: 'media',
pickImage: async () => {
const file = await openMediaPicker({ accept: 'image' })
return file ? { url: file.url, path: file.path } : null
},
}
}The panel takes the first module that has one and hands it to every editor on every screen. A panel with no file manager hands nothing, and the editor then draws no image button — it does not offer what it cannot do.
Note what the picker answers with: the address and the library's key. The key is what the document keeps.
On the server the value is checked as a string against props.maxlength, and stored through an allowlist: the tags and attributes the editor can produce survive, and a <script>, an onclick or a javascript: address does not. That is not a courtesy to the editor — the same field takes a POST that never opened one, and an agent writing through a tool never runs it at all. An emptied editor leaves <p></p> behind, which is stored as null, so "did anybody write anything" stays a check rather than a parse.
"props": { "inline": true } makes the field one line — a heading with an accent in it — and the server holds it to that: <strong>, <b>, <em>, <i> and a bare <span> survive, every attribute goes, blocks are flattened into the line with a space where each ended, and the limit is 2000 characters rather than an article's.
That happens on every save that goes through the type: a described screen (ScreenValues) and, since module-blocks puts block values through their types as well, a rich text field inside a block — whether the editor saved it or an agent wrote it through a tool.
Shortcodes in a module's texts
The site's shortcodes — [phone], [dot] — work in every text a module shows a visitor, not only in blocks. The node says which fields take them:
wx-rich-texttakes them by default: the[]button on the toolbar and suggestions after[."props": { "shortcodes": false }switches them off for a text the site does not print.wx-inputandwx-textareatake them only when asked,"props": { "shortcodes": true }: the same input is a title that becomes a slug, a code or an SEO description, where a[phone]would stay a bracket.- Leave them off titles and names (slugs,
<title>, menus, JSON-LD names), SEO fields, slugs, codes, addresses and prices.
{
"id": "summary",
"type": "wx-textarea",
"name": "summary",
"localized": true,
"props": { "rows": 3, "shortcodes": true }
}The panel asks for the list once and hands it to the core field as tokens; the flag itself never reaches the field. The site half is not optional: a field that takes shortcodes is printed through WebxUi\Admin\Shortcodes\PageText — html() for a rich text, text() for a line or a paragraph, plain() and plainFromHtml() for JSON-LD, meta tags, feeds and search indexes — on the page, on its card in a list and in its markup alike. The record keeps the bracket: the panel, the agents and the exports read what was typed.
Pictures move; documents do not
wx-media keeps the library key and never the address, so a library that moves to another disk does not rewrite a single page. A rich text field holds its pictures inside a value instead of being one, and follows the same rule one layer in: the editor writes data-wx-path beside every picture it took from the library, and the type works the address out again on every read.
That is three problems rather than one, and all three are invisible until they bite:
- the same document is deployed against different storage — a CDN on the developer's machine, the application's own disk in production;
- a private bucket answers with a signed address that expires, so any address written into a column stops working within the hour;
- editing an image writes over the same key rather than making a new one, so an address saved last week carries a
?v=stamp for the picture before the crop and a CDN will go on serving it forever.
The library is asked once per document, not once per picture, and a key it no longer knows keeps whatever address it had — a file deleted out from under a page shows one broken picture rather than refusing to render the page. A picture with no key is somebody else's and is left alone.
The src that was stored is kept beside the key as a cache, and it is the key that is the record. The panel reads values raw — it edits what is stored, not what a site would print — so the editor field refreshes the cache itself before the document reaches the editor: the module that has the library offers assetUrls beside pickImage, and a paragraph written on another host opens with its pictures pointing at this one. Opening it does not make the form dirty; saving it writes the fresh addresses back.
assetUrls: async (paths, admin) => {
const api = createMediaApi(admin)
const files = await Promise.all(paths.map((path) => api.fileByPath(path)))
return Object.fromEntries(paths.map((path, index) => [path, files[index]?.url ?? null]))
},The seam is a contract in module-admin that module-media binds (WebxUi\Admin\Contracts\AssetUrls), for the same reason as pickImage: the panel cannot depend on the module that has the files. A site with no file manager has nothing to ask, and the document is read exactly as it was written.
A module registers its own the same way — its types are merged into every panel screen, as are createAdmin({ types }) — so these are here whenever the module is installed on both halves:
| Type | From | Value | The field |
|---|---|---|---|
wx-media | module-media | MediaValue | null | one picture in a frame |
wx-gallery | module-media | MediaValue[] | a grid of thumbnails, in order |
wx-file | module-media | MediaValue | null | one file card: glyph, name, size |
wx-files | module-media | MediaValue[] | those cards a line each, in order |
wx-blocks | module-blocks | a list of blocks | the constructor |
wx-inbox-form | module-inbox | string | null | one form of the Inbox, by slug |
MediaValue is { path, alt?, title? } — the key on the media disk and this entity's own words for it. The address is worked out on read rather than stored, and the same holds for a list; see the file manager. wx-blocks is the constructor from module-blocks: on an entity's screen it is the tab that builds the content out of blocks, and inside a block's own schema it makes the type a container — see Blocks.
wx-inbox-form is a form of the Inbox, searched by its name or its slug, shown with both, and cleared by its remove button. It keeps the slug — what <x-webx-inbox::form slug="…" /> takes — so a field that used to be a plain wx-input with a slug typed in opens with the form already chosen, and nothing has to be migrated. Empty is null, no form. A slug no form has is refused when it is new, with the slugs there are in the message; one the record already held — a form deleted since — is kept and drawn as "No such form", so the record can still be saved. A form that is switched off can be chosen and is marked: the site prints nothing for it.
{ "id": "form", "type": "wx-inbox-form", "name": "form", "label": "Form" }"New" is the caller's to say: ScreenValues::validate($screen, $input, $can, held: $stored) checks only what differs from $stored, and without held the checks a type keeps for new values (ChecksNewValues — this one, a library key in wx-media) are not run at all. The settings pass what they hold; a record's form passes held: to ScreenRecord::split() when its screen has such a field.
Which half of a value is stored and which is worked out is the type's to decide, in both directions: on the server the type says what a read hands over and what a save keeps — a colour lowercased, a number that stops being the string a form sent, pasted markup through an allowlist — and the answer is taken whole, null included. The fields of a block go through the same step (Blocks). Two things are kept exactly as they came: a value whose key the schema does not name — a field dropped after the page was written — and a value of a type nobody registered, the nested tree of blocks above all.
wx-repeater is the one type with a nested model: its value is a list of records, and its children are the fields of one of them, so a name inside it is a key of the item rather than a key of the screen. WxScreenRepeater is a thin wrapper — WxRepeater draws the rows, and everything the node puts in props (title, itemLabel, min, max, collapsible, …) reaches it untouched:
{
"id": "offices",
"type": "wx-repeater",
"name": "contacts.offices",
"label": "Offices",
"props": { "itemLabel": "city", "addLabel": "Add an office" },
"children": [
{ "id": "office-city", "type": "wx-input", "name": "city", "label": "City" },
{ "id": "office-address", "type": "wx-textarea", "name": "address", "label": "Address" }
]
}On a screen the rows start folded — collapsed is on unless the node sets it to false: a list of records opened all at once is a form a kilometre long, and each header already says which record it is (#1 · …, from itemLabel). A row added now opens anyway. The words the core only has in English — add, remove, reorder, the empty text — come from the panel's dictionary (webx-admin::screens.repeater.*); a node's own addLabel and the rest win over it.
Removing a row asks first, in a popover by the bin (confirmRemove, on unless the node says false): a row is data somebody typed. A repeater whose rows stack plain fields stops at a field's width; one whose rows lay fields side by side — a wx-row among the children — or hold an editor takes the whole width.
Fields of an item can stand in columns: a wx-row of wx-col among the children, as anywhere else on a screen. A column's sm/md count the width of the row, not of the window, so a repeater in a narrow column stacks them and a wide one lays them side by side.
A condition inside a row is read against that row: { "when": "hq", "is": true } on a child asks about the item being edited, not about the screen. A type of your own can draw its children the same way — nested: true on the entry, and the component is handed node and context on top of the model binding, with WxScreenNodes to render them.
A project registers its own types the same way, under any name:
import { defineTypes } from '@webx-ui/schema'
const types = defineTypes({
map: { component: WxMapField, kind: 'field' },
'og-preview': { component: OgPreview, kind: 'display' },
})and passes them as types — or, inside a panel, as createAdmin({ types }). A TypeEntry has component, kind, an optional childrenSlot (where children without a slot go), labelProp (where the node's label goes — title on a card), and bind, a function from the node to extra props: a tab, for instance, takes its value from the node's id.
A field is wrapped in a WxFormItem, which stops its control at the width a field is read at. An entry whose control is not a field in that sense — an editor, a table, a list of blocks — says wide: true, and the form item lets it take the whole width. A text editor is not one of them: wx-rich-text stops at the column like the fields above it, and a document that needs more room opens over the whole window from its toolbar.
Two things the renderer does without being asked. A container — a card, a tab, a column — whose children are all hidden, or that was described with children: [], is not drawn: a screen can keep an empty card for a project's fields (project-fields) and nobody sees a heading over nothing. And when the page hands it errors, a refusal named by language (slug.en) is shown under the field (slug), and the tabs open the first one holding a failing field unless the tab on screen has one of its own.
Conditional visibility
"Show this when that field holds this value":
{ "id": "indexing", "type": "wx-switch", "name": "seo.indexing", "label": "Allow indexing" },
{
"id": "sitemap-url",
"type": "wx-input",
"name": "seo.sitemap-url",
"label": "Sitemap URL",
"visible": { "when": "seo.indexing", "is": true }
}The forms are { when, is }, { when, in: [...] }, { when, not }, { all: [...] } and { any: [...] }, and they nest. when is a field name; the comparison is by value, so { "is": ["a"] } matches an array field. It is evaluated on the client against the current model and works on a card or a tab as well as on a field. Anything more than "a field depends on a field" is a component of its own.
Validation and the JSON schemas
validateScreen(root) and validatePatch(patch) return a list of { path, message } — required keys, the closed key set, value types, unique ids, the shape of a condition. Empty means sound. Both halves check descriptions when they register them, and a broken description is an exception there, not a warning.
The same rules as JSON schemas, for editors that highlight as you type:
https://webx-ui.github.io/webx-ui/schema/screen.jsonhttps://webx-ui.github.io/webx-ui/schema/patch.json
{
"$schema": "https://webx-ui.github.io/webx-ui/schema/screen.json",
"screen": "settings.index",
"root": []
}They ship with the package too, as @webx-ui/schema/schemas/screen.json and patch.json.
Where the tree lives
In a panel, the reference tree of a screen lives in the module's Composer package, next to the rules for writing it and the permissions — the server needs it no less than the renderer does. The front end fetches a screen with GET /api/cms/screens/<name> when its page opens: the tree, server patches applied in order (other packages first, the project last), nodes without permission already cut out, trans:: strings already translated. Client patches from admin.ts go on top. On the server it is Screens::register, Screens::extend and Screens::rules in module-admin; the design in full is docs/architecture/WEBX_UI_SCREENS.md.