# surface reference

Compose a phone controller from data. Import from `@controller-plus/sdk/surface` or `/sdk/src/surface.mjs`. See [Custom controllers](custom-controllers.md) 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](custom-controllers.md#limits). 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](custom-controllers.md#events-and-payloads), wrapped by your room's `event` callback.

## Build, inspect and select a view

<!-- example: surface -->
```js
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 }));
```
