Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## Unreleased

### Added

- Error isolation. A view whose content fails to mount or render shows a fallback with **Try again**, and the rest of
the workspace carries on. In React, each view renders in its own error boundary. Everything that throws goes to a new
`error` event with its source, view and type; `ws.reportError` sends your own errors the same way, and
`errorFallback` replaces the fallback. React gets `onError` and `errorFallback` props.
- Right-to-left layouts. `direction: "auto"` (the default) follows the page, and `"rtl"` mirrors the layout, tabs,
menus, keys and gestures. Documents stay direction-neutral.
- `permissions`, to lock the layout or allow only some changes: `rearrange`, `resize`, `close`, `float` and `hide`.
Calls from code are never limited.
- `npm run bench`, which measures startup, memory and frame timing for large workspaces.
- Performance and Security pages in the docs.

### Changed

- Large workspaces render much faster: with the CPU slowed 4× and 300 views, slow frames during a tab drag fall from
98% to 3%, and during a zoom from 84% to 22%.
- `sanitize` and `setDocument` accept any value without throwing, and repair anything inconsistent. A saved layout
with the wrong shape falls back to the default layout.
- Trellis works under a strict Content Security Policy (`style-src-attr 'none'`) and with Trusted Types enforced, as
long as icons aren't passed as markup strings.

### Fixed

- **Close other tabs** closed tabs whose type isn't closable.

## 0.3.0 — 2026-09-28

### Changed
Expand Down
65 changes: 36 additions & 29 deletions docs/core-api.md

Large diffs are not rendered by default.

89 changes: 89 additions & 0 deletions docs/errors.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
---
title: Handling errors
description: How a failing view is contained, the error event, and replacing the fallback a failed view shows.
section: Guides
order: 16.4
nav: Errors
---

# Handling errors

When something in a view throws, Trellis contains it to that view. The view shows a fallback, the rest of the workspace carries on, and the error reaches you through one event:

```ts
ws.on("error", ({ error, source, viewId, type }) => {
reportToMonitoring(error, { source, viewId, type });
});
```

```tsx
<Workspace onError={({ error, source, viewId }) => reportToMonitoring(error, { source, viewId })}>
{/* … */}
</Workspace>
```

Without a listener or an `onError` prop, errors go to the console, so none are lost.

## What's contained

| `source` | Where it threw | What users see |
| ---------- | ------------------------------------------------------------------------ | -------------------------------------------------------- |
| `mount` | A view type's `mount` | The view's fallback |
| `iframe` | A view type's `iframe` function | The view's fallback |
| `render` | A React view's content, icon or accessory while rendering | The view's fallback. Icons and accessories show nothing. |
| `cleanup` | The function `mount` returned, when the view unmounts | Nothing |
| `title` | A `title` function | The type's name as the tab title |
| `menu` | A type's `menu` function or the `panelMenu` option | The menu without those items |
| `guard` | A close guard | The view stays open |
| `listener` | A listener on the workspace or a view, or a view state subscription | Nothing. Other listeners still run. |
| `callback` | Another option, such as `onMissingType`, `hideToward` or `errorFallback` | The option's default behaviour |
| `content` | Your own code, through `ws.reportError` | Nothing |

Each view renders in isolation. In React, every view's content sits in its own error boundary, so one component throwing doesn't unmount the workspace, and other views keep their state.

## The fallback

A view whose content fails shows its title, the error message and a **Try again** button, which mounts or renders the content again. It's `[data-trellis-part="view-error"]`, with `role="alert"`, for styling.

To show your own, pass `errorFallback`. In the core, it returns a DOM node or text:

```ts
createWorkspace(el, {
types,
errorFallback: ({ error, view, retry }) => {
const box = document.createElement("div");
box.textContent = `${view.title} is unavailable.`;
const again = document.createElement("button");
again.textContent = "Reload";
again.onclick = retry;
box.append(again);
return box;
},
});
```

In React, it's a function returning React content, on the workspace or on a single type:

```tsx
<Workspace errorFallback={({ view, retry }) => <ViewUnavailable title={view.title} onRetry={retry} />}>
<ViewType id="map" title="Map" errorFallback={({ retry }) => <MapOffline onRetry={retry} />}>
<IncidentMap />
</ViewType>
</Workspace>
```

A type's own `errorFallback` wins over the workspace's. In React, the workspace's `errorFallback` covers React content. Views that use `mount` or `iframe` show the core fallback.

## Reporting your own errors

`ws.reportError` sends an error through the same event, so errors from your content land in one place:

```ts
socket.addEventListener("error", (e) => ws.reportError(e, { viewId: view.id, source: "content" }));
```

`source` defaults to `"content"`. The event adds the view's type when you pass a `viewId`.

## Iframes

A view's iframe runs its own document, so errors inside it don't reach Trellis. A page that fails to load shows the browser's own error inside the frame. To report errors from a same-origin frame, listen in the frame and call `ws.reportError` from the parent.
2 changes: 1 addition & 1 deletion docs/faq.md
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ Pinch events inside an iframe don't reach the workspace. See [Zoom gestures over

### How big is it?

The core has no runtime dependencies and is about 33 kB minified and gzipped. The React adapter adds about 3 kB, and the stylesheet about 4 kB.
The core has no runtime dependencies and is about 37 kB minified and gzipped. The React adapter adds about 3 kB, and the stylesheet about 4 kB.

### Is Trellis open source?

Expand Down
6 changes: 5 additions & 1 deletion docs/keyboard-accessibility.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,12 +95,16 @@ The panel menu includes **Move _tab_ to ▸**, a submenu listing the other panel

## Dividers

Dividers are focusable `separator`s. <kbd>←</kbd>/<kbd>→</kbd> (or <kbd>↑</kbd>/<kbd>↓</kbd> for columns) move them by a small step. Hold <kbd>Shift</kbd> for larger steps. Double-click a divider to even out its two neighbours. Panels don't shrink below a minimum usable size: past a neighbour's minimum, a divider pushes the panels beyond it, the same as dragging.
Dividers are focusable `separator`s. <kbd>←</kbd>/<kbd>→</kbd> (or <kbd>↑</kbd>/<kbd>↓</kbd> for columns) move them by a small step, in the direction of the arrow. Hold <kbd>Shift</kbd> for larger steps. Double-click a divider to even out its two neighbours. Panels don't shrink below a minimum usable size: past a neighbour's minimum, a divider pushes the panels beyond it, the same as dragging.

## Menus

Panel menus are ARIA `menu`s. Use <kbd>↑</kbd>/<kbd>↓</kbd> and <kbd>Home</kbd>/<kbd>End</kbd> to move, <kbd>Enter</kbd> to activate, <kbd>→</kbd> to open a submenu, <kbd>←</kbd> or <kbd>Esc</kbd> to close it, and <kbd>Tab</kbd> to dismiss. When the menu closes, focus returns to where it was.

## Right to left and permissions

In a [right-to-left](./right-to-left.md) workspace, <kbd>←</kbd> moves to the next tab and opens submenus, following reading order. Shortcuts for actions that [permissions](./permissions.md) turn off do nothing, and locked dividers aren't focusable.

## ARIA and announcements

- The workspace root is a `region` labelled by the `label` option (default "Workspace"). Give each workspace on a page a distinct label.
Expand Down
58 changes: 58 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
---
title: Performance
description: Measured startup time, memory and frame rates for workspaces of 16 to 300 views, and how to keep your own content fast.
section: More
order: 40.5
---

# Performance

Trellis keeps interaction at 60 frames per second for hundreds of views. These numbers come from `npm run bench` in the repository, which you can run on your own hardware.

## Measurements

Each layout is a grid of panels with three tabs each, or one for the smallest. The benchmark measures startup, then frame times while it drags a divider, drags a tab across the layout, animates a zoom and pinches. It runs each case at full speed and with the CPU slowed 4× to stand in for modest hardware.

| Views | Panels | Startup | Startup, CPU slowed 4× | JS heap |
| ----- | ------ | ------- | ---------------------- | ------- |
| 16 | 16 | 24 ms | 92 ms | 3.4 MB |
| 108 | 36 | 45 ms | 180 ms | 3.6 MB |
| 300 | 100 | 108 ms | 432 ms | 4.3 MB |

At full speed, every interaction runs at 60 frames per second at every size, with no frame over 17 ms. With the CPU slowed 4×, the share of frames that miss 60 frames per second is:

| Views | Divider drag | Tab drag | Animated zoom | Pinch zoom |
| ----- | ------------ | -------- | ------------- | ---------- |
| 16 | 0% | 0% | 0% | 0% |
| 108 | 0% | 0% | 0% | 1% |
| 300 | 3% | 3% | 18% | 14% |

Measured on an Apple M1 Max in headless Chromium at 1600 × 1000, after one unmeasured warm-up of each interaction. The heap is the whole test page's JavaScript heap. Views mount a small form each, so real content adds its own cost.

At 300 views, zooming spends most of its time in the browser's own layout, because 100 panels change size on every frame. Fewer panels with more tabs each costs less than many small panels.

## What Trellis does for you

- Content mounts once and never moves in the DOM, so docking, tabbing, floating and zooming never reload an iframe or re-render a framework tree.
- While a zoom animates, content is laid out once at its final size and scaled, rather than on every frame. Views that end the zoom out of view aren't laid out again.
- Views in background tabs aren't repositioned while anything moves.
- Views aren't told about size or scale changes mid-animation. `resize` and `scale` fire once motion settles.

## Keeping your content fast

- Pause work that nobody can see. `view.visible` is `false` for background tabs, hidden panels and views scrolled out of the camera, and the `visibility` event tells you when it changes.
- Don't do heavy work on `resize`. It fires after every committed change, so debounce anything expensive.
- Set `minSize` on dense views. Below it, content scales instead of reflowing, which is cheaper for complex layouts.
- Keep params small. They're saved with the layout and copied on every change.
- Iframes are the most expensive content. Each is a whole document, so prefer in-page content for views that are opened often.

## Bundle size

| Package | Minified | Minified and gzipped |
| ----------------------------- | -------- | -------------------- |
| `@danfessler/trellis` | 104 kB | 37 kB |
| `@danfessler/trellis-react` | 9 kB | 3.5 kB |
| `@danfessler/trellis-element` | 6 kB | 2.2 kB |
| Stylesheet | 22 kB | 3.9 kB |

The core has no dependencies. `npm run size` in the repository prints the current figures.
61 changes: 61 additions & 0 deletions docs/permissions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
---
title: Locking and permissions
description: Lock the layout, or let each user change only some of it, such as resizing but not closing.
section: Guides
order: 16.2
nav: Permissions
---

# Locking and permissions

`permissions` sets what users can change through the interface. Lock the whole layout, or turn off individual kinds of change, for example so an operator can resize panels but not close or rearrange them.

```ts
createWorkspace(el, { types, permissions: false }); // nothing can be rearranged, resized, closed, floated or hidden
```

```tsx
<Workspace permissions={{ close: false, rearrange: false }}>{/* … */}</Workspace>
```

Everything is allowed by default. Content is never affected: users can still select tabs, focus views, use menus you add and work inside your content.

## What each permission covers

| Permission | Turns off |
| ----------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `rearrange` | Dragging tabs and panels: docking, tabbing, reordering and moving floating windows. The panel menu's **Move** and **New split** items. |
| `resize` | Dividers, by dragging, arrow keys or double-click. Resizing floating windows by their edges. Locked dividers and edges aren't shown. |
| `close` | Close buttons, middle-click, <kbd>Delete</kbd> on a tab, the **Close** menu items and the `view.close` shortcut. |
| `float` | Turning a panel into a floating window and back: the **Float** and **Dock** menu items, the `panel.float` shortcut, and dragging a whole panel between the layout and the desktop. |
| `hide` | The **Hide** menu item and the `panel.hide` shortcut. |

`permissions: false` turns off all of them, and `true` allows them all. An object turns off the permissions set to `false` and allows the rest.

Permissions add to each view type's own rules. A view of a type with `closable: false` can't be closed however the permissions are set, and `allow` still limits where a type can go.

## Code isn't limited

Permissions only apply to people using the interface. Calls from your code always work, so an administrator's tools or a layout pushed from your server can still change a locked workspace:

```ts
ws.update({ permissions: false });
ws.open("incident", { params: { id: 4211 } }); // still opens
ws.setDocument(centerLayout); // still applies
```

## Permissions per user

Permissions can change at any time. Set them from each user's role, and update them when the role changes:

```ts
const byRole = {
admin: true,
supervisor: { close: false },
dispatcher: { rearrange: false, close: false, float: false },
} satisfies Record<string, WorkspaceOptions["permissions"]>;

ws.update({ permissions: byRole[user.role] });
```

To give everyone at a dispatch center the same starting layout, pass it as `defaultLayout` and use `ws.reset()` to return to it. See [several layouts per user or team](./persistence.md#recipe-several-layouts-per-user-or-team).
44 changes: 41 additions & 3 deletions docs/persistence.md
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,7 @@ ws.setDocument(doc); // animates to it
ws.setDocument(doc, { animate: false });
```

`setDocument()` sanitizes the input and matches views by id. Views that exist in both documents stay mounted, new ones mount and removed ones unmount. Panels animate from where they were to where they now are.
`setDocument()` [repairs](#damaged-and-outdated-layouts) the input and matches views by id. Views that exist in both documents stay mounted, new ones mount and removed ones unmount. Panels animate from where they were to where they now are.

### The `change` event

Expand Down Expand Up @@ -139,10 +139,48 @@ ws.on("change", (doc) => {
});
```

## Unknown types in saved documents
## Recipe: several layouts per user or team

Saved layouts can outlive your view types. `onMissingType(type, id)` decides what happens to a view whose type isn't registered. Return `"placeholder"` (the default) to show an "Unavailable" placeholder in its place, or `"drop"` to remove it:
A dispatcher might switch between a call-taking layout and a radio layout, and a center might hand out its own default. Store each layout as a named document on your server:

```ts
type SavedLayout = { name: string; document: LayoutDocument };

async function saveAs(name: string) {
await fetch(`/api/layouts/${encodeURIComponent(name)}`, {
method: "PUT",
body: JSON.stringify({ name, document: ws.getDocument() }),
});
}

async function open(name: string) {
const saved: SavedLayout = await fetch(`/api/layouts/${encodeURIComponent(name)}`).then((r) => r.json());
ws.setDocument(saved.document);
}
```

Views keep their mounted content across the switch when their ids match, so a map or a call view that's in both layouts doesn't reload.

For a team default set by an administrator, pass it as `defaultLayout`. `ws.reset()` then returns anyone to it. To stop users rearranging an administrator's layout, or only let some of them, set [permissions](./permissions.md).

## Damaged and outdated layouts

Saved layouts come back from servers, `localStorage` and older versions of your app. Trellis repairs any document before using it, so a bad one can't break the workspace:

- `setDocument()` accepts any value without throwing. A document that isn't one at all, such as `null`, gives an empty workspace.
- A saved layout in `localStorage` that isn't a layout document, or isn't valid JSON, is ignored in favour of the default layout.
- Views without a record, or shown twice, are removed, and so are panels left without views.
- Missing or duplicate ids are replaced, so content isn't lost.
- Weights, floating rects, restore targets and saved framings that aren't valid fall back to sensible values.

`sanitize(document)` is exported if you want to repair a document yourself, for example before storing it.

### Unknown view types

Saved layouts can outlive your view types. `onMissingType(type, id)` decides what happens to a view whose type isn't registered. Return `"placeholder"` (the default) to keep it, showing an "Unavailable" placeholder in its place, or `"drop"` to remove it:

```ts
createWorkspace(el, { types, persist: { key: "app" }, onMissingType: () => "drop" });
```

A placeholder keeps the view's record, params included, so saving the layout doesn't lose it. When the type is registered later, for example once a plugin or microfrontend loads, pass the new types to `ws.update({ types })`. The view mounts its real content in place. The placeholder is `[data-trellis-part="view-missing"]` for styling.
Loading
Loading