# Surface support

This table describes the source checked on 6 November 2026. It covers custom `surface` views. It does not certify an installed app or a physical device.

All three clients draw controls in the player's finish. They use painted keys and text. They do not open a system keyboard or render HTML.

“Yes” means the code draws or handles the field. “Stored” means it accepts the field but does not use it for that task. Bug numbers refer to [Known gaps](#known-gaps).

## Surface and layout

| Node or option | Browser controller | iOS | Android |
| --- | --- | --- | --- |
| `v: 1`, `root` | Required. Unknown nodes are dropped. | Same. | Same. |
| Surface `id` | Resets text, toggles and strokes, and cancels touches (held buttons let go). | Resets local state and cancels touches. | Resets local state and cancels touches. |
| `turn.you` | Blinking LCD lamp and shell glow. | Same, plus feedback on a new turn. | Same, plus feedback on a new turn. |
| `turn.name`, `turn.color` | Default turn heading and lamp/glow colour. | Same. | Same. |
| `accent` | Keyboard submit-key colour. Selection rings keep their own colour. | Same. | Same. |
| `minApp` | View metadata. No renderer version gate. | Same. | Same. |
| `legacy` | Host selects a fallback before delivery. | Same. | Same. |
| `row`, `column`, `children` | Horizontal or vertical split. First 12 children. | Same. | Same. |
| Part `id` | Stored. Child paths locate parts. | Same. | Same. |
| `weight` | Share of free space, 0–50. | Same. | Same. |
| Part `size` | Fixed extent on the parent axis, 8–2000. | Same. | Same. |
| Group `gap` | Space between children, 0–80, including updates. | Yes, including updates. | Yes, including updates. |
| `spacer` | Empty space. No touch target. | Same. | Same. |
| Automatic MENU key | On the topmost LCD; rightmost breaks a tie. | Same. | Same. |

`minApp` does not replace room `requires`. A surface needs an LCD to get an automatic MENU key. The first surface app versions are listed in `SURFACE_APPS`; this table adds no new version promise.

## Key shapes

| `keys.shape` | Browser controller | iOS | Android |
| --- | --- | --- | --- |
| `round` | Circular rubber key. A lone key can grow to buzzer size. | Same. | Same. |
| `square` | Rectangle with capped height. | Same. | Same. |
| `tile` | Broad rectangle with rounded corners. | Same. | Same. |
| `screen` | Square glass display in a rubber bezel. | Same. | Same. |
| `paddle` | Full-height rectangle. Label, colour, badge and selected ring work. | Same. | Same. |

All shapes handle the same key events and buttons.

## Key options

| Option | Browser controller | iOS | Android |
| --- | --- | --- | --- |
| `keys.keys` | First 32 keys, within the shared node budget. | Same. | Same. |
| `keys.columns` | 1–8, or automatic packing, including updates. | Yes, including updates. | Yes, including updates. |
| Key `id` | Event/toggle identity. Missing IDs become `key-N`. | Same. | Same. |
| `label` | Printed on every shape. `screen` also supports a caption below an icon. | Same. | Same. |
| `icon` | All names in the [icon list](custom-controllers.md#icons). | Same, using `SurfaceGlyphs.swift` too. | Same. |
| `label` with `icon` | Both on `screen`. Other shapes draw the icon alone. | Same. | Same. |
| `color` | Tints round, square, tile and paddle keys. Tints screen contents. | Same. | Same. |
| `fill` | Fills `screen` glass when `color` is valid. Ignored on other shapes. | Same, including updates. | Yes on `screen`. Ignored on other shapes. |
| `badge` | Uppercase pill on `screen` and `paddle`. Trimmed to 12 UTF-16 units. | Same, including updates. | Same. Width is uncapped. B5. |
| `badgeTone` | `alert`: red. `good`: green. Omit for dark. | Same. | Same. |
| Badge on `round`, `square`, `tile` | Stored but not drawn. | Same. | Same. |
| `selected` | Ring on every shape. Used initially for a toggle; otherwise follows the host. | Same. | Same. |
| `disabled` | Dimmed; presses ignored. | Same, for touch and VoiceOver. | Dimmed; presses ignored. |
| `event`, `payload` | Release on the same key sends the event and payload. | Same. | Same. |
| `button` | Standard button 0–15 stays down while held. | Same. | Same. |
| `toggle` | Local selection group. Same-ID views keep the local choice. | Same. | Same. |
| Non-tool `value` | Stringified during normalization. Used as a confirm target. | Same. | Same. |
| `send` | Sends the selected value/ID as `target`. Disabled until a choice exists. | Same. | Same. |
| `gestures: ['hold']` | Event at 450 ms. Suppresses the release event. | Same. | Same. |
| `gestures: ['double']` | Delays a single tap for 280 ms. Paired releases send `gesture: 'double'`. | Same. | Uses the second press to recognize the pair. |
| `tool: 'color'`, tool `value` | Selects local ink for the first canvas. | Same. | Same. |
| `tool: 'width'`, tool `value` | Selects local brush width, 1–24. | Same. | Same. |
| `tool: 'undo'` | Removes every chunk of the last stroke and sends its ID. | Removes all local chunks with that stroke ID. | Same as iOS. |
| `tool: 'clear'` | Clears local strokes and sends the first canvas's clear event. | Same. | Same. |
| Key `size` | Brush-dot size on round width-tool keys. Does not size a key rectangle. | Same. | Same. |
| `image` | Inline PNG/GIF/JPEG/WebP accepted. Not painted. | Same. | Same. |

Keep IDs distinct. Use one behaviour per key. In particular, do not combine a held `button` with event gestures. `fill` needs a valid colour. Badges do not depend on `fill`.

## LCD

| Option | Browser controller | iOS | Android |
| --- | --- | --- | --- |
| `lcd` | Glass display with text and indicators. | Same. | Same. |
| `title`, `text`, `sub` | Uppercase, single lines. Shrunk or squeezed to fit; clipped to glass. | Same. | Same. |
| Text wrapping or rich text | Unsupported. There is no `wrap`, font or HTML option. | Same. | Same. |
| `tone` | `good`, `bad`, `alert` tint the main text. `normal` and `dim` use normal ink. | Same. | Same. |
| `timer.left`, `timer.total` | Local countdown, digits and progress bar. | Same. | Same. |
| `meter.value`, `meter.max` | Segmented progress bar. | Same. | Same. |
| `meter.label` | Visible on wide or large glass. Narrow glass shows the bar alone. | Same. | Same. |
| `tips` | Rotate every 4.5 seconds. A nonempty `sub` takes priority. | Same. | Same. |
| `flash.text`, `flash.tone`, `flash.id` | One shared flash, once per unseen ID, about 1.4 seconds. IDs survive surface changes. | Same. | Same. |
| `entry` | Keyboard text/cursor or slots. Ignored when glass height exceeds 170 design units. | Same. | Same. |
| `secret`, `cover` | Covered until held. Visual privacy only. | Same. Also covers during screen capture. | Covered until held; window capture policy is outside the renderer. |
| `pages.index`, `pages.count` | Page dots when glass height is at most 170. No page-change event. | Same. | Same. |

Use one entry LCD and one keyboard. Use one flash at a time. The timer is a display; the host decides when time runs out.

## Canvas

| Option or data | Browser controller | iOS | Android |
| --- | --- | --- | --- |
| `canvas`, `aspect` | Drawing paper centred in its slot, with the requested aspect ratio. | Same. | Same. |
| `event` | Sends normalized stroke chunks with `points`, `color`, `width`, `stroke`. | Same. | Same. |
| `enabled` | False blocks new drawing and dims the paper. Does not disable undo/clear tools. | Same. | Same. |
| `clearEvent`, `undoEvent` | Tool event names. Default to `clear` and `undo`. | Same. | Same. |
| `ink.value` | Blocks new drawing at zero. No automatic ink spending. | Same. | Same. |
| `ink.max` | Stored. Does not set a separate visual meter. | Same. | Same. |
| Shared `canvas[].points`, `color`, `width`, `stroke` | Restores when local strokes are empty. Clears when shared strokes become empty. New strokes number on from the restored ones. | Same restore/clear policy. Reseeds the counter. | Same as iOS. |
| Nonempty shared canvas replacement | Does not replace existing local strokes. | Same. | Same. |

Use one canvas. Keep authoritative drawing and ink limits on the host. Shared strokes are not a general drawing synchronizer.

## Keyboard and trackpad

| Node or option | Browser controller | iOS | Android |
| --- | --- | --- | --- |
| `keyboard` | Painted keys. One shared editor per surface. | Same. | Same. |
| `language` | Game language, player language and English. Unsupported tags fall back to English. | Same, from bundled layout data. | Same, from bundled layout data. |
| `charset` | `letters`, `text`, `digits`. Text adds spaces and punctuation. | Same. | Same. |
| `maxLength` | Grapheme limit when `Intl.Segmenter` exists; otherwise code points. | Swift character limit. | Extended grapheme limit. |
| `slots` | Fixed entry boxes. Overrides `maxLength`. | Same. | Same. |
| `submit.event`, `submit.label` | Sends trimmed, nonempty `{ text }`. Clears the editor. | Same. | Same. |
| Keyboard `enabled` | False dims keys and blocks typing. | Same. | Same. |
| Layout pages, shift, RTL, Hangul and kana | Handled. Chinese has pinyin letters, without Hanzi candidates. | Same. | Same. |
| Layout `alt` characters | Available in the data API. No touch menu exposes them. This limits accented and Indic entry. | Same. | Same. |
| `trackpad` | Relative pointer, tap, drag and two-finger gestures. | Same. | Same. |
| Trackpad `event` | Sends `{ dx, dy }` motion. | Same. | Same. |
| `clickEvent` | Tap/hold-drag sends `{ button: 0, down }`. Two-finger tap uses button 2. | Same. | Same. |
| `scrollEvent` | Two-finger scroll sends `{ dx, dy }`. | Same. | Same. |
| Trackpad `enabled` | False blocks new touches. | Same. | Same. |

Trackpads send game events. They do not grant system-control permission. See [Keyboards](sdk-keyboards.md) for language limits.

## Accessibility

| Feature | Browser controller | iOS | Android |
| --- | --- | --- | --- |
| Painted labels, keys and LCDs | Canvases are hidden from assistive technology. No semantic control tree. B6. | VoiceOver elements and key actions. Secret text stays covered. | Accessibility nodes and click actions. |
| Badge value and selected state | No semantic value/state. B6. | Badge value and selected trait. | Missing from nodes. B6. |
| Hold/double actions | Touch only. B6. | VoiceOver custom actions on enabled keys. | Touch only. B6. |

## Known gaps

These are source findings. No app code was changed for this table.

| Bug | Finding |
| --- | --- |
| B1 | Fixed: the browser re-lays a surface out when `gap`, key `shape` or `columns` change. |
| B2 | Fixed: iOS repaints a key when its fill, badge or badge colour changes. |
| B3 | Fixed: a new browser surface ID cancels active touches and releases held buttons (test: `tests/testing-surface-swap.test.mjs`). |
| B4 | Fixed: Android cuts badges to 12 characters, as the browser and iOS do. |
| B5 | Fixed: Android caps the badge pill at the key's width and squeezes its text. |
| B6 | Browser surfaces lack semantic controls. Android omits badge values, selected state and gesture actions that iOS exposes. |
| B7 | Fixed: iOS offers VoiceOver's hold and double-tap actions only on enabled keys. |
| B8 | Fixed: browser undo removes every chunk of the last restored stroke, as the apps do. |
| B9 | Fixed: after restoring a shared drawing, the browser numbers new strokes after the restored ones. |

## Source map

Browser behaviour comes from [surface builders and layout](../src/surface.mjs), [touch and editor handling](../web/surface.mjs), and [painting](../web/surface-paint.mjs). [surface-layout.mjs](../web/surface-layout.mjs) handles the separate wheel/flight controllers; custom trees use `layoutSurface` in `src/surface.mjs`.

iOS uses `Sources/Surfaces/SurfaceModel.swift`, `SurfacePaint.swift`, `SurfaceGlyphs.swift`, `GameSurfaceView.swift`, `SurfaceKeyboard.swift` and `SurfaceTrackpad.swift`.

Android uses `GameSurfaceModel.kt`, `GameSurfacePaint.kt`, `GameSurfaceView.kt`, `SurfaceKeyboard.kt`, `SurfaceTrackpad.kt` and `SurfaceKeyGestures.kt` in `controller/plus`.
