NUI contract (Lua ⇄ web)
The message contract between the Lua client and the web UI. The full source of truth is docs/ARCHITECTURE.md in the resource (§3 wire types, §4 protocol); this page summarises it and adds the decisions the web made where the contract leaves room (web/README.md). You only need it to change the web UI or write a different front end. Resources that just use menus never touch it.
The rule: navigation logic lives in the web (selection, wrap-around, hold-to-confirm, search, key repeat). Menu data, callbacks, permissions and the back stack live in Lua. The web never sees functions; Lua serializes menus into WireMenu / WireItem objects, evaluating permissions and format() on the way.
Lua → web: SendNUIMessage({ action, data })
Messages sent before the web posted ready are queued (up to 256) and flushed in order.
| action | data | meaning |
|---|---|---|
menu:open | { menu: WireMenu, direction: 'forward' | 'back' | 'none' } | Show or replace the list / grid menu, with the slide direction |
menu:close | {} | Hide it |
menu:patch | { menuId, patch: Partial<WireMenu> } | Header, preview, cart or item list changed |
menu:item | { menuId, itemId, patch: Partial<WireItem> } | One row changed (value, display, loading, …) |
input | { key, down, repeat, shift } | Walk-mode navigation from Lua controls. key: up, down, left, right, enter, back, pin, search, pageup, pagedown, home, end. enter sends down and up (hold rows) |
radial:open | { id, title, items, icon? } | Show the wheel (Lua already set focus) |
radial:close | {} | |
radial:release | {} | Hold key released: the web runs the aimed slice, or closes |
context:open | { menu: WireMenu, anchor: { x, y }, entityLabel? } | World-anchored compact menu, anchor in 0..1 screen space |
context:anchor | { x, y, visible } | Anchor moved (≤ 20 Hz, only when it moved > 0.002) |
context:close | {} | |
dialog:open | Dialog | Input or confirm dialog (Lua set focus) |
dialog:close | { id } | |
notify | { type, title, message?, icon?, tint?, duration?, app? } | Toast |
settings | { brand?, strength, material, position, density, scale, sounds, locale, focusMode } | Player / server settings |
locale | { strings } | UI strings (key hints, search, dialogs) |
clipboard | { text } | Copy text to the clipboard |
Web → Lua: fetch('https://np_menu/<name>', { method: 'POST', body })
Every body is validated in Lua, and menuId must be a menu that is currently visible.
| name | body | Lua does |
|---|---|---|
ready | {} | Sends settings + locale, flushes the queue (or re-renders after a web reload) |
select | { menuId, itemId } | Runs onSelect / opens a submenu / confirm dialog / server action. itemId: '__cart' = cart button |
change | { menuId, itemId, value } | Validates and stores the value, runs onChange, answers with a menu:item patch |
focus | { menuId, itemId } | Remembers the selection for the back stack, runs onFocus |
back | { menuId } | Pops the stack (or closes the root / context menu) |
close | { menuId?, kind?: 'menu' | 'radial' | 'context' } | Closes |
pin | { menuId, itemId } | Toggles the pin (KVP), answers with a patch |
jump | { menuId, itemId } | Deep-search result: builds the stack down to that menu and selects the row |
nuiFocus | { focus, cursor? } | Walk mode: take / release focus while typing |
radial:select | { id, itemId } | Runs the slice; submenu slices answer { ok, items } for the next ring |
radial:close | { id } | Releases focus |
dialog:submit | { id, values } | Validates and resolves the waiting inputDialog / confirm ({ confirmed: true }) |
dialog:cancel | { id } | Resolves with nil / false |
sound | { name } | PlaySoundFrontend when sounds are on (nav, select, back, error, toggle, open, close) |
settings | partial settings | Accepted for validated player-setting keys (reserved) |
Wire types
type Tint = 'neutral'|'red'|'orange'|'rust'|'pink'|'olive'|'lilac'|'green'|'blue'|'aqua'|'brand'
interface WireMenu {
id: string; title: string; subtitle?: string; icon?: string; tint?: Tint
layout: 'list'|'grid'; position: 'right'|'left'|'center'; skin: 'default'|'staff'
search: boolean; counter: boolean; canClose: boolean
crumbs: string[] // titles of the stack, root first
depth: number // 1 = root
selectedId?: string
items: WireItem[]; dock?: WireItem[]
preview?: PreviewCard; cart?: { label: string; total: string; action: string }
searchIndex?: { menuId: string; itemId: string; label: string; crumbs: string[]; icon?: string }[]
tag?: { icon?: string; label: string } // extension
}
interface WireItem {
id: string; type: ItemType; label?: string; sublabel?: string; description?: string
icon?: string; tint?: Tint; keywords?: string[]
disabled?: boolean; disabledReason?: string; loading?: boolean; badge?: number|string|true
cooldown?: { remaining: number; total: number } // ms
pinned?: boolean
// per type: rightLabel, key, variant, hold, confirm, value, display, min, max, step, bigStep, unit,
// options: { label, value }[], index, wrap, palette, inputType, placeholder, maxLength, checked, group,
// count, hasSubmenu
}
interface PreviewCard {
title: string; subtitle?: string; icon?: string; image?: string; hint?: string
stats?: { label: string; value: number /* 0-100 */; display?: string; delta?: number }[]
palette?: { label?: string; colors: string[]; selected?: number /* 1-based */ }
}
interface Dialog {
id: string; kind: 'input'|'confirm'; title: string; message?: string; icon?: string; tint?: Tint
fields?: { id: string; type: 'text'|'number'|'money'|'password'|'select'|'checkbox'|'color'|'textarea'
label: string; placeholder?: string; default?: unknown; min?: number; max?: number
required?: boolean; options?: { label: string; value: unknown }[]; hint?: string }[]
confirmLabel?: string; cancelLabel?: string; variant?: 'confirm'|'danger'
timeout?: number; hold?: number
}Web implementation notes
- Disabled rows stay selectable so the reason is visible. Enter plays the error sound.
- Optimistic values: the web updates a value immediately. A
menu:itemecho that arrives less than 250 ms after a local change with a different value is ignored as stale. Rows with a Luaformat()keep their old display text until the patch arrives. - Radial rings: going back a ring is local (no callback), so
radial:selectcan receive an item id from any ring opened sinceradial:open. After a non-submenu pick the web closes the wheel and postsradial:close. - Context menu:
backon the root postsclose { menuId, kind: 'context' }. - Focus requests: input rows, search and keybind capture post
nuiFocus { focus: true }in walk mode only, and{ focus: false }when done. Nothing is posted in'nui'focus mode. - Lists over 60 rows are virtualised.
prefers-reduced-motionis respected. - Optional extensions:
WireMenu.tag(header meta tag),radial:open.icon(hub tile),notify.app(toast head label).