xpage — declarative pages (.xpage)
Audience: people building tools, and the AI working on their behalf. Both read this same page.
What it is
.xpage is "RCN for a whole page": one JSON file is a node tree, containers place things and leaves draw things. It sits alongside html pages and is selected by type: "xpage" on that route in page/router.json (for how to write routes, see numable docs page).
There is exactly one reason to pick it: you want the page to speak the same visual language as the widgets. Detail pages, chart pages, tab pages, paginated lists — written this way they use the same render core, the same .rcn drawing model and the same light/dark pairs as a .xwidget. Conversely, forms, long-form text and anything you want to lay out with the DOM are less work as an html page.
Two structural differences from html pages shape how you write it:
- No WebView, and no
xbridge: fetch data by writingdependson a node to bind a.df, and handle interaction by writingeventson a node to bind an.af. @[file://…]is relative to the package root: both the.xpageand thepage/rc/*.rcnfiles it references must spell outpage/flow/x.df,page/rc/x.rcn. Writingflow/x.dfresolves to nothing — no fetch, nothing drawn, and no error.
A minimal working example
page/xpage/detail.xpage:
{
"type": "page",
"id": "detail",
"i18n": {
"zh-CN": { "empty": "还没有数据" },
"en-US": { "empty": "Nothing yet" }
},
"root": {
"type": "container",
"id": "root",
"layout": "list",
"direction": "vertical",
"padding": "16pt",
"gap": "12pt",
"paddingTop": "${@contentInset.top}pt",
"paddingBottom": "${@contentInset.bottom}pt",
"depends": [{ "flow": "@[file://page/flow/detail.df]", "params": { "id": "${id}" } }],
"events": { "onLoad": "@[file://page/flow/mark-read.af]" },
"items": [
{
"type": "Canvas",
"id": "hero",
"h": "120pt",
"params": { "title": "${title}", "sub": "${sub}" },
"canvas": { "source": "@[file://page/rc/hero.rcn]" }
},
{
"op": "forEach",
"props": { "items": "${rows}", "key": "r", "index": "i" },
"items": [
{
"type": "Canvas",
"id": "row-${r.id}",
"h": "56pt",
"params": { "label": "${r.label}", "value": "${r.value}" },
"events": { "onClick": "@[file://page/flow/open-row.af]" },
"canvas": { "source": "@[file://page/rc/row.rcn]" }
}
]
}
]
}
}
The shell and the root
The shell has only four keys, three of them required:
| Field | Required | Notes |
|---|---|---|
type |
✓ | Always "page" |
id |
✓ | Page identifier; match the route name |
root |
✓ | The content root, which must be a container |
i18n |
The page-level string table {locale: {key: string}}, used by ${@i18n.key} at the node level |
The shell has no depends / events / layout / state / width. Page-level fetching is the depends on root, the page lifecycle is the events on root, the width is always given by the container, and the presentation belongs to router.json.
The page title goes on root (optional): "title": "${detail.name}". Once the page scrolls past its header, the container shows a small title in the pill row, and this is where it comes from. It is evaluated against everything the root node can read (route params, state, data, the output of the root's depends) and recomputed on first paint, on every redraw and when the root's fetch returns, so the title follows the data; if it evaluates to empty, it falls back to name / title in the route query, then to the router.json title and the package name (see numable docs page). You can leave it out — users then see the route title after scrolling.
There are only two kinds of node:
- container:
{type:"container", id, layout, items, …}.itemsmay mix containers, leaves andopoperations (forEach/if/set). - leaves: only two —
Canvas(one piece of RCN; for how to draw seenumable docs rcn) andinput(a bare input, below).
id must be unique within the page; nodes generated by forEach stay unique through interpolation ("id": "row-${r.id}"), because a duplicate id makes a redraw land on the wrong cell.
Besides canvas.source, a Canvas leaf can carry its own canvas.depends (re-fetch only this block without disturbing the page) and canvas.refresh (timed re-fetch; for the syntax and the floor see numable docs xwidget):
{
"type": "Canvas",
"id": "quote",
"h": "96pt",
"canvas": {
"source": "@[file://page/rc/quote.rcn]",
"depends": { "flow": "@[file://page/flow/quote.df]", "params": { "code": "${code}" } },
"refresh": { "interval": ["300"] }
}
}
Making room at the top
The container chrome is a floating capsule and it reserves no space for the page. The root container has to make room itself:
{
"paddingTop": "${@contentInset.top}pt",
"paddingBottom": "${@contentInset.bottom}pt"
}
- Read
@contentInset, not@safeArea: the former is the safe area plus the chrome's footprint, the latter is the bare system safe area, which is always 0 inside an overlay widget — write that and a large screen will cover your content. For the difference between the two and the rest of the built-in variables, seenumable docs builtins. - G17 in
numable checkcatches a missing inset; if you really want content to run to the very top (a full-bleed hero header, say), write"_lintTopInset": "exempt"and state the reason in_notePad. - Each side has its own key:
paddingTop/paddingBottom/paddingLeft/paddingRight; whichever you write overridespaddingon that side. Making room at the top only touchespaddingTop— the left and right are still yours to set. - The root can't be
layout: "pager": the pager branch ignores padding entirely, so the inset you wrote has no effect — G17 catches this too. For tab paging, use alistas the root and put the pager inside it.
The eight layouts
layout is required. The default is flex, not list — a scrolling list that forgot its layout becomes a vertical flex with no spacing, which looks like "gap isn't working".
Each layout only understands its own fields; fields belonging to another layout raise no error and do nothing.
absolute — free anchoring
Children position themselves with x/y/w/h (plus r for the right edge and b for the bottom) and support geometry expressions: {parent.w}, sibling anchors {sibling_id.r}, arithmetic, and pt/px.
{
"type": "container",
"id": "hero",
"layout": "absolute",
"h": "220pt",
"items": [
{ "type": "Canvas", "id": "bg", "x": "0pt", "y": "0pt", "w": "{parent.w}", "h": "{parent.h}", "canvas": { "source": "@[file://page/rc/bg.rcn]" } },
{ "type": "Canvas", "id": "badge", "x": "{bg.r}-72pt", "y": "16pt", "w": "56pt", "h": "24pt", "canvas": { "source": "@[file://page/rc/badge.rcn]" } }
]
}
{parent.h} is only available when the parent's height is known (the parent declared h, or the parent is the page root). If the parent's height is content-driven, referencing it can't be resolved and falls back to auto.
stack — overlay
Children cover each other, items order is bottom to top, and positioning comes from align on the child — no coordinates.
{
"type": "container",
"id": "card",
"layout": "stack",
"h": "160pt",
"items": [
{ "type": "Canvas", "id": "bg", "align": "fill", "canvas": { "source": "@[file://page/rc/bg.rcn]" } },
{ "type": "Canvas", "id": "tag", "align": "bottom|right", "w": "88pt", "h": "28pt", "canvas": { "source": "@[file://page/rc/tag.rcn]" } }
]
}
align is matched as a substring, not an enum, and three rules cover it:
| Written | Effect |
|---|---|
fill |
Fills both axes; once it fills, center / right / bottom all stop mattering |
| Horizontal | center centers, right right-aligns; when both are present right wins; neither means left |
| Vertical | bottom aligns to the bottom, otherwise the top — there is no vertical centering, center only affects the horizontal axis |
The default is top|left|fill. Because matching is by substring, words like middle and middleCenter mean nothing at all and silently fall back to the default. A child that isn't fill must write its own w, or it gets stretched to the full width.
flex — sequential placement
Only two fields do anything: direction (vertical by default / horizontal) and gap. Horizontally, children use their own w, and share the space equally if they don't declare one.
{
"type": "container",
"id": "tabbar",
"layout": "flex",
"direction": "horizontal",
"padding": "12pt",
"gap": "8pt",
"items": [
{ "type": "Canvas", "id": "tab0", "w": "112pt", "h": "44pt", "params": { "label": "Quotes" }, "events": { "onClick": "@[file://page/flow/tab0.af]" }, "canvas": { "source": "@[file://page/rc/tab.rcn]" } },
{ "type": "Canvas", "id": "tab1", "w": "112pt", "h": "44pt", "params": { "label": "Watchlist" }, "events": { "onClick": "@[file://page/flow/tab1.af]" }, "canvas": { "source": "@[file://page/rc/tab.rcn]" } }
]
}
wrap / justify / align, and flexGrow / flexShrink / flexBasis on children, are not implemented and do nothing. For wrapping use flow; for centering, compute w and padding yourself.
flow — wrap when full
For tags, chips and other naturally flowing blocks. Fields: itemSpacing (spacing between items, default 8) and lineSpacing (spacing between lines, default 8). Children declare their own w (80 is assumed when absent) and wrap to the next line when they no longer fit.
{
"type": "container",
"id": "tags",
"layout": "flow",
"itemSpacing": "8pt",
"lineSpacing": "8pt",
"items": [
{ "type": "Canvas", "id": "t1", "w": "72pt", "h": "28pt", "params": { "label": "A-shares" }, "canvas": { "source": "@[file://page/rc/tag.rcn]" } },
{ "type": "Canvas", "id": "t2", "w": "96pt", "h": "28pt", "params": { "label": "HK stocks" }, "canvas": { "source": "@[file://page/rc/tag.rcn]" } }
]
}
lineAlignment and maxLines are not implemented.
list — single-axis collection
The one you'll use most, and the default choice for a page root.
| Field | Notes |
|---|---|
direction |
vertical (default) / horizontal, which sets the scroll axis |
itemSpacing |
Spacing between items, takes precedence over gap; gap only applies when it is absent (two names, one meaning) |
edgeInsets |
Extra space at each end, applied once at both ends of the main axis, on top of padding |
snap |
true = snap to an item's start on release; only meaningful when this list is itself the scroll source |
h |
Vertical: declaring a fixed height makes this list its own scroll source (it always occupies h from the outside and scrolls internally when the content overflows); omitting it makes the list as tall as its content and leaves scrolling to the page |
A horizontal band:
{
"type": "container",
"id": "band",
"layout": "list",
"direction": "horizontal",
"h": "120pt",
"itemSpacing": "8pt",
"edgeInsets": "16pt",
"snap": true,
"items": [
{ "type": "Canvas", "id": "s1", "w": "140pt", "h": "120pt", "params": { "label": "1" }, "canvas": { "source": "@[file://page/rc/slot.rcn]" } },
{ "type": "Canvas", "id": "s2", "w": "140pt", "h": "120pt", "params": { "label": "2" }, "canvas": { "source": "@[file://page/rc/slot.rcn]" } }
]
}
Children of a horizontal list take their own w (omit it and one item fills the screen), the content scrolls horizontally when it overflows, and it scrolls without requiring an h — the "needs a fixed height to scroll" rule is vertical-only. ⚠️ A horizontal list does not support onReachEnd, so there is no horizontal infinite scroll.
pager — paging
The paging axis is always horizontal. Each page is a container, and a page runs its own depends only when you first turn to it, so "one data set per page" needs no nested pages.
| Field | Notes |
|---|---|
h |
Declare it explicitly; 400 is assumed otherwise |
pageSize |
Page width; when it is smaller than the container, the current page is centered and the neighbors peek out on both sides (a carousel). Omitted or wider than the container means one full-width page |
pageSpacing |
Spacing between pages, default 0; at full width you only see this gap during the swipe |
loop |
true = wrap around from the last page to the first, effective only with more than two pages |
pageCacheCount |
How many pages to pre-render on each side of the current one, default 1; 0 = only the current page. It's a page count, not a length |
scrollEnabled |
false = no swiping, pages can only be changed by the controlled page |
page |
The controlled page index, written as an expression ("${activeTab}"); tapping a tab changes state and turns the page |
{
"type": "container",
"id": "pager",
"layout": "pager",
"h": "300pt",
"pageSize": "280pt",
"pageSpacing": "12pt",
"loop": "true",
"page": "${activeTab}",
"events": { "onPageChange": "@[file://page/flow/tab-sync.af]" },
"items": [
{ "type": "container", "id": "p0", "layout": "list", "depends": [{ "flow": "@[file://page/flow/p0.df]", "params": {} }], "items": [] },
{ "type": "container", "id": "p1", "layout": "list", "depends": [{ "flow": "@[file://page/flow/p1.df]", "params": {} }], "items": [] }
]
}
The payload of onPageChange is @event.page, always the logical index 0..n-1 (loop included). direction: "vertical" and initialPage are not implemented; set the initial page with the page expression. loop and pageCacheCount take effect on a device; the local preview doesn't simulate either of them.
grid — equal-width grid
| Field | Notes |
|---|---|
columnCount |
Column count, must be a number ("2" is read as absent and falls back to the default of 2) |
columnSpacing / rowSpacing |
Column spacing / row spacing |
{
"type": "container",
"id": "grid",
"layout": "grid",
"columnCount": 2,
"rowSpacing": "12pt",
"columnSpacing": "12pt",
"items": [
{ "type": "Canvas", "id": "g1", "h": "88pt", "canvas": { "source": "@[file://page/rc/cell.rcn]" } },
{ "type": "Canvas", "id": "g2", "h": "88pt", "canvas": { "source": "@[file://page/rc/cell.rcn]" } }
]
}
The container computes the column width ((content width − spacing) / columns), and children get stretched to it whether or not they declare a w; every row is as tall as its tallest item. itemAspectRatio and crossAxisAlignment are not implemented.
waterfall — ragged-height masonry
Same fields as grid (columnCount / columnSpacing / rowSpacing), except each child keeps its own height and a new item always lands in whichever column is currently shortest. The column assignment strategy isn't configurable; balanceStrategy does nothing.
{
"type": "container",
"id": "feed",
"layout": "waterfall",
"columnCount": 2,
"columnSpacing": "12pt",
"rowSpacing": "12pt",
"items": []
}
Control flow: op
Besides XContainer and Canvas, a node array can hold op nodes: an op renders nothing itself, it only decides which nodes this stretch expands into, and with what variables. Its children go in items (not the react that RCN uses).
There are five, and they behave identically on iPhone, Android, HarmonyOS and desktop:
op |
props | What it does |
|---|---|---|
forEach |
items array · key (variable name for the current element, defaults to item) · index (variable name for the position, defaults to index) |
Expands the stretch in items once per array element |
for |
from · to · step (defaults to 1) · index (defaults to index) |
Expands a fixed number of times. Half-open: from is included, to is not; a negative step counts down |
if |
cond · else (a node array, optional) |
Expands items when the condition holds, otherwise else |
set |
name · value |
Computes a variable for the siblings that follow it |
remove |
cond |
Drops this whole stretch when the condition holds |
{
"op": "forEach",
"props": { "items": "${rows}", "key": "r", "index": "i" },
"items": [
{ "type": "Canvas", "id": "row-${r.id}", "h": "56pt",
"params": { "label": "${r.label}", "n": "${i}" },
"canvas": { "source": "@[file://page/rc/row.rcn]" } }
]
}
⚠️ if takes its condition in cond — this is not the same thing as the op:if in a data flow or an action flow, which reads props.val. Getting them the wrong way round raises no error, and breaks differently in each direction: writing val in a .xpage leaves cond empty, and an empty condition counts as true — so the stretch expands every time and else is never reached, which looks like "the condition does nothing"; in a flow, writing cond means you never branch at all. The rule is one line: write cond in a .xpage, and val in a .df / .af.
⚠️ For the same reason, a cond that cannot be evaluated counts as true (as does leaving cond out entirely). So "cond": "${maybeMissing}" needs that key to be present: an empty string reads as false, while a missing key reads as true — the two kinds of "empty" give opposite answers.
⚠️ for takes from / to / step, not the count that RCN's for uses. The same word means different things in the two places; do not copy one into the other.
⚠️ set changes what follows it, not what is inside it: it merges the variable into the scope of this level, so every node after it — items included — can read it, while nodes written before it cannot.
⚠️ Loop variables are gone once the stretch is expanded: key and index exist only during expansion and are not kept on the node tree. To use one inside an event flow, pass it in through params at expansion time ("n": "${i}" above).
⚠️ Put the loop variable in the id ("id": "row-${r.id}"): duplicate ids on one page make a redraw land on the wrong row.
Page scopes: params / state / data
When reading values there is one merged scope. Write ${rows}, not ${params.rows}. At runtime the six levels below are searched top down, and the first hit wins:
| Priority | Source |
|---|---|
| 1 | Local variables of an op operation (forEach's key / index, set's name) |
| 2 | This node's depends output |
| 3 | This node's explicit params |
| 4 | Params inherited down the parent chain (already including every ancestor's depends output; nearer wins) |
| 5 | Page state |
| 6 | Global data |
The write side, in contrast, is strictly three separate scopes:
| Scope | Who writes it | Direction | Typical use |
|---|---|---|---|
params |
Fetching and author declarations, read-only | Top down only | List data, widget inputs |
state |
xpage.setState / xpage.patchState in an .af |
Any direction within the page | Selected tab, expanded state, accumulated pages |
data |
data.set and friends in an .af |
Across pages | The user's watchlist, settings |
state is the only channel that carries values sideways or upward within a page: a child taps and sets state.selectedId, and a sibling reads ${selectedId} to highlight itself. It isn't declared in the .xpage file and is empty when you enter the page; all the pages of a pager share one state.
When names really do collide, disambiguate with a prefix: ${state.tab} / ${params.tab} / ${data.tab}.
The two state-writing actions behave very differently:
xpage.setState— wholesale replacement; keys you didn't include this time are cleared;xpage.patchState— a shallow merge that only overwrites the keys you list.
Use patchState for everyday appending and tab switching; save setState for when you genuinely want to wipe and start over. After changing state, remember to follow it with a redraw (below).
⚠️ Referencing a key that doesn't exist raises no error — the literal string is passed straight through. A title rendered as Box ␣␣ or a date showing 1970 is almost always this. When something renders oddly, check whether the first character is $.
depends and the three states
depends can go on any node, not just the root:
{
"depends": [
{ "flow": "@[file://page/flow/quote.df]", "params": { "code": "${code}" } },
{ "flow": "@[file://page/flow/news.df]", "params": {} }
]
}
- It must be a
{flow, params}object, even whenparamsis empty. A bare string swallows the input parameters, the flow still reports success, and the page renders a screen full of--(checkG12 catches it). - Multiple entries in the array run concurrently and merge in declaration order; branching, retries and serial dependencies all belong inside a single
.df(seenumable docs df). - The output merges straight into that node's params, and the whole subtree can read it.
Every node with a depends is an async boundary with three states:
| State | When | What it renders |
|---|---|---|
| loading | Fetching and this node has never had successful data | The app's built-in skeleton (laid out to match this node) |
| error | Any flow failed and there is still no successful data | The app's built-in error message plus retry |
| loaded | Everything succeeded | The normal content |
The phrase that matters is "has never had successful data": once data exists, a later refresh keeps the current content, fetches in the background and swaps it in — no skeleton flash; a failure keeps the old data rather than switching to an error page. So nested boundaries fill in block by block instead of making the whole page wait on the slowest flow.
The app draws both of these states for you — you don't write them and can't: a loading / error field on a node has no effect, and check flags it with G1c so you remember to remove it.
The skeleton doesn't appear the moment a fetch starts, either: if the result comes back within 150ms it is shown straight away, without a single skeleton frame; only after 150ms does the skeleton fade in, and once shown it stays for at least 300ms before cross-fading into the result. So on pages that hit the local cache or call a fast API, users never see a skeleton at all.
A node with a depends must have a height that can be computed before the fetch (a hard-coded h, or one given by the parent). Otherwise the height changes when loading finishes and the whole page jumps.
Events
Values are always standard bindings: "@[file://page/flow/x.af]", {"flow": "…", "params": {…}}, or an inline array of actions.
| Event | Where it goes | Payload | When it fires |
|---|---|---|---|
onLoad |
Any node | none | After this node first reaches loaded. Exactly once per lifetime |
onRefresh |
root | none | The user pulls down. Declaring it takes over pull-to-refresh completely |
onReachEnd |
Vertical list / waterfall | none | The content scrolls to the bottom |
onPageChange |
pager | @event.page |
A page turn completes |
onClick |
Any node | @event.id |
A tap |
longClick |
Any node | @event.id |
A long-press. Write it and the same node's menu never opens |
onChange / onSubmit / onBlur / onFocus on an input |
input | @event.value (none for onFocus) |
See below |
The timing of the three data events, pinned down in one table:
| Action | depends | onLoad | onRefresh |
|---|---|---|---|
| Entering the page for the first time | All run | Runs once | — |
Pull down, page has no onRefresh |
All run | Doesn't run | — |
Pull down, page has an onRefresh |
None run | Doesn't run | Runs |
| Refresh from the container menu | All run | Runs again | — |
| Retry on the error page | Only that one node's | Doesn't run | — |
xpage.reloadPage |
All run | Doesn't run | — |
xpage.reenterPage |
All run | Runs again | — |
| The user switches the app language | All run | Doesn't run | — |
xpage.setState / patchState + redraw |
Don't run, existing data is used | Doesn't run | — |
Two easy traps follow from that:
- Once
onRefreshexists, pulling down no longer re-fetches automatically, even when the root has adepends. If you want to reset state and re-fetch, add an explicitxpage.reloadPageat the end of that.af. onLoadruns only once. For "start over every time I come back to this page", usexpage.reenterPage— don't count ononLoad.
The four redraw / reload actions, by strength (the full table is in numable docs af):
| Action | What it does |
|---|---|
xpage.redraw |
Redraws only the node named by id, no re-fetch |
xpage.redrawPage |
Redraws the whole page, no re-fetch |
xpage.reloadPage |
Soft reload: re-runs every depends, onLoad does not run again, state is kept |
xpage.reenterPage |
Hard reload: as if closed and reopened — state cleared, onLoad runs again |
Infinite scroll is the standard combination of those four: put onReachEnd on the list → fetch the next page in the flow → xpage.patchState to append the new data to ${items} → xpage.redraw to redraw just that list. Don't reload the page — it flashes, and it re-runs every depends.
Pagination also needs loadMore declared on the same list, or the reach-end event never fires (hasMore defaults to false):
{
"type": "container",
"id": "rows",
"layout": "list",
"direction": "vertical",
"events": { "onReachEnd": "@[file://page/flow/next-page.af]" },
"loadMore": { "hasMore": "${hasMore}", "noMoreText": "No more items" },
"items": []
}
There is one more switch on root: "refresh": false = don't attach a pull-to-refresh header. A root with neither a depends nor an onRefresh has none anyway, so you don't need to write it.
visible
visible accepts true / false or an expression; absent means visible.
{
"type": "Canvas",
"id": "k90",
"visible": "$[if::(eq::(${seg},90),1,0)]",
"h": "180pt",
"canvas": { "source": "@[file://page/rc/k90.rcn]", "depends": { "flow": "@[file://page/flow/k90.df]", "params": { "n": 90 } } }
}
A node with visible: false isn't in the tree at all: it takes no space, doesn't render, and its depends doesn't run either. It fetches for the first time only once state changes — which is exactly how three mutually exclusive chart ranges avoid two requests nobody is going to look at.
⚠️ The test is strict: only true, 1 or a non-zero number counts as visible, and everything else is treated as hidden. So referencing a key that doesn't exist (leaving the literal ${…} string) makes the whole node disappear with no error. The safe form is $[if::(…,1,0)], which explicitly produces 1 or 0.
enabled is a different matter: it does get evaluated, but nothing consumes it, so writing it won't disable events. To make a node untappable, don't give it events.
menu
A menu array on any node means a long-press opens a declarative menu there. For the fields and their precedence see the "long-press node menu" section of numable docs page; only two things are specific to xpage:
flowis required;label/icon/roleare optional.- A
${@i18n.key}insidelabelis looked up in this page's top-leveli18ntable, and renders as an empty string when it isn't found (checkG35 catches it).
The input node
input is the second kind of leaf and handles exactly three things: text editing, the keyboard, and the caret. It has no visuals of its own — no border, no background, no focus highlight. Visuals come from layering: put a Canvas underneath to draw the rounded background and border, and lay the input on top.
{
"type": "input",
"id": "kw",
"x": "28pt",
"y": "16pt",
"w": "276pt",
"h": "44pt",
"props": {
"bindKey": "kw",
"hint": "Search…",
"hintColor": "#999999|#8E8E93",
"inputType": "text",
"fontSize": 16,
"color": "#1C1C1E|#F2F2F7",
"caretColor": "#128F66|#6FE8BE",
"changeFireOn": "change",
"changeDebounce": 300
},
"events": { "onChange": "@[file://page/flow/search.af]" }
}
w and h must be written explicitly (a single-line input does not size its height to its content).
| props | Notes |
|---|---|
bindKey |
The page state key the value is written back to; defaults to the node id |
value |
The initial value, written as ${…}. Uncontrolled: it is not pushed back in afterwards, only input.setValue overwrites it |
hint / hintColor |
The placeholder and its color; the color supports a light|dark pair |
inputType |
text / number / decimal / password / email / phone / url, which picks the keyboard layout |
secure |
true is equivalent to inputType: "password" |
lines |
1 = single line (default); N = a multi-line box fixed at N lines |
maxLength |
The length limit. Characters mid-composition (e.g. pinyin input) don't count |
fontSize / color / align / typeface |
Typography; color supports a light|dark pair |
caretColor |
The caret color — the one visual property that was kept |
autoFocus |
Focus and raise the keyboard on mount |
clearButton |
A trailing clear button |
returnKey |
done / send / search / next / go, the look and meaning of the return key |
keyboardToolbar |
done = add a "Done" bar above the keyboard |
contentType |
Tells the system what this field holds so password managers and one-time-code autofill can hook in. Use the platform autofill identifiers (username · current-password · new-password · one-time-code · email · tel …); leave it out and there is no autofill |
changeFireOn |
When onChange fires: change (default, debounced) / blur / submit |
changeDebounce |
The debounce in milliseconds for change mode, default 300 |
The value lives in page state, and the write-back is silent: every keystroke writes into state[bindKey] for other nodes to read, but triggers no redraw (otherwise every keystroke would rebuild the whole tree and yank the box you are typing in out from under you). For anything reactive — a focus highlight, a character counter — call xpage.patchState + xpage.redraw explicitly in the onChange / onFocus flow.
Imperative control goes through five actions, all taking the input node's id: input.focus / input.blur / input.clear / input.selectAll / input.setValue (which also takes value and updates the state key that bindKey points at).
If what you want is "pop something up and ask for one value" rather than a box living on the page, don't use an input node — use singleValue / .xform (see numable docs params).
Fields that do nothing
These names are legal, raise no error, and have no effect whatsoever. They turn up most often in pages copied from somewhere else:
| Field | Written on | For that effect |
|---|---|---|
virtualization |
list / grid / waterfall | No substitute; a list of a few dozen items doesn't need it |
preloadCount |
list / waterfall | Same as above |
scrollEnabled |
list / waterfall (it does work on pager) | A vertical list without a fixed h won't scroll on its own |
direction: "vertical" |
pager | The paging axis is always horizontal |
initialPage |
pager | Use the controlled page expression |
onScroll |
Collection containers | No substitute |
onVisible / onHidden |
Any node | To refresh on returning from a subpage, call xpage.reloadPage from the continuation of the flow that opened it |
enabled |
Any node | Don't attach events |
wrap / justify / align |
flex containers | Use flow for wrapping; compute w and padding yourself for alignment |
flexGrow / flexShrink / flexBasis |
Children of a flex | Hard-code w |
lineAlignment / maxLines |
flow | No substitute |
balanceStrategy |
waterfall | It is always "land in the shortest column" |
itemAspectRatio / crossAxisAlignment |
grid | Hard-code h on the children |
navStyle |
route / page | The chrome is always the floating capsule and isn't configurable |
Rules (breaking one means rework)
| Rule | How it's checked | Symptom when broken | Fix |
|---|---|---|---|
The root container's paddingTop must reference ${@contentInset.top} |
check G17 |
The top of the page sits under the floating capsule, with no error on any platform | Write "paddingTop": "${@contentInset.top}pt" on the root; for a deliberate full-bleed page write _lintTopInset: "exempt" and explain it in _notePad |
The root can't be layout: "pager" |
check G17 |
The inset has no effect and the top is still covered | Wrap it in a list root |
depends can't be a bare string |
check G12 |
The input parameters get swallowed, the flow still reports success, and the whole page renders -- |
Write {"flow": "…", "params": {"k": "${k}"}} |
A ${@i18n.key} at the node level (params / props / menu[].label) must exist in this page's top-level i18n table |
check G35 |
That string renders empty, as if you forgot it | Add it to the page table; strings drawn inside a Canvas still live in each .rcn's own table |
An events value can't start with ${ |
check G36 |
The dispatcher classifies by first character before interpolating, and a string starting with an interpolation looks like none of the categories → taps do nothing | Put the known prefix outside the interpolation: "/detail?id=${id}" |
@[file://…] in the .xpage and in page/rc/*.rcn is relative to the package root |
Manual review | It resolves to nothing: no fetch, nothing drawn, no error | Spell out page/flow/x.df, page/rc/x.rcn |
On a page with route inputs, don't add a same-named params fallback on the root node |
Manual review | You tap A and see B, with no error — the root params override the route query | Delete the same-named fallback on the root, and compare two queries side by side |
Every node id is unique within the page, interpolated inside forEach |
Manual review | Redraws land on the wrong cell, and the once-only onLoad test crosses wires |
"id": "row-${r.id}" |
A node with a depends must have a height determinable before the fetch |
Manual review | The whole page jumps the moment the data arrives | Hard-code h, or let the parent give it |
If you attach onReachEnd, you must supply loadMore.hasMore |
Manual review | Scrolling to the bottom does nothing | Add "loadMore": {"hasMore": "${hasMore}"} and maintain that key in the flow |
When something breaks
| Symptom | Most likely cause | What to do first |
|---|---|---|
| The whole page is blank with no error | The route type isn't xpage (any unknown value is treated as html); or the entry path is wrong |
Check those three route fields |
| The top of the page is under the capsule | The root has no top inset, or the root is a pager | Run numable check and look for G17 |
List items are packed together and gap looks broken |
The container has no layout, so it defaulted to flex |
Add "layout": "list" |
You wrote itemSpacing and nothing changed |
The container isn't a list (no other layout knows that name) |
Switch to gap, or make the container a list |
| A node vanished entirely | The visible expression evaluated to something other than true/1, including a reference to a key that doesn't exist |
Use $[if::(…,1,0)] to produce 1/0 explicitly |
Text renders as a literal ${xxx} |
That key doesn't exist at any of the six scope levels | Check the output key names of the .df; when something renders oddly, check whether the first character is $ |
| One string is blank | A node-level ${@i18n.key} isn't in the page table |
Run numable check and look for G35 |
| Nothing changes after pull-to-refresh | You wrote onRefresh, it has taken over the pull, and no depends runs |
Add xpage.reloadPage at the end of that .af |
| Coming back from a subpage, the page data doesn't move | You counted on onVisible (not implemented) |
Call xpage.reloadPage from the continuation of the flow that opened the subpage |
| Scrolling to the bottom doesn't load the next page | loadMore.hasMore is missing, or it's a horizontal list |
Add loadMore; horizontal lists have no reach-end event |
| You switched tabs but the page didn't move | You called patchState without a redraw |
Follow it with xpage.redraw (or redrawPage) |
| You type in the input but other places still read the old value | The write-back is silent and never redraws on its own | Call patchState + redraw in the onChange flow |
Related
numable docs page— routes, the three page types, the long-press node menu, navigation and external linksnumable docs af— the full table ofxpage.*/input.*actions and how to write interaction flowsnumable docs rcn— how to draw the.rcninside a Canvas