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
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,37 @@
# 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.
- Experimental `worldTransform` option: while the camera moves, the workspace is laid out once and moved with one
transform. With 300 views and the CPU slowed 4×, slow frames during a zoom fall from 30–40% to under 10%. Panels
scale like pictures until the camera stops. `{ minPanels }` uses it only for moves with that many panels in view,
and `"auto"` is `{ minPanels: 40 }`. Off by default.
- `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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@ npm run build:all # packages + website with examples (site/dist)
npm run verify:pack # pack tarballs and build a fresh app against React 18 and 19
```

The browser tests serve the test app on port 5330 and reuse whatever is already there; `E2E_PORT=5332 npm run test:e2e` picks another port. Playwright's WebKit doesn't run on older macOS releases; to run it locally, use Playwright's Linux image (`mcr.microsoft.com/playwright`) with `CI=true`.

The examples live in [`examples/`](examples):

| Example | Shows | Run |
Expand Down
66 changes: 37 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 38 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
78 changes: 78 additions & 0 deletions docs/performance.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
---
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.

## Experimental: world transform

`worldTransform: true` (React: `worldTransform`) moves the camera a different way. At the start of a zoom or pan, the whole workspace is laid out once for a view covering the entire move. Each frame then sets a single transform on the layer that holds it, instead of laying out every panel. Pinch and drag gestures work the same way, and are laid out again when they leave that view or zoom in more than 3×. When the camera stops, everything is laid out normally again.

With 300 views and the CPU slowed 4×, slow frames fall from 30–40% to under 10% during an animated zoom, and from 20–25% to about 5% during a pinch. The trade-offs, while the camera moves:

- Chrome, text and the gaps between panels scale like a picture, and stretch unevenly when the view's shape changes.
- Zooming in looks slightly soft until the camera stops.
- Collapsed groups and icon tiles are chosen for the laid-out view, not for each frame.

Nothing changes once the camera is still. The option may become the default, change, or go away.

### Only for moves with many panels

`worldTransform: { minPanels: n }` uses the world transform only for camera moves with at least `n` panels in view where they start or end. Floating panels always count. Each move is decided when the camera starts moving, so a zoom from one panel to its neighbour stays on the normal renderer, even in a large layout.

`worldTransform: "auto"` is `{ minPanels: 40 }`. With the CPU slowed 4×, laying out 36 panels on every frame kept up, and 49 began to stutter. Set your own threshold if your content is especially light or heavy.

`WORLD=1 npm run bench` measures with the world transform on, and `WORLD=auto` with auto. `SIZES=6x6,8x8` measures other grids.

## 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` | 107 kB | 38 kB |
| `@danfessler/trellis-react` | 9.5 kB | 3.6 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).
Loading
Loading