# Skin UI API

> The full list of the room.skin API, which adds menus, dialogs, areas, and styling to the default skin (basic-v2).

Use `room.skin` to add buttons, menus, dialogs, and areas to the default skin (basic-v2) and to change how nicknames and messages are displayed. This page is the canonical reference. If you are new to this, start with [Extending the skin UI](/en/doc/2.3).

The legacy skin (`basic`) does not have this API. For the legacy skin, see [Legacy skin API](/en/doc/3.8).

## Common rules {#rules}

```js
U.chat('*').on('after.create#my-ui', function (room) {
	if (room.id !== 'my-room') return;
	var ui = room.skin;
	var remove = ui.menu.add({
		id: 'hello',
		label: 'Greet',
		onSelect: function (ctx) { ctx.room.action.send('Hello!'); },
	});
	// call remove() when you no longer need it
});
```

- **When to register:** Register inside `after.create`. When the room is reinstalled (`room.reload()`, re-inserting the tag), all skin registrations are cleared and `after.create` fires again. Events bound only to that room (`U.chat('my-room').on`) are cleared too, so bind to `U.chat('*')` and filter by `room.id` as in the example above; then everything is registered again automatically on every reinstall.
- **IDs:** Every registration needs an `id`. A duplicate within the same kind throws an error.
- **Removal function:** `add`-style functions return a removal function. It returns `true` the first time and `false` after that, so calling it more than once is safe.
- **Synchronous callbacks only:** Callbacks such as `condition`, `describe`, `resolve`, and `match` must be synchronous. Returning a Promise is treated as an error and that item is dropped. An exception inside a callback is logged to the console and only that item is skipped. `onSelect` ignores its return value, so it can be async, but you must handle exceptions inside it yourself.
- **Display only:** Hiding or styling a menu does not change permissions. The server checks permissions.
- Calling `room.skin` after the room is destroyed throws an error.

## Menus {#menus}

| API | Location |
| --- | --- |
| `ui.menu` | Buttons in the input toolbar |
| `ui.userMenu` | The menu that opens when you click a nickname |

| Function | Returns | Description |
| --- | --- | --- |
| `add(item)` | Removal function | Adds an item |
| `remove(id)` | `true`/`false` | Removes an item. `false` if the ID did not exist |
| `resolver.add({ id, resolve })` | Removal function | Adjusts the item list. See [Menu resolver](#menu-resolver) below |
| `resolver.remove(id)` | `true`/`false` | Removes a resolver |
| `resolver.refresh()` | — | Recalculates the list when conditions change |

### Item fields

| Key | Type | Description |
| --- | --- | --- |
| `id` | String | Required. Unique ID |
| `label` | String | Display text. Falls back to `title`, then to `id` |
| `title` | String | Tooltip on hover |
| `order` | Number | Lower comes first. Default 0 |
| `group` | String | `userMenu`: groups items of the same group on one row |
| `column` | `0`/`1` | `userMenu`: splits items within the same group into left (0) and right (1) columns |
| `separator` | Boolean | `userMenu`: a divider. Cannot be selected |
| `disabled` | Boolean | Disabled (visible but cannot be clicked) |
| `pressed` | Boolean | `menu`: shown as pressed (toggle button) |
| `padding` | `'none'`/`'normal'` | `menu`: inner padding of the button. Default `'normal'` |
| `condition(ctx)` | Function | Hidden if it returns `false` |
| `describe(ctx)` | Function | Returns an object with fields that change over time (`label`, `disabled`, `pressed`, etc.). Hidden if it returns `null` |
| `onSelect(ctx)` | Function | When clicked |

### Callback ctx

| API | `ctx` |
| --- | --- |
| `ui.menu` | `{ room, skin }`, plus `event` in `onSelect` |
| `ui.userMenu` | `{ room, skin, target, messageUid, message, event }` — `target` is the clicked person's [user object](/en/doc/3.2#user-object); if the nickname was clicked in a message, `messageUid` and `message` are set |

```js
ui.userMenu.add({
	id: 'profile',
	label: 'View profile',
	condition: function (ctx) { return !!ctx.target.id; },
	onSelect: function (ctx) {
		window.open('/profile/' + encodeURIComponent(ctx.target.id));
	},
});
```

### Default items

| API | ID (`order`) |
| --- | --- |
| `ui.menu` | `skin.bold`(-60), `skin.italic`(-50), `skin.underline`(-40), `skin.color`(-30), `skin.emoticon`(-20), `skin.upload`(-10), `skin.brand`(100), `skin.scroll`(110) |
| `ui.userMenu` | Start with `skin.`, such as `skin.whisper`, `skin.mute`, `skin.ban`. `order` starts at -1000 in steps of 10 |

## Menu resolver {#menu-resolver}

A resolver only works with items that are already registered. It can hide items, reorder them, change display values, and disable them; it cannot create new items or enable disabled ones.

```js
ui.userMenu.resolver.add({
	id: 'hide-ignore',
	resolve: function (ctx) {
		return ctx.current.filter(function (item) { return item.id !== 'skin.ignore'; });
	},
});
```

| Item | Description |
| --- | --- |
| `resolve(ctx)` | `ctx` is the menu ctx plus `base` (the list before any resolver) and `current` (the list after the previous resolvers). Return an array to replace the list, or `null` to leave it unchanged |
| Returned array | Items without an ID, unregistered IDs, and duplicate IDs are dropped. The list is re-sorted by `order` |

## Dialogs {#dialogs}

| Function | Returns (Promise result) |
| --- | --- |
| `ui.dialog.alert(title, content)` | OK `true`, close `null` |
| `ui.dialog.confirm(title, content)` | OK `true`, cancel `false` |
| `ui.dialog.prompt(title, content, value)` | The entered string, cancel `null` |
| `ui.dialog.password(title, content, value)` | The entered string (masked input), cancel `null` |
| `ui.dialog.select(title, content, options, value)` | The chosen value (key), cancel `null`. `options` is an object `{ value: 'display text' }` |
| `ui.dialog.open({ kind, title, content, options, value })` | `{ id, result, close }` |

```js
ui.dialog.confirm('Challenge', 'Play rock-paper-scissors?').then(function (ok) {
	if (ok) room.action.send('Challenge!');
});

var handle = ui.dialog.open({
	kind: 'confirm',
	title: 'Challenge',
	content: 'Answer within 10 seconds.',
	options: { backdropDismiss: false },
});
handle.result.then(function (ok) { /* true, false, or the value passed to close */ });
setTimeout(function () { handle.close(null); }, 10000);
```

- `kind` is one of `alert`, `confirm`, `prompt`, `password`, `select`. Any other value throws an error.
- `title` and `content` are shown as text (not HTML).
- With `options.backdropDismiss: false`, clicking outside does not close the dialog.
- `close(value)` resolves `result` with that value and returns `true`. If the dialog is already closed, it returns `false`.
- Dialogs are shown one at a time, and the rest wait in order. If the skin's own dialog is open, it is shown first. When the room is destroyed, all waiting dialogs resolve with `null`.

## Mounting areas {#mount}

Pass `mount(host, ctx)` to `ui.mount.*` and the skin creates an empty element (`host`) and hands it to you. Work only inside the host; do not modify the DOM outside it.

```js
ui.mount.messageList({
	id: 'banner',
	position: 'before',
	mount: function (host, ctx) {
		host.textContent = 'Event today at 8 PM!';
		return function () { host.replaceChildren(); };
	},
});
```

| Function | `position` | Placement |
| --- | --- | --- |
| `header` | `before` (default), `after` | Before/after the header area |
| `headerTitle` | — | Inside the room title spot |
| `middle` | `before` (default), `after` | Before/after the chat + user list area |
| `messageList` | `before` (default), `after` | Before/after the chat area |
| `userList` | `before` (default), `after` | Before/after the user list |
| `composer` | `above-input` (default, `before-input` is the same), `after-input` | Above the input, after the input |
| `footer` | `before` (default), `after` | Before/after the input area |
| `wrap` | `before` (default), `after`, `left`, `right` | Above, below, left, or right of the whole room |
| `toolbar` | — | Inside the input toolbar (after the buttons) |
| `overlay` | — | Overlaid on top of the screen. See [Overlay areas](#overlay) below |

All of them return a removal function. You can also remove by ID with `ui.mount.removeOverlay(id)` and `ui.mount.removeToolbar(id)`. An unsupported `position` throws an error.

### mount ctx

| Key | Description |
| --- | --- |
| `room`, `skin` | The room object, `room.skin` |
| `signal` | `AbortSignal`. Aborted right before cleanup. Pass it to `addEventListener(..., { signal })` for automatic cleanup |
| `bounds()` | The host's `getBoundingClientRect()` value |
| `close()` | Removes this whole registration (same as the removal function) |
| `requestLayout()` | Asks the skin to lay out again after you change the host's size |
| `slot` | (areas) The name of the slot it is attached to. Example: `messageList.before` |
| `layer`, `scope` | (`overlay`) Overlay layer and scope |
| `message`, `author` | (`message.extend`) That message and its sender |

If `mount` returns a function or a `{ destroy }` object, it is called once when the host goes away or the registration is removed.

## Overlay areas {#overlay}

`ui.mount.overlay({ id, layer, scope, exclusive, mount })` overlays content on top without pushing the layout.

| Key | Value | Description |
| --- | --- | --- |
| `layer` | `background-effect` | Background effect. Clicks pass through |
|  | `content-overlay` (default) | Information shown over the content |
|  | `modal` | Topmost layer that covers what is below |
| `scope` | `skin` (default) | Overlays the whole room |
|  | `message-list` | Overlays only the chat area |
| `exclusive` | Boolean | Used with `layer: 'modal'`, `ui.dialog` dialogs wait instead of opening while this area is shown. Only allowed when `scope` is `skin`; otherwise it throws an error |

```js
ui.mount.overlay({
	id: 'player',
	layer: 'content-overlay',
	scope: 'message-list',
	mount: function (host, ctx) {
		host.textContent = 'Now playing';
	},
});
```

## Pinned notice {#top-layer}

| Function | Returns | Description |
| --- | --- | --- |
| `ui.topLayer.add({ id, content, backgroundColor, color, dismissLabel, weight })` | Removal function | Adds an item to the notice bar at the top of the chat area |
| `ui.topLayer.remove(id)` | `true`/`false` | Removes an item |

- `content` is required and is shown as text.
- A higher `weight` places the item higher (default 0).
- `dismissLabel` is the text of the button the viewer uses to close the notice.

```js
ui.topLayer.add({ id: 'event', content: 'Event in progress!', backgroundColor: '#fff4cc', color: '#7a4b00' });
```

## Attaching to messages {#message-extend}

`ui.message.extend({ id, position, match, mount })` attaches an area to every normal message and whisper line. It applies to both past and new messages.

| Key | Description |
| --- | --- |
| `position` | `before` (start of the line, before the nickname), `inline` (right after the content), `after` (default, after `inline`) |
| `match(ctx)` | Chooses which messages to attach to. `ctx` is `{ message, author, room, skin }`. If omitted, all messages |
| `mount(host, ctx)` | [mount ctx](#mount) plus `message` and `author` |

The main values of `message` are `kind` (`'message'` or `'whisper'`), `author` (the sender), `content`, `uid`, `created`, and `mine` (`true` if you sent it). `ctx.close()` removes the whole registration, not just that one message.

```js
ui.message.extend({
	id: 'admin-badge',
	position: 'inline',
	match: function (ctx) { return ctx.author && ctx.author.auth >= 2; },
	mount: function (host) { host.textContent = ' [Admin]'; },
});
```

## Nickname styling {#nickname-resolver}

`ui.nickname.resolver.add({ id, resolve })`, `remove(id)`, `refresh()`.

| Item | Description |
| --- | --- |
| `ctx` of `resolve(ctx)` | `{ user, location, message, backgroundColor, room, skin, base, current }`. `location` is `'message'` (chat) or `'user-list'` (user list); `message` is set only in chat |
| Return value | Only the parts of `{ style, nickcon, iconsBefore, iconsAfter, addBefore, addAfter, tag, gradient, classes, vars }` you want to change. `null` skips |
| `style` | See [Allowed style](#style) below |
| `gradient` | An array of 2–8 colors painted left to right across the nickname text (e.g. `['#e65c00', '#d4145a']`). Only color formats allowed in style are accepted; `null` clears it. Titles and icons are not painted |
| `classes` | An array of class names added to the whole nickname (`[data-nickname-display]`). Use it to apply rules registered with [plugin CSS](#plugin-style). Classes are only added (an earlier resolver's classes are never removed), up to 16. Only names made of letters, digits, `_` and `-` are accepted |
| `vars` | CSS variables set on the whole nickname (`{ '--my-color': '#f00' }`), for passing per-person values to your CSS. Names start with `--`; values containing `;`, `{` or `}` are dropped. `null` removes that variable |
| `nickcon` | `{ src, alt }` (an image instead of the nickname) or `null` (remove the nickcon) |
| `iconsBefore`, `iconsAfter` | `[{ src, alt }]`. **Replaces the whole list** of icons before/after the nickname. To add, use `ctx.current.iconsAfter.concat(...)` |
| `addBefore`, `addAfter` | Parts **appended** before or after the nickname: `[{ text, image, color, background, border, title, classes }]`. Up to 6 per side, text up to 16 characters. `classes` (up to 8) applies shapes from [plugin CSS](#plugin-style) |

Image `src` must start with `https://`, `http://`, `//`, or `/`. URLs containing spaces or with an invalid format are dropped.

```js
ui.nickname.resolver.add({
	id: 'admin-nick',
	resolve: function (ctx) {
		if (!ctx.user || ctx.user.auth < 2) return null;
		return {
			style: { fontWeight: 'bold', color: '#c2410c' },
			iconsAfter: ctx.current.iconsAfter.concat({ src: 'https://example.com/star.png', alt: 'Admin' }),
		};
	},
});
```

## Message styling {#message-resolver}

`ui.message.resolver.add({ id, resolve })`, `remove(id)`, `refresh()`. Applies only to normal messages and whispers.

| Item | Description |
| --- | --- |
| `ctx` of `resolve(ctx)` | `{ message, author, backgroundColor, room, skin, base, current }`. `message` is read-only |
| Return value | Only the parts of `{ style, row, classes, vars }` you want to change. `null` skips |
| `style` | Content text and row background. See [Allowed style](#style) below |
| `row.spacing` | `'normal'` or `'relaxed'` (wider row spacing) |
| `row.hideNickname` | `true` hides the nickname. Applies only to normal messages; whispers are not hidden |
| `classes` | An array of class names added to the whole message row, for rules registered with [plugin CSS](#plugin-style). Only added, up to 16. The message text inside is `.message-content` |
| `vars` | CSS variables set on the message row (`{ '--my-accent': '#f00' }`). Same rules as the nickname `vars` |

```js
ui.message.resolver.add({
	id: 'notice-highlight',
	resolve: function (ctx) {
		if (ctx.message.content.indexOf('Notice') < 0) return null;
		return { style: { backgroundColor: '#fff4cc' }, row: { spacing: 'relaxed' } };
	},
});
```

Resolvers are applied in `order` (default 0), then in registration order, each overriding the previous result. If conditions change and you need to recalculate, call `refresh()`.

## Allowed style {#style}

The `style` of nickname and message resolvers accepts only the keys below. Other keys and values in the wrong format are silently dropped. For other CSS, register it with [plugin CSS](#plugin-style) and attach it through the nickname's `classes`.

| Key | Allowed values |
| --- | --- |
| `color`, `backgroundColor` | `#rgb`, `#rgba`, `#rrggbb`, `#rrggbbaa`, `rgb()`, `rgba()`, `hsl()`, `hsla()` |
| `fontWeight` | `normal`, `bold`, `100`~`900` (in steps of 100) |
| `fontStyle` | `normal`, `italic`, `oblique` |
| `textDecoration` | `none`, `underline`, `overline`, `line-through` |

## Plugin CSS {#plugin-style}

`ui.style.add({ id, css })` returns a removal function, and `ui.style.remove(id)` returns `true`/`false`.

- The `css` text goes into the chat room page as a `<style>` element as is. Its content is not filtered. It is removed when the plugin is turned off or the skin is cleaned up.
- The CSS applies to the whole chat room page. Prefix your class names with the plugin name so you don't affect anything else.
- Nickname text always sits inside `[data-nickname-label]`. With a gradient, the same element also gets `[data-nickname-gradient]`.

```js
ui.style.add({
	id: 'myPlugin.flow',
	css: '.my-flow [data-nickname-gradient]{background-size:200% 100%;animation:my-flow 4s linear infinite}'
		+ '@keyframes my-flow{to{background-position:-200% 0}}',
});
ui.nickname.resolver.add({
	id: 'myPlugin.nick',
	resolve: function (ctx) {
		if (!ctx.user || ctx.user.auth < 2) return null;
		return { gradient: ['#538eff', '#f61ecf', '#538eff'], classes: ['my-flow'] };
	},
});
```

## Other features {#other}

| Function | Returns | Description |
| --- | --- | --- |
| `ui.composer.guard.add({ id, shouldBlock, onBlocked })` | Removal function | Blocks use of the input. If `shouldBlock(ctx)` is truthy, it blocks and calls `onBlocked(ctx)` |
| `ui.composer.guard.remove(id)` | `true`/`false` | Removes a guard |
| `ui.message.removeByUser(nick)` | Number removed | Removes that nickname's messages from your screen |
| `ui.scrollToBottom(force)` | `true`/`false` | Scrolls to the bottom of the chat. Without `force`, it does not move and returns `false` if the user is hovering over a nickname or link in a message, or has turned off auto-scroll and is reading above |

The guard's `ctx` is `{ action, draft, event, room, skin }`, and `action` is `'send'` (sending) or `'activate'` (clicking or focusing the input).

```js
ui.composer.guard.add({
	id: 'login-first',
	shouldBlock: function (ctx) { return ctx.room.my.auth < 1; },
	onBlocked: function (ctx) { ctx.skin.dialog.alert('Notice', 'Only logged-in members can chat.'); },
});
```
