diff --git a/CHANGELOG.md b/CHANGELOG.md index 96093c2..1954bd8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/core-api.md b/docs/core-api.md index 9d53627..86d8ca9 100644 --- a/docs/core-api.md +++ b/docs/core-api.md @@ -35,27 +35,30 @@ Creates a workspace inside `host` and returns its [handle](#workspacehandle). Tr ### `WorkspaceOptions` -| Option | Type | Default | Description | -| --------------- | ---------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `types` | `Record` | | **Required.** The registered [view types](#viewtypedefinition), keyed by type id. | -| `theme` | `"light" \| "medium" \| "dark" \| "darker" \| "system"` | `"system"` | The built-in theme. | -| `tokens` | `Record` | | CSS custom properties set on the workspace root. A new object replaces the previous set, so omitted tokens are removed, and an empty string removes a token. | -| `floating` | `false \| "overlay" \| "stage"` | `"overlay"` | The layer floating panels live in. | -| `navigation` | `false \| "focus" \| "free"` | `"focus"` | The navigation mode. `"free"` adds gestures and a default 480 × 320 content minimum. See [Navigation](./navigation.md). | -| `motion` | `"system" \| "full" \| "reduced"` | `"system"` | The animation policy. | -| `keymap` | `Keymap` | | Changes key bindings with a partial map of command to combo. `null` disables a command. | -| `panelMenu` | `boolean \| (entries, context) => MenuEntry[]` | `true` | Turns the [built-in menu items](./menus.md#the-built-in-items) on or off, or edits every panel's menu with a function. See [Panel menus](./menus.md). | -| `detail` | `false \| { size?, outline? }` | `{ size: 48, outline: 2 }` | Collapses a nested group into one tile when all its parts are smaller than `size` × `size` on screen. See [Zoomable layouts](./zoomable-layouts.md#small-panels). | -| `keepPushed` | `boolean` | `true` | Whether panels pushed by a divider stay pushed when it's dragged back. `false` makes each drag work from the layout it started with, so dragging back undoes the pushes. | -| `gestureKeys` | `{ pan?, scale?, rect?, step? }`, each a combo, a list or `null` | `defaultGestureKeys()` | Keys held to pan (drag), scale (drag), zoom to a rectangle (drag) and step (scroll) under free navigation. See [Gesture keys](./navigation.md#gesture-keys). | -| `renderMenu` | `(request: MenuRequest) => void` | | Shows panel menus with your own component instead of the built-in one. See [Panel menus](./menus.md#render-menus-yourself). | -| `tabs` | `{ fill?: boolean; inset?: number }` | `{ fill: false, inset: 4 }` | Sets the tab style. `fill` stretches tabs across the tab row, and `inset` is the space around them in pixels (`0` is full-bleed). See [Theming](./theming.md#tab-styles). | -| `hideToward` | `(panelId: string) => Element \| Rect \| null \| undefined` | | Picks where the built-in menu's **Hide** animates to, such as your dock or tray button. See [Hiding](./hiding.md#the-built-in-hide). | -| `label` | `string` | `"Workspace"` | The accessible name of the workspace region. | -| `onMissingType` | `(type, id) => "drop" \| "placeholder"` | `"placeholder"` | Decides what happens to a view whose type isn't registered. See [Unknown view types](./layout.md#unknown-view-types). | -| `defaultLayout` | `LayoutDocument \| LayoutSpec \| null` | | The initial layout when nothing is persisted or passed as `document`. Read once, when the workspace mounts. | -| `document` | `LayoutDocument` | | The document to start from. It overrides persistence. Read once, when the workspace mounts. | -| `persist` | `{ key: string; version?: string \| number }` | | Saves the layout to `localStorage` under `key` and restores it from there. A saved layout with a different `version` is discarded. Read once, when the workspace mounts. | +| Option | Type | Default | Description | +| --------------- | ---------------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `types` | `Record` | | **Required.** The registered [view types](#viewtypedefinition), keyed by type id. | +| `theme` | `"light" \| "medium" \| "dark" \| "darker" \| "system"` | `"system"` | The built-in theme. | +| `tokens` | `Record` | | CSS custom properties set on the workspace root. A new object replaces the previous set, so omitted tokens are removed, and an empty string removes a token. | +| `floating` | `false \| "overlay" \| "stage"` | `"overlay"` | The layer floating panels live in. | +| `navigation` | `false \| "focus" \| "free"` | `"focus"` | The navigation mode. `"free"` adds gestures and a default 480 × 320 content minimum. See [Navigation](./navigation.md). | +| `motion` | `"system" \| "full" \| "reduced"` | `"system"` | The animation policy. | +| `keymap` | `Keymap` | | Changes key bindings with a partial map of command to combo. `null` disables a command. | +| `panelMenu` | `boolean \| (entries, context) => MenuEntry[]` | `true` | Turns the [built-in menu items](./menus.md#the-built-in-items) on or off, or edits every panel's menu with a function. See [Panel menus](./menus.md). | +| `detail` | `false \| { size?, outline? }` | `{ size: 48, outline: 2 }` | Collapses a nested group into one tile when all its parts are smaller than `size` × `size` on screen. See [Zoomable layouts](./zoomable-layouts.md#small-panels). | +| `keepPushed` | `boolean` | `true` | Whether panels pushed by a divider stay pushed when it's dragged back. `false` makes each drag work from the layout it started with, so dragging back undoes the pushes. | +| `gestureKeys` | `{ pan?, scale?, rect?, step? }`, each a combo, a list or `null` | `defaultGestureKeys()` | Keys held to pan (drag), scale (drag), zoom to a rectangle (drag) and step (scroll) under free navigation. See [Gesture keys](./navigation.md#gesture-keys). | +| `permissions` | `boolean \| Partial` | `true` | What users may change through the interface: `rearrange`, `resize`, `close`, `float` and `hide`. `false` locks the layout. Calls from code are never limited. See [Permissions](./permissions.md). | +| `direction` | `"auto" \| "ltr" \| "rtl"` | `"auto"` | The layout direction. `"auto"` follows the page, and `"rtl"` mirrors the workspace. See [Right to left](./right-to-left.md). | +| `errorFallback` | `(info: { error, view, retry }) => Node \| string` | | What a view shows when its content fails to mount. See [Handling errors](./errors.md). | +| `renderMenu` | `(request: MenuRequest) => void` | | Shows panel menus with your own component instead of the built-in one. See [Panel menus](./menus.md#render-menus-yourself). | +| `tabs` | `{ fill?: boolean; inset?: number }` | `{ fill: false, inset: 4 }` | Sets the tab style. `fill` stretches tabs across the tab row, and `inset` is the space around them in pixels (`0` is full-bleed). See [Theming](./theming.md#tab-styles). | +| `hideToward` | `(panelId: string) => Element \| Rect \| null \| undefined` | | Picks where the built-in menu's **Hide** animates to, such as your dock or tray button. See [Hiding](./hiding.md#the-built-in-hide). | +| `label` | `string` | `"Workspace"` | The accessible name of the workspace region. | +| `onMissingType` | `(type, id) => "drop" \| "placeholder"` | `"placeholder"` | Decides what happens to a view whose type isn't registered. See [Unknown view types](./layout.md#unknown-view-types). | +| `defaultLayout` | `LayoutDocument \| LayoutSpec \| null` | | The initial layout when nothing is persisted or passed as `document`. Read once, when the workspace mounts. | +| `document` | `LayoutDocument` | | The document to start from. It overrides persistence. Read once, when the workspace mounts. | +| `persist` | `{ key: string; version?: string \| number }` | | Saves the layout to `localStorage` under `key` and restores it from there. A saved layout with a different `version` is discarded. Read once, when the workspace mounts. | To change other options later, call [`update()`](#everything-else). @@ -132,6 +135,7 @@ const types = { | `select(viewId)` | Selects a view's tab without moving focus. | | `setTitle(viewId, title)` | Stores an explicit title. | | `setParams(viewId, patch)` | Shallow-merges `patch` into a view's params. | +| `reportError(error, { viewId?, source? }?)` | Sends an error through the `error` event, as if the workspace had caught it. `source` defaults to `"content"`. See [Handling errors](./errors.md). | | `views({ type? }?): ViewInfo[]` | Returns every view, optionally filtered by type. | | `view(id): ViewHandle \| null` | Returns a view's [handle](#viewhandle). | @@ -177,14 +181,14 @@ Animation targets (`open({ from })`, `hide({ toward })`, `restore({ from })` and ### Everything else -| Member | Description | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `element` | The workspace root element (`.trellis`). | -| `slots` | Elements you can render into. See [Slots](#slots). | -| `surfaces(): readonly Surface[]` | Returns the mount points for every view. See [Surfaces](#surfaces). | -| `run(command)` | Runs a keymap command, such as `"panel.next"`. | -| `update(options)` | Changes options after creation: `types`, `theme`, `tokens`, `floating`, `navigation`, `motion`, `keymap`, `panelMenu`, `renderMenu`, `tabs`, `detail`, `hideToward`, `onMissingType` and `label`. | -| `destroy()` | Unmounts everything and removes listeners. | +| Member | Description | +| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------ | +| `element` | The workspace root element (`.trellis`). | +| `slots` | Elements you can render into. See [Slots](#slots). | +| `surfaces(): readonly Surface[]` | Returns the mount points for every view. See [Surfaces](#surfaces). | +| `run(command)` | Runs a keymap command, such as `"panel.next"`. | +| `update(options)` | Changes options after creation. Every option can change except `defaultLayout`, `document` and `persist`, which are read once. | +| `destroy()` | Unmounts everything and removes listeners. | ```ts ws.update({ theme: "light", tokens: { "--trellis-accent": "#e5484d" } }); // replaces the previous tokens @@ -234,8 +238,11 @@ ws.on("focus", (viewId: string | null) => {}); ws.on("navigate", (framed: string | null) => {}); ws.on("surfaces", (surfaces: readonly Surface[]) => {}); // the set of mount points changed ws.on("camera", (rect: Rect) => {}); // every frame the camera moves, for imperative minimaps +ws.on("error", ({ error, source, viewId, type }: WorkspaceError) => {}); // something in a view or a callback threw ``` +`error` fires when a view's code, a callback or a listener throws. The workspace carries on, and the failing view shows a fallback. Without a listener, errors go to the console. See [Handling errors](./errors.md). + `camera` fires on each animation frame while the camera moves. Use it to draw directly, for example on a canvas or a transformed element. Don't re-render a framework tree from it. ## `ViewHandle` diff --git a/docs/errors.md b/docs/errors.md new file mode 100644 index 0000000..9c345da --- /dev/null +++ b/docs/errors.md @@ -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 + reportToMonitoring(error, { source, viewId })}> + {/* … */} + +``` + +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 + }> + }> + + + +``` + +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. diff --git a/docs/faq.md b/docs/faq.md index 22bbea9..d289cf9 100644 --- a/docs/faq.md +++ b/docs/faq.md @@ -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? diff --git a/docs/keyboard-accessibility.md b/docs/keyboard-accessibility.md index ed03323..9a6d03b 100644 --- a/docs/keyboard-accessibility.md +++ b/docs/keyboard-accessibility.md @@ -95,12 +95,16 @@ The panel menu includes **Move _tab_ to ▸**, a submenu listing the other panel ## Dividers -Dividers are focusable `separator`s. ←/→ (or ↑/↓ for columns) move them by a small step. Hold Shift 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. ←/→ (or ↑/↓ for columns) move them by a small step, in the direction of the arrow. Hold Shift 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 ↑/↓ and Home/End to move, Enter to activate, → to open a submenu, ← or Esc to close it, and Tab 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, ← 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. diff --git a/docs/performance.md b/docs/performance.md new file mode 100644 index 0000000..753c75b --- /dev/null +++ b/docs/performance.md @@ -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. diff --git a/docs/permissions.md b/docs/permissions.md new file mode 100644 index 0000000..30f06be --- /dev/null +++ b/docs/permissions.md @@ -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 +{/* … */} +``` + +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, Delete 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; + +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). diff --git a/docs/persistence.md b/docs/persistence.md index 6621d5d..9c10876 100644 --- a/docs/persistence.md +++ b/docs/persistence.md @@ -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 @@ -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. diff --git a/docs/react-api.md b/docs/react-api.md index 9a644e4..9e00f1c 100644 --- a/docs/react-api.md +++ b/docs/react-api.md @@ -46,35 +46,39 @@ Creates a workspace in a `div` that fills its parent (`width: 100%; height: 100% ``` -| Prop | Type | Default | Description | -| -------------------- | ---------------------------------------------------------------- | --------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `children` | `ReactNode` | | View types, [layout components](#layout-components), and the slots `Workspace.Empty`, `Workspace.Backdrop`, `Workspace.StageEmpty` and `Workspace.Chrome`. | -| `theme` | `"light" \| "medium" \| "dark" \| "darker" \| "system"` | `"system"` | The built-in theme. | -| `tokens` | `Record` | | CSS custom properties set on the workspace root. A new object replaces the previous set, so omitted tokens are removed, and an empty string removes a token. | -| `floating` | `false \| "overlay" \| "stage"` | `"overlay"` | The layer floating panels live in. | -| `navigation` | `false \| "focus" \| "free"` | `"focus"` | The navigation mode. `"free"` adds gestures and a default 480 × 320 content minimum. See [Navigation](./navigation.md). | -| `motion` | `"system" \| "full" \| "reduced"` | `"system"` | The animation policy. | -| `keymap` | `Keymap` | | Changes key bindings with a partial map of command to combo. `null` disables a command. | -| `panelMenu` | `boolean \| (entries, context) => MenuEntry[]` | `true` | Turns the [built-in menu items](./menus.md#the-built-in-items) on or off, or edits every panel's menu with a function. See [Panel menus](./menus.md). | -| `detail` | `false \| { size?, outline? }` | `{ size: 48, outline: 2 }` | Collapses a nested group into one tile when all its parts are smaller than `size` × `size` on screen. See [Zoomable layouts](./zoomable-layouts.md#small-panels). | -| `keepPushed` | `boolean` | `true` | Whether panels pushed by a divider stay pushed when it's dragged back. `false` makes each drag work from the layout it started with, so dragging back undoes the pushes. | -| `gestureKeys` | `{ pan?, scale?, rect?, step? }`, each a combo, a list or `null` | `defaultGestureKeys()` | Keys held to pan (drag), scale (drag), zoom to a rectangle (drag) and step (scroll) under free navigation. See [Gesture keys](./navigation.md#gesture-keys). | -| `renderMenu` | `(request: MenuRequest) => void` | | Shows panel menus with your own component instead of the built-in one. See [Panel menus](./menus.md#render-menus-yourself). | -| `tabs` | `{ fill?: boolean; inset?: number }` | `{ fill: false, inset: 4 }` | Sets the tab style. `fill` stretches tabs across the tab row, and `inset` is the space around them in pixels (`0` is full-bleed). See [Theming](./theming.md#tab-styles). | -| `hideToward` | `(panelId: string) => Element \| Rect \| null \| undefined` | | Picks where the built-in menu's **Hide** animates to, such as your dock or tray button. See [Hiding](./hiding.md#the-built-in-hide). | -| `label` | `string` | `"Workspace"` | The accessible name of the workspace region. | -| `onMissingType` | `(type, id) => "drop" \| "placeholder"` | `"placeholder"` | Decides what happens to a view whose type isn't registered. See [Unknown view types](./layout.md#unknown-view-types). | -| `storageKey` | `string` | | Saves the layout to `localStorage` under this key and restores it from there. Read once, when the workspace mounts. | -| `version` | `string \| number` | | Your layout version. A saved layout with a different `version` is discarded. Read once, when the workspace mounts. | -| `defaultLayout` | `LayoutDocument \| LayoutSpec \| null` | | The initial layout when nothing is persisted or passed as `document`. JSX layout children take precedence. Read once, when the workspace mounts. | -| `document` | `LayoutDocument` | | The controlled layout. It overrides persistence. Pair it with `onDocumentChange`. | -| `onDocumentChange` | `(doc: LayoutDocument) => void` | | Called with each committed layout change. | -| `onOpen` | `(view: ViewInfo) => void` | | Called when a view is added. | -| `onClose` | `(view: ViewInfo) => void` | | Called when a view is removed. | -| `onFocus` | `(viewId: string \| null) => void` | | Called when focus moves. | -| `onNavigate` | `(framed: string \| null) => void` | | Called when the camera frames a node or returns to the overview. | -| `className`, `style` | | | Applied to the host `div`. | -| `ref` | `Ref` | | The [imperative handle](./core-api.md#workspacehandle). `null` until the workspace mounts. | +| Prop | Type | Default | Description | +| -------------------- | ---------------------------------------------------------------- | --------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `children` | `ReactNode` | | View types, [layout components](#layout-components), and the slots `Workspace.Empty`, `Workspace.Backdrop`, `Workspace.StageEmpty` and `Workspace.Chrome`. | +| `theme` | `"light" \| "medium" \| "dark" \| "darker" \| "system"` | `"system"` | The built-in theme. | +| `tokens` | `Record` | | CSS custom properties set on the workspace root. A new object replaces the previous set, so omitted tokens are removed, and an empty string removes a token. | +| `floating` | `false \| "overlay" \| "stage"` | `"overlay"` | The layer floating panels live in. | +| `navigation` | `false \| "focus" \| "free"` | `"focus"` | The navigation mode. `"free"` adds gestures and a default 480 × 320 content minimum. See [Navigation](./navigation.md). | +| `motion` | `"system" \| "full" \| "reduced"` | `"system"` | The animation policy. | +| `keymap` | `Keymap` | | Changes key bindings with a partial map of command to combo. `null` disables a command. | +| `panelMenu` | `boolean \| (entries, context) => MenuEntry[]` | `true` | Turns the [built-in menu items](./menus.md#the-built-in-items) on or off, or edits every panel's menu with a function. See [Panel menus](./menus.md). | +| `detail` | `false \| { size?, outline? }` | `{ size: 48, outline: 2 }` | Collapses a nested group into one tile when all its parts are smaller than `size` × `size` on screen. See [Zoomable layouts](./zoomable-layouts.md#small-panels). | +| `keepPushed` | `boolean` | `true` | Whether panels pushed by a divider stay pushed when it's dragged back. `false` makes each drag work from the layout it started with, so dragging back undoes the pushes. | +| `gestureKeys` | `{ pan?, scale?, rect?, step? }`, each a combo, a list or `null` | `defaultGestureKeys()` | Keys held to pan (drag), scale (drag), zoom to a rectangle (drag) and step (scroll) under free navigation. See [Gesture keys](./navigation.md#gesture-keys). | +| `permissions` | `boolean \| Partial` | `true` | What users may change through the interface: `rearrange`, `resize`, `close`, `float` and `hide`. `false` locks the layout. Calls from code are never limited. See [Permissions](./permissions.md). | +| `direction` | `"auto" \| "ltr" \| "rtl"` | `"auto"` | The layout direction. `"auto"` follows the page, and `"rtl"` mirrors the workspace. See [Right to left](./right-to-left.md). | +| `errorFallback` | `(props: ErrorFallbackProps) => ReactNode` | | Shown instead of a view's content if it throws while rendering. A type's own `errorFallback` wins. See [Handling errors](./errors.md). | +| `renderMenu` | `(request: MenuRequest) => void` | | Shows panel menus with your own component instead of the built-in one. See [Panel menus](./menus.md#render-menus-yourself). | +| `tabs` | `{ fill?: boolean; inset?: number }` | `{ fill: false, inset: 4 }` | Sets the tab style. `fill` stretches tabs across the tab row, and `inset` is the space around them in pixels (`0` is full-bleed). See [Theming](./theming.md#tab-styles). | +| `hideToward` | `(panelId: string) => Element \| Rect \| null \| undefined` | | Picks where the built-in menu's **Hide** animates to, such as your dock or tray button. See [Hiding](./hiding.md#the-built-in-hide). | +| `label` | `string` | `"Workspace"` | The accessible name of the workspace region. | +| `onMissingType` | `(type, id) => "drop" \| "placeholder"` | `"placeholder"` | Decides what happens to a view whose type isn't registered. See [Unknown view types](./layout.md#unknown-view-types). | +| `storageKey` | `string` | | Saves the layout to `localStorage` under this key and restores it from there. Read once, when the workspace mounts. | +| `version` | `string \| number` | | Your layout version. A saved layout with a different `version` is discarded. Read once, when the workspace mounts. | +| `defaultLayout` | `LayoutDocument \| LayoutSpec \| null` | | The initial layout when nothing is persisted or passed as `document`. JSX layout children take precedence. Read once, when the workspace mounts. | +| `document` | `LayoutDocument` | | The controlled layout. It overrides persistence. Pair it with `onDocumentChange`. | +| `onDocumentChange` | `(doc: LayoutDocument) => void` | | Called with each committed layout change. | +| `onOpen` | `(view: ViewInfo) => void` | | Called when a view is added. | +| `onClose` | `(view: ViewInfo) => void` | | Called when a view is removed. | +| `onFocus` | `(viewId: string \| null) => void` | | Called when focus moves. | +| `onNavigate` | `(framed: string \| null) => void` | | Called when the camera frames a node or returns to the overview. | +| `onError` | `(error: WorkspaceError) => void` | | Called when something in a view or a callback throws. Without it, errors go to the console. See [Handling errors](./errors.md). | +| `className`, `style` | | | Applied to the host `div`. | +| `ref` | `Ref` | | The [imperative handle](./core-api.md#workspacehandle). `null` until the workspace mounts. | `theme`, `tokens`, `tabs`, `detail`, `floating`, `navigation`, `motion`, `keymap`, `panelMenu`, `renderMenu` and `label` can change at any time. Event props, `onMissingType`, `hideToward`, a `panelMenu` function and `renderMenu` always call the latest function. @@ -162,26 +166,27 @@ Registers a kind of view. It renders nothing itself and must be a direct child o ``` -| Prop | Type | Default | Description | -| ----------- | -------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `id` | `string` | | **Required.** The type id that `` and `open(type)` refer to. | -| `title` | `string \| (view: ViewHandle) => string` | The type id | The tab label. | -| `icon` | `ReactNode \| string` | | The tab icon: a React node, or trusted SVG/HTML markup. Without one, the empty icon slot takes no space. | -| `children` | `ReactNode` | | The content, rendered once per view. Use [`useView()`](#useview) inside. | -| `render` | `(view: ViewApi) => ReactNode` | | Renders content from the view's state, as an alternative to `children`. | -| `accessory` | `ReactNode \| (view: ViewApi) => ReactNode` | | Content for the tab bar while this view is selected. | -| `iframe` | `string \| IframeOptions \| (view: ViewHandle) => string \| IframeOptions` | | Renders an iframe instead: a URL, or [`IframeOptions`](./core-api.md#iframeoptions). It reloads only when the computed options change. | -| `mount` | `(element, view, parts) => void \| (() => void)` | | Renders vanilla content instead of React content. See [`ViewTypeDefinition`](./core-api.md#viewtypedefinition). | -| `menu` | `MenuEntry[] \| (view: ViewHandle) => MenuEntry[]` | | Items at the top of the panel menu while this view is selected. | -| `placement` | `Placement` | | The default [placement](./core-api.md#placement) for `open()`. | -| `allow` | `{ stage?, side?, floating? }` | All `true` | The regions users may drop this view into. | -| `singleton` | `boolean` | | Allows at most one instance. `open()` focuses the existing one. | -| `closable` | `boolean` | | `false` removes the close button and ignores close commands. | -| `minSize` | `{ width, height }` | 480 × 320 with `navigation="free"`, none otherwise | The smallest size content lays out at. Below it, content scales down. | -| `scaling` | `"interactive" \| "inert" \| false` | `"interactive"` | Below `minSize`: scaled content takes input, ignores it (`"inert"`), or isn't scaled at all (`false`). See [Small panels](./zoomable-layouts.md#small-panels). | -| `tabbar` | `"always" \| "auto" \| "never" \| "overlay"` | `"always"` | When the tab bar shows. See [Tab bar modes](./core-api.md#tab-bar-modes). | -| `gestures` | `"content" \| "exclusive" \| "workspace"` | `"content"` | Who gets gestures over the content under free navigation. `"exclusive"` keeps pinch for the content; `"workspace"` lets a plain scroll step the workspace. See [Gesture ownership](./navigation.md#gesture-ownership). | -| `className` | `string` | | An extra class on the view's surface. | +| Prop | Type | Default | Description | +| --------------- | -------------------------------------------------------------------------- | -------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `id` | `string` | | **Required.** The type id that `` and `open(type)` refer to. | +| `title` | `string \| (view: ViewHandle) => string` | The type id | The tab label. | +| `icon` | `ReactNode \| string` | | The tab icon: a React node, or trusted SVG/HTML markup. Without one, the empty icon slot takes no space. | +| `children` | `ReactNode` | | The content, rendered once per view. Use [`useView()`](#useview) inside. | +| `render` | `(view: ViewApi) => ReactNode` | | Renders content from the view's state, as an alternative to `children`. | +| `accessory` | `ReactNode \| (view: ViewApi) => ReactNode` | | Content for the tab bar while this view is selected. | +| `iframe` | `string \| IframeOptions \| (view: ViewHandle) => string \| IframeOptions` | | Renders an iframe instead: a URL, or [`IframeOptions`](./core-api.md#iframeoptions). It reloads only when the computed options change. | +| `mount` | `(element, view, parts) => void \| (() => void)` | | Renders vanilla content instead of React content. See [`ViewTypeDefinition`](./core-api.md#viewtypedefinition). | +| `menu` | `MenuEntry[] \| (view: ViewHandle) => MenuEntry[]` | | Items at the top of the panel menu while this view is selected. | +| `placement` | `Placement` | | The default [placement](./core-api.md#placement) for `open()`. | +| `allow` | `{ stage?, side?, floating? }` | All `true` | The regions users may drop this view into. | +| `singleton` | `boolean` | | Allows at most one instance. `open()` focuses the existing one. | +| `closable` | `boolean` | | `false` removes the close button and ignores close commands. | +| `minSize` | `{ width, height }` | 480 × 320 with `navigation="free"`, none otherwise | The smallest size content lays out at. Below it, content scales down. | +| `scaling` | `"interactive" \| "inert" \| false` | `"interactive"` | Below `minSize`: scaled content takes input, ignores it (`"inert"`), or isn't scaled at all (`false`). See [Small panels](./zoomable-layouts.md#small-panels). | +| `tabbar` | `"always" \| "auto" \| "never" \| "overlay"` | `"always"` | When the tab bar shows. See [Tab bar modes](./core-api.md#tab-bar-modes). | +| `gestures` | `"content" \| "exclusive" \| "workspace"` | `"content"` | Who gets gestures over the content under free navigation. `"exclusive"` keeps pinch for the content; `"workspace"` lets a plain scroll step the workspace. See [Gesture ownership](./navigation.md#gesture-ownership). | +| `errorFallback` | `(props: ErrorFallbackProps) => ReactNode` | The workspace's | Shown instead of this type's content if it throws while rendering. `props` is `{ error, view, retry }`. | +| `className` | `string` | | An extra class on the view's surface. | When a type has more than one kind of content, `iframe` wins, then `mount`, then `render`, then `children`. diff --git a/docs/right-to-left.md b/docs/right-to-left.md new file mode 100644 index 0000000..3a2fa8b --- /dev/null +++ b/docs/right-to-left.md @@ -0,0 +1,47 @@ +--- +title: Right to left +description: Mirrored layouts for right-to-left languages, and what changes for users and for your code. +section: Guides +order: 18.5 +nav: Right to left +--- + +# Right to left + +For right-to-left languages such as Arabic, Hebrew and Persian, Trellis mirrors the whole workspace. It follows the page's direction automatically: + +```html + +``` + +To set it on the workspace instead, pass `direction`: + +```ts +createWorkspace(el, { types, direction: "rtl" }); +``` + +```tsx +{/* … */} +``` + +`direction` is `"auto"` by default, which follows the `dir` attribute or CSS `direction` around the workspace. `"ltr"` and `"rtl"` override the page. With `"auto"`, Trellis reads the direction when the workspace starts and on every `ws.update()`, so call `ws.update({})` after changing the page's direction. + +## What mirrors + +| Part | Right to left | +| ------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Layout | A row's first child sits on the right. Floating windows are placed from the right edge. | +| Tabs | Tabs run right to left, with the panel menu at the end. ← selects the next tab. | +| Menus | Menus open toward the start edge. Submenus open to the left, ← opens one and → closes it. Key combinations still read left to right. | +| Dividers | Arrow keys move a divider the way they point. | +| Pointer input | Dragging dividers, docking, reordering tabs, resizing floating windows, zooming into collapsed groups and every navigation gesture follow the pointer. | + +The **New split right** menu item becomes **New split left**, because a new split opens toward the end edge. + +## Your layouts don't change + +Layout documents don't record a direction. A row's first child is at its start edge, whichever side that is, so the same saved layout mirrors when the direction changes. Floating rects measure `x` from the start edge too. + +This applies to code as well. Placement edges mirror with the layout, so in a right-to-left workspace `{ beside: "stage", edge: "left" }` puts a panel on the stage's start side, which is on the right. The same code puts a sidebar at the start of the reading direction in every language. + +Your content inherits the workspace's direction through normal CSS inheritance, so write its styles with logical properties such as `margin-inline-start`. diff --git a/docs/security.md b/docs/security.md new file mode 100644 index 0000000..a2354d0 --- /dev/null +++ b/docs/security.md @@ -0,0 +1,48 @@ +--- +title: Security +description: What Trellis does and doesn't do with data, the network, storage and the DOM, for security reviews. +section: More +order: 40.6 +--- + +# Security + +Trellis is a layout library that runs entirely in the page. It has no server component, and it only handles the layout document and the content your app gives it. This page answers the questions security reviews usually ask. + +## Data + +| Question | Answer | +| --------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| What data does it handle? | The layout document: where each view is, and each view's type, title and `params`. Your app decides what goes in `params`. Trellis never reads the content inside views. | +| Does it send data anywhere? | No. It makes no network requests of any kind: no `fetch`, XHR, WebSockets, beacons or dynamic imports. | +| Does it collect telemetry? | No. There's no analytics, telemetry, error reporting or licence check. | +| Where does it store data? | Only in `localStorage`, and only when you set `persist` (`storageKey` in React). It writes one entry, under the key you choose. It uses no cookies, `sessionStorage` or IndexedDB. | +| Does it read the clipboard? | No. | +| Does it run arbitrary code? | No. It never uses `eval`, `new Function` or string timers. | + +## Content and the DOM + +- Trellis mounts your views' content once and moves it with CSS. It never reads or copies what's inside. +- Iframe views load the URL or `srcdoc` you give them, with the `sandbox`, `allow` and `referrerPolicy` you set. Trellis never reaches into an iframe's document or messages it. +- Titles, labels, error messages and other text are set as text, never parsed as HTML. +- The only markup Trellis parses is icon markup you pass as a string: a view type's `icon`, or a web component's `data-icon`. Treat it as trusted code, and never build it from user input. In React, pass icons as elements instead. + +## Content Security Policy and Trusted Types + +Trellis works under a strict Content Security Policy. It sets styles through the style object, not inline style attributes, so `style-src-attr 'none'` is fine. It doesn't inject `