# Views and the view SDK

> How an NXM module shows its records. Declarative views that nexxus draws for you, custom HTML views in a sandbox, and the view SDK that connects them to the platform.

An NXM module has two ways to put something on screen. Most screens need no code at all: you describe a list or a record page in `module.json`, and nexxus draws it with the same components as its built-in modules. When a screen needs its own layout, such as a calendar, a cockpit with several blocks or a PDF with notes beside it, you write one HTML file. nexxus runs that file in a sandboxed frame and hands it a small JavaScript API, the **view SDK** (`window.nx`).

This page covers both. Start with the declarative views and reach for a custom view only where they stop being enough.

## Two tiers of views

| | Declarative views | Custom views |
|---|---|---|
| Written as | JSON in `module.json` (`type`: `list`, `record`, `related`) | One HTML file per view (`type: "custom"`, `html: "ui/<name>.html"`) |
| Drawn by | nexxus itself | Your HTML, in a sandboxed frame |
| Looks like | The rest of nexxus, automatically | nexxus when you use the UI kit and theme tokens |
| Data access | Built in: lists, filters, forms, related lists | Through `window.nx`, as the person looking at the view |
| Good for | Lists, record forms, lists of related records | Pages, cockpits, calendars, dialogs, PDF review |

Both tiers act with the rights of the person on the page. A custom view never gets more access than the person who opened it, and every call it makes is checked again on the server.

<Flow
  steps={[
    { icon: 'file-code', title: 'Your view', text: 'One HTML file with inline script and style, running in a sandboxed frame.' },
    { icon: 'arrow-left-right', title: 'The view SDK', text: 'window.nx sends each request over a private message channel.', tone: 'lavender' },
    { icon: 'app-window', title: 'The host in nexxus', text: 'Adds the module and the session, shows toasts, dialogs and the PDF viewer.', tone: 'cyan' },
    { icon: 'shield-check', title: 'The platform API', text: 'Checks the rights of the viewing person and answers.' },
  ]}
  caption="A custom view never sees a session, a tenant id or a user id. The host adds them, and the server checks every request."
/>

## Declarative views

These four types need no HTML. nexxus builds lists, forms, tabs and maps from them.

- **`list`**: the list page of an entity at `/x/<module>/<entity>`. You choose `columns` (a field key, or `{ "field": "status", "display": "badge" }`), `filters` and a default `sort`; `"create": true` shows the button for a new record. Filters and sorting work on indexed fields only. Without a list view, nexxus builds one from the `titleField` and the first four indexed fields.
  The list shows each record as a row in nexxus' list style, and every column stays visible: the `titleField` becomes the heading, references and text fields go below it, the first person (`core:user`) with its label beside them. Choices, dates and numbers sit on the right, the first amount highlighted, and the badge column `status` (else the first badge column) as a chip. Under <Ui>View options</Ui> each person picks the density <Ui>Normal</Ui> or <Ui>Compact</Ui> and saves views of their own.
- **`record`**: the <Ui>Data</Ui> tab of a record. `sections` group the fields, `related` adds lists of records that point to this one, and `actions` decides which actions the action bar offers.
- **`related`**: a tab on another entity's page that lists records pointing to it. On a core record that is the partner, project, invoice or opportunity page (`"target": "core:partner"`, `"core:project"`, `"core:invoice"` or `"core:opportunity"`, slot `record.tab`).
- **`map`**: the records of an entity on a map, together with their list, as a page of the module at `/x/<module>/p/<view>`. `geoField` names the `geo` field that places a record.

The full field reference for these views is on [Package structure](/modules/structure/).

### How a record page is put together

A record page of a package entity shows its tabs in this order:

1. every `record.detail` view (custom views about this record),
2. the <Ui>Data</Ui> tab (the `record` view, or all fields if there is none),
3. every `record.tab` view of the module,
4. the related lists.

Only views of the entity's own package appear there: packages do not build on each other, so no other package adds tabs or dialogs to the page. Actions sit in the action bar at the bottom of the tab.

<Screenshot src={shot_nxm_14} url="/x/curiaos.kanzlei/akte/1/ansicht/akte.cockpit" alt={"A package record page with a custom view as its first tab"} caption={"Custom detail views come first, then Data, then the module's tabs, then related lists."} />

## Slots: where a custom view appears

A custom view declares a `slot`. The slot decides where nexxus shows it and what the view gets as context.

| Slot | Shown in the catalogue as | Where it appears | `target` |
|---|---|---|---|
| `page` | <Ui>Page</Ui> | A page of its own at `/x/<module>/p/<view>`. It fills the area and can be a menu entry. | none |
| `record.detail` | <Ui>View on a record</Ui> | The first tabs of a record page | an entity of the package |
| `record.tab` | <Ui>Tab on a record</Ui> | A tab after <Ui>Data</Ui>; with a core target a tab on the partner, project, invoice or opportunity page | an entity, or `core:partner`, `core:project`, `core:invoice`, `core:opportunity` |
| `record.action` | <Ui>Dialog on a record</Ui> | A dialog opened from the record's action bar | an entity |
| `dashboard.widget` | <Ui>Dashboard tile</Ui> | A tile with `size` `s`, `m` or `l` | none |
| `member` | <Ui>Client area page</Ui> | A page of the [customer area](/modules/portal/#customer-area) on the portal domain, at `/konto/<view>`, filling the window | none |

nexxus shows the title above a page and a record view: the view's label on a page, the record's title on a record page. The view does not repeat it and starts with what it adds, such as a period, a count or filters. A tile and a dialog get their title bar from nexxus too.

A tile reaches the start page in two ways: a person adds it with <Ui>Customize</Ui> and <Ui>Add widget</Ui>, where the view's label, icon and module name are listed, or applies a template of the package (see [Start page templates](/modules/structure/#start-page-templates-dashboards)). None appears by itself. When the module is deactivated or removed, a placed tile says <Ui>Module not available</Ui> and comes back with the module.

```json
{ "key": "faellig", "type": "custom", "slot": "page", "html": "ui/faellig.html",
  "label": { "de": "Fällige Rückgaben", "en": "Due returns" } }
```

### Showing a view only when it applies

Views and actions take an optional `when`. nexxus hides the view when the condition does not hold for the current record, and the server checks it again before an action runs.

```json
"when": { "field": "status", "in": ["verfuegbar"] }
"when": { "field": "dokument", "set": true }
```

`in` holds when the field's value is one of the listed values (for a list field, when any element is). `set: true` holds when the field has a value, `set: false` when it is empty.

## The sandbox

Every custom view runs in an `<iframe>` with `sandbox="allow-scripts allow-forms"` and without same origin, under the same content policy as apps:

```
default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline';
img-src data: blob:; font-src data:; media-src data: blob:; form-action 'none'; base-uri 'none'
```

The sandbox exists because a package's code does not come from nexxus. Whatever the view does, it cannot reach the network, read the session or touch the page around it. In practice:

- **Inline only.** Scripts, styles, fonts and images are inline or `data:`/`blob:` URLs. `fetch`, XHR, WebSockets and `<script src>` from the network are blocked.
- **No cookies and no `localStorage`.** The frame has no origin. Use `nx.storage` for what a person should find again.
- **Forms work, submissions don't.** A `<form>` fires its `submit` event, but `form-action 'none'` blocks anything that would navigate. Call `preventDefault()` and handle the data yourself.
- **No popups and no downloads.**
- **No icon font.** The Material Symbols font of nexxus is not available in the frame. Use inline SVG.
- **Text as text.** Records contain what people typed. Insert it with `textContent`, never with `innerHTML`.

The SDK and the UI kit are inserted before your own code, so `window.nx` exists when your first script runs. An HTML file may be up to 2 MB.

## A minimal view

This page lists the devices that are on loan, from the [first module tutorial](/modules/first-module/):

```html
<!doctype html>
<html>
<head>
  <meta charset="utf-8">
  <style>body { padding: 16px; }</style>
</head>
<body>
  <div class="nx-list" id="list"></div>
  <script>
    (async () => {
      const ctx = await nx.ready();
      const {items} = await nx.records.search('geraet', {
        filter: {status: ['verliehen']}, sort: 'rueckgabe_am', size: 50,
      });
      for (const geraet of items) {
        const item = document.createElement('button');
        item.className = 'nx-list__item';
        item.textContent = geraet.title;
        item.onclick = () => nx.nav.open({entity: 'geraet', id: geraet.id});
        document.getElementById('list').append(item);
      }
    })();
  </script>
</body>
</html>
```

## Context and events

`await nx.ready()` resolves once the host has connected. It returns the context of the view:

| Field | Meaning |
|---|---|
| `sdk` | Protocol version, currently `1` |
| `module`, `version`, `view`, `slot` | Where the view runs |
| `locale` | `de`, `en` or `pl`, the person's language |
| `theme` | `light` or `dark` |
| `user` | `{id, name}` of the person looking at the view |
| `record` | `{entity, id}` on a record page of an entity of the view's package, e.g. `{entity: 'akte', id: 12}` |
| `target` | `{type, id}` on a core record, e.g. `{type: 'core:project', id: 7}` |
| `params` | Whatever the opener handed over |

`nx.on(name, fn)` subscribes to an event and returns a function that unsubscribes.

- **`context`**: the context changed (another record, another language). The argument is the new context.
- **`theme`**: light or dark changed, as `{theme, tokens}`. The SDK has already applied it. Listen only if you draw something yourself, such as a canvas or a chart.
- **`record`**: the record your view is about was changed by nexxus outside your view, as `{entity, id, version}`. This happens after an action ran on it, after a dialog wrote it, after the <Ui>Data</Ui> tab was saved, or after another view changed it. For core records `version` is `null`. Your own writes do not come back as events.

The event says that something changed, not what. Read the record again:

```js
const ctx = await nx.ready();
let shown = await nx.records.get(ctx.record.entity, ctx.record.id);
render(shown);

nx.on('record', async ({version}) => {
  if (version !== null && version === shown.version) return;
  shown = await nx.records.get(ctx.record.entity, ctx.record.id);
  render(shown);
});
```

Changes that nexxus did not make in this browser tab, by another person or by a [module service](/modules/services/) in the background, are not announced.

## SDK reference

Every call returns a promise. A failed call rejects with an `nx.NxError` that carries `code`, `message` and, for validation errors, `fieldErrors`. When a [module service](/modules/services/) refuses an action, `code` is `SERVICE_REJECTED`, `serviceCode` is the service's own code (for example `COMPOSE_STALE`) and `serviceStatus` its HTTP status; check `serviceCode`, not the text, which is in the person's language.

| Call | Result |
|---|---|
| `nx.ready()` | The context (see above) |
| `nx.on(name, fn)` | Subscribes to `context`, `theme` or `record`; returns an unsubscribe function |
| `nx.records.search(entity, {q?, filter?, sort?, from?, size?})` | `{items, total}`. `filter` per field: a value, a list of values or `{from, to}`. `sort`: `'field'`, `'field,asc'` or `'field,desc'` (see below). `size` at most 200 |
| `nx.records.get(entity, id)` | The record |
| `nx.records.create(entity, data)` | The new record |
| `nx.records.update(entity, id, patch, {version?})` | The record. With `version`, a newer copy on the server fails with `CONFLICT` |
| `nx.records.remove(entity, id)` | `true` |
| `nx.lookup(target, {q?, ids?})` | `[{id, label, sublabel}]`. Targets: an entity of the module, `'core:partner'`, `'core:partnerContact'`, `'core:project'`, `'core:invoice'`, `'core:opportunity'`, `'core:user'`, `'core:dmsDocument'`, `'core:ismsMeasure'`. Without `q`, the first 20 |
| `nx.invoke(action, {recordId?, payload?})` | `{result, record?}`. Runs an action of the module; the server checks its permission and its `when`. An action that sends e-mail asks the person first, see below |
| `nx.actions.preview(action, recordId)` | `{recipient: {name, address}, subject, text, fileName}`: what an action that sends e-mail would send for the record. Sends nothing |
| `nx.nav.open(target)` | Opens `{entity, id}`, `{list: entity}`, `{view}` (a page of the module) or `{core, id}` with `core` one of `'partner'`, `'project'`, `'invoice'`, `'opportunity'`. `{view, id}` with a `record.action` view opens its dialog for that record and resolves with what the dialog passed to `nx.dialog.close()`, or `null` when it was dismissed. `{view, id}` with a `map` view opens the map on that record (`?focus=<id>`) |
| `nx.ui.toast(message, {kind?})` | Shows a toast; `kind` is `success`, `error` or `info` |
| `nx.ui.confirm({title, message, confirmLabel?, danger?})` | `true` when confirmed. nexxus shows its own dialog |
| `nx.dialog.close(result?)` | In `record.action` views only: closes the dialog and hands `result` to the opener |
| `nx.storage.get(key)` | The stored value, per person and view |
| `nx.storage.set(key, value)` | Stores a value in the person's profile, so it follows them to other devices. `set(key, null)` removes it |
| `nx.assistant.setContext({summary, records?})` | Tells the AI Assistant what the view shows. `records` is `[{entity, id}]`; an empty object withdraws it |
| `nx.assistant.ask(prompt)` | Opens the assistant column and asks `prompt` as a new conversation |
| `nx.t({de, en, pl})` | The text in the person's language, else the English one, else the German one. `pl` is optional |
| `nx.pdf.mount(element, {documentId, page?, highlights?})` | A handle for a PDF viewer over `element` (see below) |
| `nx.images.url(documentId, {width?})` | A `blob:` URL of the document's picture for `<img src>`. nexxus loads the file with the reading rights of the person looking at the view and takes only pictures up to 15 MB. With `width`, the width on screen in CSS pixels, a copy scaled to fit comes instead of the original, and the browser keeps it for ten minutes. The same id and width give the same URL, loaded once; at most four load at a time |
| `nx.files.upload(file, {entity, field, name?})` | `{id, documentId, name, type, size, url}`. Stores a file from the view's own input, a drop or the camera as a document of the person, for a field of `entity` that refers to documents. Write `documentId` into that field yourself; `url` shows the file at once. In `record.detail`, `record.tab` and `page` views, with the scope `core:dmsDocument:create`, see [Photos and files from the view](#photos-and-files-from-the-view) |

**Entities** are those of the view's own package, named by their key (`'geraet'`). A view reaches no other package: a name with a package key in front, such as `'curiaos.kanzlei:akte'`, fails with `INVALID_PARAMS`. Searches filter on indexed fields only.

`sort` takes an indexed field or one of `id`, `createdAt` (created) and `updatedAt` (last changed). Without a direction it sorts ascending. Without `sort`, the most recently created record comes first. Records without a value in the sort field come last, and among equal values the most recently created one comes first. `nx.records.search('geraet', {sort: 'updatedAt,desc', size: 10})` gives the ten records changed last.

### Actions that send e-mail

An action of type `mail` makes nexxus send an e-mail with a PDF on behalf of your organisation to the address of a contact. nexxus takes the address from the contact itself, never from your view, and asks the person before anything goes out.

When your view calls `nx.invoke` for such an action, nexxus opens its own dialog <Ui>Send e-mail</Ui> outside your frame. It shows the action's `confirm` text, then <Ui>To</Ui> with the recipient's name and address, <Ui>Subject</Ui>, the text of the e-mail and <Ui>Attachment</Ui> with the PDF's file name, all as the server computed them. Only <Ui>Send</Ui> sends, and only what the dialog showed. <Ui>Cancel</Ui>, the close button or Escape reject your call with `CANCELLED`. The same dialog opens when someone clicks the action in the record's action bar.

- Don't ask with `nx.ui.confirm` first. The person would confirm twice.
- If the contact has no e-mail address, the dialog shows the server's message and offers only <Ui>Close</Ui>. Your call then rejects with `CANCELLED`, and `nx.actions.preview` with `NO_ADDRESS`.
- If the record or the recipient changed between the preview and the send, the call fails with `CONFIRMATION_STALE`. Call `nx.invoke` again for a fresh preview.
- `nx.actions.preview(action, recordId)` returns the same preview, so your view can show it next to its button. It never contains the token that ties a send to the preview, so a view cannot send past the dialog.

```js
const mail = await nx.actions.preview('entwurf-versenden', ctx.record.id);
showPreview(mail.recipient.name, mail.recipient.address, mail.subject, mail.text, mail.fileName);

try {
  await nx.invoke('entwurf-versenden', {recordId: ctx.record.id});
} catch (e) {
  if (e.code !== 'CANCELLED') nx.ui.toast(e.message, {kind: 'error'});
}
```

### Photos and files from the view

A view on a record page or on a page of its own can take photos and other files, for example on a tablet in the field. The view keeps its own `<input type="file">`. nexxus stores the file in <Ui>Files</Ui> as a document of the person and hands back its id, which the view writes into a field that refers to documents.

1. Declare the scope `core:dmsDocument:create` under `scopes`. The administration agrees to it when installing the package (<Ui>Create documents</Ui>). Without it the call fails with `CONSENT_REQUIRED`.
2. Give the entity a `ref` field with `"target": "core:dmsDocument"`, with `"multiple": true` for several files and `"accept": ["image/*"]` when only pictures belong there.
3. Call `nx.files.upload(file, {entity, field})` and save the `documentId` it returns into the field.

```js
// <input type="file" id="photo" accept="image/*" capture="environment">
const input = document.querySelector('#photo');
input.addEventListener('change', async () => {
  const file = input.files[0];
  if (!file) return;
  try {
    const photo = await nx.files.upload(file, {entity: 'component', field: 'photos'});
    preview.src = photo.url;
    const record = await nx.records.get('component', ctx.record.id);
    const photos = [...(record.data.photos || []), photo.documentId];
    await nx.records.update('component', record.id, {photos}, {version: record.version});
  } catch (e) {
    nx.ui.toast(e.message, {kind: 'error'});
  }
});
```

- **What nexxus checks:** the person may change records of the entity, the field refers to documents and its `accept` takes the file, the file is a document, picture, office file or e-mail, and it is at most 25 MB. A larger file is refused before it is sent (`TOO_LARGE`).
- **Where the file goes:** into a folder with the package's name in the person's own files, made on the first upload. Nothing needs setting up, because everyone who may read a record that refers to the file may read the file too.
- **Optionally one folder for the team:** an administrator creates a top folder in <Ui>Files</Ui> with the package's name in the organisation's language and shares it with the right to edit. The uploads of everyone who sees it and has no folder of that name of their own then land there. Shared for reading only, it refuses their uploads.
- **Who sees it:** the person who uploaded it, everyone the folder is shared with, and everyone who may read a record of the package that refers to the file.
- **The upload alone changes no record.** A file that never reaches a field stays in the person's files.
- **HEIC:** iPads and iPhones usually hand a file input a JPEG. A HEIC file is stored as it is, and browsers other than Safari do not show it.
- **Not available** in widgets on the start page, in action dialogs (`record.action`) and on public forms: there the call fails with `NOT_AVAILABLE`.

Later the view shows a stored photo with `nx.images.url(documentId)`.

### Error codes

| Code | Comes from | Meaning |
|---|---|---|
| `VALIDATION` | Server | The data was rejected; `fieldErrors` names the fields |
| `FORBIDDEN` | Server | The person lacks the permission |
| `NOT_FOUND` | Server | The record, action or view does not exist |
| `MODULE_INACTIVE` | Server | The module is deactivated |
| `CONFLICT` | Server | Someone changed the record since you read it |
| `UNIQUE` | Server | A unique value is already taken |
| `NO_ADDRESS` | Server | The contact of an action that sends e-mail has no e-mail address |
| `CONFIRMATION_STALE` | Server | The record or the recipient changed since the e-mail preview was shown, or the preview expired |
| `CONSENT_REQUIRED` | Server | The package lacks the scope the call needs, such as `core:dmsDocument:create` for `nx.files.upload` |
| `INVALID_PARAMS` | Host | A parameter had the wrong shape; the message names it |
| `UNKNOWN_METHOD` | Host | No such SDK method |
| `BUSY` | Host | Too many calls at once, or a confirmation is already open |
| `CANCELLED` | Host | The person closed the dialog of an action that sends e-mail without sending |
| `STORAGE_FULL` | Host | More than the storage limit for this view |
| `NOT_IN_DIALOG` | Host | `nx.dialog.close()` outside a `record.action` view |
| `NO_USER_ACTION` | Host | The call needs a click or key press in the view first |
| `NOT_AVAILABLE` | Host | The call does not exist in this kind of view, such as `nx.files.upload` in a widget |
| `NOT_AN_IMAGE` | Host | `nx.images.url`: the document is no picture |
| `TOO_LARGE` | Host | `nx.images.url`: the picture is larger than 15 MB. `nx.files.upload`: the file is larger than 25 MB |
| `LIMIT` | Host | A limit was hit, e.g. too many PDF viewers |
| `NETWORK` | Host | The request did not reach the server |
| `UNAUTHENTICATED` | Host | The session has ended |

Show `error.message` to the person. The server answers in German. For validation errors the message is general and `fieldErrors` holds the detail: a search sorted by a field that is not indexed fails with „Die Suche ist ungültig.“ (the search is invalid) and `fieldErrors.sort` „Nach 'notiz' lässt sich nicht sortieren: das Feld ist nicht indexed.“ (cannot sort by 'notiz': the field is not indexed)

## Theme and UI kit

The frame is transparent and follows the theme of nexxus: `<html data-theme="light">` or `"dark"`. On a record page there is no card behind it: your view stands directly on the page, like the sections of the <Ui>Data</Ui> tab, so its content belongs in cards (`nx-card`). The UI kit is a CSS file nexxus injects into every view. You may ignore it, but with it your view looks like nexxus in both themes and in the tenant's brand colour.

### Colour and shape tokens

| Variable | Use |
|---|---|
| `--nx-text`, `--nx-text-muted`, `--nx-text-faint` | Text, secondary text, hints |
| `--nx-surface`, `--nx-surface-raised`, `--nx-surface-sunken`, `--nx-page` | Cards, menus, recessed areas, the page ground |
| `--nx-line`, `--nx-line-soft` | Borders and dividers |
| `--nx-brand`, `--nx-brand-contrast`, `--nx-brand-ink` | Brand fill, text on it, brand colour as text |
| `--nx-accent`, `--nx-success`, `--nx-warning`, `--nx-danger`, `--nx-info` | Signal colours |
| `--nx-radius`, `--nx-radius-sm`, `--nx-shadow`, `--nx-font`, `--nx-font-mono` | Shape and type |

### Classes

| Class | For |
|---|---|
| `nx-btn` with `nx-btn--primary`, `--danger`, `--quiet`, `--sm`, `--icon` | Buttons |
| `nx-badge` with `nx-badge--success`, `--warning`, `--danger`, `--info`, `--brand` | Status labels |
| `nx-card`, `nx-card__title` | A panel |
| `nx-field`, `nx-field__label`, `nx-field__hint`, `nx-field__error`, `nx-input`, `nx-select`, `nx-textarea` | Forms; `aria-invalid="true"` marks an error |
| `nx-list`, `nx-list__item` (`is-active`) | Lists; a `<button class="nx-list__item">` is clickable |
| `nx-rows`, `nx-entity-row` (`is-active`), `nx-meta` | Records the way nexxus draws its lists; `nx-list-compact` on the list turns them into rows in one frame |
| `nx-table` | Tables |
| `nx-empty`, `nx-empty__icon`, `nx-empty__title` | Empty states |
| `nx-title`, `nx-subtitle`, `nx-label`, `nx-muted`, `nx-faint`, `nx-mono`, `nx-num` | Type |
| `nx-stack`, `nx-row`, `nx-spacer`, `nx-divider` | Layout helpers |
| `nx-kbd`, `nx-progress` (`--nx-value`), `nx-spinner` | Small things |
| `nx-pdf-slot` | The region reserved for a PDF |

## A PDF in a view

A view can show a PDF from the files area without ever holding its bytes. You reserve an element, and nexxus lays its own PDF viewer over it and loads the document with the rights of the person looking at it.

```js
const handle = nx.pdf.mount(document.getElementById('pdf'), {
  documentId: 88, highlights: [{text: 'binnen zwei Wochen', page: 2}],
});
handle.on('select', ({page, text, rects}) => showNoteButton(page, text));
handle.on('page', page => updatePageLabel(page));
await handle.goTo(3);
const {total, index} = await handle.find('Zustellung');
await handle.setHighlights([{text: quote, page}]);
await handle.destroy();
```

- Give the element a size, especially a height: `<div id="pdf" class="nx-pdf-slot"></div>`. The viewer follows the element when it moves, scrolls or resizes, and is cut where your scrolling containers cut the element.
- Events: `select` (`{page, text, rects}`, with `rects` as fractions 0 to 1 of the page, origin top left; a cleared selection comes with empty `text`), `page` (the page in view), `load` (`{pages}`), `error` (`{code}`). `handle.ready` resolves when the viewer is mounted.
- Highlights match regardless of case and whitespace, across line breaks, and with typographic quotes and dashes typed plainly. Without `page`, every occurrence is marked.

<Screenshot src={shot_nxm_17} url="/x/curiaos.kanzlei/aktendokument/26/ansicht/aktendokument.ansicht" alt={"A custom view with the PDF viewer of nexxus and a notes pane"} caption={"Clicking a note highlights its quote; nexxus loads the PDF with the reader's rights."} />

## Size, keys and dialogs

- **Height** follows your content, up to 20 000 px. A `page` view fills the area below its top instead (at least 320 px). Leave `<html>` at its natural height unless you are a page.
- **Keys** with Ctrl, Cmd or Alt, and Escape, go on to nexxus unless your handler calls `preventDefault()`. So Escape closes the dialog of a `record.action` view.
- **Dialogs** (`record.action`): nexxus draws the dialog header with the view's label, icon and a close button. Your view draws the body and its own buttons, and finishes with `nx.dialog.close(result)`. Closing the dialog any other way means the action was not carried out. Another view of the module opens the dialog with `nx.nav.open({view, id})`.
- **Tiles** (`dashboard.widget`): nexxus draws the card with icon, label and module name; the view draws only the content. Sizes on a wide screen: `s` a quarter of the row, `m` half, `l` the full row.

<Screenshot src={shot_nxm_16} url="/x/curiaos.kanzlei/akte/5/daten" alt={"A record.action view as a dialog"} caption={"nexxus draws the header, the view draws the content and the buttons."} />

## Limits

| What | Limit |
|---|---|
| Calls in flight per view | 32 |
| Toasts | 20 a minute, at least 400 ms apart |
| `nx.storage` per person and view | 15 000 characters as JSON; keys `[A-Za-z0-9._-]`, at most 64 characters |
| Record payload per call | 256 KB as JSON |
| View height | 20 000 px |
| PDF viewers per view | 4, with up to 50 highlights of 2 000 characters |
| HTML file | 2 MB |

## Views and the AI Assistant

Package modules bring no chat of their own. The AI Assistant of nexxus works on every package page, and a view can tell it what the view shows.

- On list, record and page views, nexxus already tells the AI Assistant the module, the entity, the record and the view.
- `nx.assistant.setContext({summary, records})` adds what only your view knows, for example "3 devices overdue" with the records behind it. It appears as a chip in the assistant column and goes with every message while the view is on screen.
- `nx.assistant.ask(prompt)` opens the assistant column with a new conversation. It works only right after a click or key press in your view, so a view cannot start a conversation on its own.
- The example prompts of the manifest (`assistant.prompts` with a `view` key) show up as chips in the assistant column when that view is on screen.

<Screenshot src={shot_nxm_20} url="/x/curiaos.kanzlei/akte/1/ansicht/akte.cockpit" alt={"The assistant column with prompts and context from a custom view"} caption={"The chips come from the manifest's prompts; the context chip names the view."} />

How the assistant uses a module's tools is described in [Working with the AI Assistant](/use/assistant/) and [Package structure](/modules/structure/).

## The protocol, for debugging

You rarely need this, but it helps when you read the browser's message log.

1. The SDK posts `{type: 'nxm:ready', sdk}` to the parent window.
2. nexxus checks that the message comes from its own frame (`event.source`) and answers `{type: 'nxm:connect'}` with a `MessagePort`.
3. Everything after that goes over the port: requests `{t: 'req', id, method, params}`, answers `{t: 'res', id, ok, result}` or `{t: 'res', id, ok: false, error}`, and events `{t: 'evt', name, data}` in both directions.

Events from nexxus to the view are `init`, `context`, `theme`, `record` and `pdf.*`; from the view to nexxus `height`, `key` and `pdf.rect`. The method names are `records.search`, `records.get`, `records.create`, `records.update`, `records.remove`, `lookup`, `invoke`, `actions.preview`, `nav.open`, `ui.toast`, `ui.confirm`, `dialog.close`, `storage.get`, `storage.set`, `assistant.setContext`, `assistant.ask`, `images.get`, `pdf.mount`, `pdf.update`, `pdf.goTo`, `pdf.find` and `pdf.destroy`.

## Known gaps

- **No descriptor in the view.** A view cannot read the module's field and enum labels. Repeat the labels you show, with `nx.t({de, en, pl})`, and keep them in line with the manifest.
- **No rights in the view.** A view cannot ask whether the person may run an action. Offer the button and show the server's `FORBIDDEN` message when it comes back, or put the action in the record's action bar, which nexxus shows only to people who may run it.
- **No icon font.** Use inline SVG.

## When something goes wrong

nexxus shows its own message in place of a view that cannot run:

| Message | Cause and fix |
|---|---|
| "This view could not be loaded." | The view could not be loaded, for example after a network error. <Ui>Try again</Ui> loads it again. |
| "You are not allowed to see this view." | The person lacks a permission of the module. Give them a role of the package, see [Roles](/setup/roles/). |
| "This view does not exist (any more)." | The view key is not in the installed version, for example after an update removed it. |
| "The module of this view is not active." | The package is deactivated. Activate it on its package page. |

Inside your own view, the usual surprises are a `fetch` that the content policy blocks (use `nx.records` instead), a sort or filter on a field that is not `indexed` (`VALIDATION`), and `nx.assistant.ask` called without a click (`NO_USER_ACTION`).

## Next steps

<CardGrid>
  <LinkCard title="Build your first module" description="A list, a dialog view and a page, from the manifest to the installed package." href="/modules/first-module/" />
  <LinkCard title="Module services" description="When a view is not enough: code that runs outside nexxus and reacts to changes." href="/modules/services/" />
  <LinkCard title="Package structure" description="Every field of module.json, including views, actions and the assistant." href="/modules/structure/" />
</CardGrid>