surface reference
Compose a phone controller from data. Import from @controller-plus/sdk/surface or /sdk/src/surface.mjs. See Custom controllers for every part option, event payload and complete game examples.
Builders
Builders return plain objects without validation. Options are copied into the part; use normalizeSurface before doing layout yourself. Hosts select a compatible view, and clients normalize it before drawing.
| Full signature | Return |
|---|---|
surface({ id, root, turn = null, accent = null, minApp = null }) |
{ kind: 'surface', surface: { v: 1, id: String(id), root, turn?, accent? }, minApp? }. Truthy optional fields are included. minApp is metadata; viewFor does not enforce it. Use room requires for app gating. |
row(children, options = {}) |
{ type: 'row', children, ...options }, horizontal split. |
column(children, options = {}) |
{ type: 'column', children, ...options }, vertical split. |
spacer(options = {}) |
{ type: 'spacer', ...options }, empty layout space. |
lcd(options = {}) |
{ type: 'lcd', ...options }, screen with text, timer, entry and other indicators. |
keys(list, options = {}) |
{ type: 'keys', keys: list, ...options }, tray of touch keys. |
canvas(options = {}) |
{ type: 'canvas', ...options }, bounded drawing paper. |
keyboard(options = {}) |
{ type: 'keyboard', ...options }, drawn text input. |
trackpad(options = {}) |
{ type: 'trackpad', ...options }, relative pointer/click/scroll input. |
drawingTools({ colors = [], widths = [], undo = true, clear = true } = {}) |
Key array: ink-N color toggles, width-N width toggles, optional undo/clear. First color and middle width start selected. |
SURFACE_VERSION is 1. SURFACE_APPS is frozen { ios: '1.0.0', android: '1.0.0' }. These are feature minimums, not a claim about installed versions.
Normalization
normalizeSurface(input): SurfaceDescription | null accepts the inner { v, id, root, turn?, accent? }, not the outer view. It returns a clean copy, drops unknown nodes/options, clamps numbers and truncates text. Wrong version, missing root, or a wholly undrawable tree returns null. It is not a transport size validator or a sanitizer for arbitrary event payloads: a key's object payload is retained. Use JSON-safe trusted descriptions.
The budget is 160 nodes plus keys, depth 0–6, 12 children per group and 32 keys per tray. See all normalized bounds. Malformed non-JSON values can still raise ordinary JavaScript errors; no custom error class is exported.
Layout helpers
These operate on a normalized description. Rectangles are [x, y, width, height] in design units. A rendered phone uses a 450-unit design height and a screen fit that accounts for safe areas.
| Full signature | Parameters and return |
|---|---|
layoutSurface(desc, fit) |
fit: { size: [W, H], side, bottom }; returns { content: Rect, parts: [{ node, rect, path, menu? }] }. Rows/columns allocate fixed sizes before weighted space; canvas preserves aspect. path is child indices joined by dots. |
keyGrid(part, rect) |
Keys node and tray slot; returns [{ key, rect }]. Auto-selects columns when absent. Uses 12-unit inner padding and gaps. Round keys cap at 96 units, or 190 for a lone buzzer; screen keys cap at 132. |
keyTray(part, rect): Rect |
Bounding tray around the key cluster with 12-unit padding. Empty grid returns input rect. |
hitSurface(layout, point) |
Return first leaf part containing [x, y], or null. Despite the helper's name, it does not identify an individual key; use keyGrid for that. |
timerLeft(timer, receivedAt, now): number |
timer.left is seconds; timestamps are milliseconds. Subtract elapsed time, clamped at zero. |
The content box starts at [fit.side + 12, 14], with bottom keep-out fit.bottom + 12. Include an LCD: the topmost, rightmost LCD receives the single MENU key. Excessively large fixed sizes or gaps can produce unusable layouts; normalization is not a guarantee that arbitrary trees fit. Keep controls few and inspect target screen sizes.
Compatibility helpers
| Full signature | Behavior |
|---|---|
supportsSurfaces(app): boolean |
true for no app/platform, web, or browser; iOS/Android require the minimum in SURFACE_APPS. Unknown platforms return false. |
viewFor(view, app) |
For a surface-capable app, strip legacy from a surface. For an old app, return view.legacy or a waiting view asking for an update. For other view kinds, use asSurface if supported; otherwise pass through. |
asSurface(view) |
Convert waiting and choices into surfaces with legacy: view; pass other kinds through. Choices cap at 12 and emit { target: choice.id } under choice.event || 'choice'. submitted: true replaces controls with an LCD. |
These helpers have no events or I/O. Builders and layout do not emit player input. The phone renderer emits custom-controller events, wrapped by your room's event callback.
Build, inspect and select a view
import { surface, column, row, lcd, keys, canvas, keyboard, trackpad, spacer,
drawingTools, normalizeSurface, layoutSurface, keyGrid, keyTray, hitSurface,
timerLeft, supportsSurfaces, viewFor, asSurface, SURFACE_APPS, SURFACE_VERSION
} from '@controller-plus/sdk/surface';
const view = surface({ id: 'draw/1', root: column([
lcd({ size: 92, title: 'Draw a star', timer: { left: 30, total: 30 } }),
row([keys(drawingTools({ colors: ['#292d37'], widths: [2, 4, 8] }), { size: 180 }),
canvas({ aspect: 1.5 })]),
]) });
view.legacy = { kind: 'waiting', title: 'Drawing', text: 'Use a browser to draw' };
const desc = normalizeSurface(view.surface);
const layout = layoutSurface(desc, { size: [940, 450], side: 0, bottom: 0 });
const tray = layout.parts.find(p => p.node.type === 'keys');
console.log(keyGrid(tray.node, tray.rect), keyTray(tray.node, tray.rect));
console.log(hitSurface(layout, [20, 20]), timerLeft({ left: 30 }, 1000, 2000));
console.log(viewFor(view, { platform: 'ios', version: '0.1.0' }).kind); // waiting
console.log(supportsSurfaces({ platform: 'web' }), SURFACE_APPS, SURFACE_VERSION);
console.log(asSurface({ kind: 'waiting', title: 'Lobby', text: 'Ready' }));
console.log(keyboard({ charset: 'text' }), trackpad(), spacer({ size: 20 }));