---
title: Pages
description: "A Notion-style editor any app can mount: blocks, folders, boards, forms and sign-ups."
section: Frontend
order: 5
---

# Pages

<p class="lead"><code>sluurp/pages</code> is a Notion-style editor any app can mount: blocks, nested pages, a wiki, books, a link graph, version history, PDF export and <a href="/docs/charts">charts</a>.</p>

- **Blocks:** text, headings, lists, to-dos, quotes, code, tables, images, embeds, charts, boards, figures, pull quotes, and nested pages.
- **Inline Markdown, Typora-style:** `**bold**`, `*italic*`, `` `code` ``, `~~strike~~`, `==highlight==` and `[text](url)` are formatted as you type; the markers show faintly on the current line and hide when you leave it. `Ctrl+B` / `I` / `E`, `Ctrl+Shift+X` / `H` and `Ctrl+K` (link) toggle formatting on the selection. Lines are stored as Markdown.
- **Reading mode, books view, and a graph** of links between pages.
- **Version history** in the core change log (who, when, why), with restore.
- **Ask AI / chat about a page,** when a model is configured.

### Magazines and articles

A folder can be shown as a magazine front page: the newest article large, the rest as cards. Any page can use the article layout: folder name as kicker, a large serif headline, the description as deck, a byline with reading time, a cover image, a narrow column with a drop cap, and figures (with caption and credit) at column, wide or full width, or floated left/right, rectangular or round.

In reading mode, articles are typeset line by line with [Pretext](https://github.com/chenglou/pretext):

- Text wraps around floated and round figures and the drop cap, following a round image's curve.
- Paragraphs are justified with Knuth–Plass: all line breaks of a paragraph are optimised together to minimise stretched spaces, so a loose line never follows a tight one. Rivers (spaces over 1.5× normal) and over-squeezed spaces (under ⅔) are avoided when possible.
- Hyphenation uses TeX's Liang patterns for English, French, German and Romanian, with a penalty per hyphen and a bigger one for consecutive hyphens. The last line is ragged.
- Bold, italic, code and links are preserved; lines are real text, so selection, copy, Comment and Ask work; screen readers still get paragraphs.
- Tables, boards and charts render normally between typeset runs.

An article's PDF uses the same layout on A4, embedding Source Serif 4 (the font the lines were measured with), so text wraps around round images on paper too. Colour emoji are drawn as images; elsewhere in PDFs they use Noto Emoji.

### Boards

A board is a FigJam-style whiteboard inside a page: sticky notes (with author), text, shapes and sections. Pan and zoom with the wheel, pinch or `Space`+drag; select, move, resize, recolour, duplicate, undo and redo. Connectors (`C`, or drag from a selected note's dot) join items with arrows, optionally labelled, so a board can become a concept map. Everyone on the page sees changes and each other's cursors live.

Multiplayer works like Figma's:

- A change carries only the fields it touched, and the server merges them. Two people editing different fields of the same note (one moves, one recolours) both keep their change; for the same field, last write to the server wins.
- The server sequences every change and broadcasts it, so all clients apply changes in the same order. While your own change is in flight, others' changes to that field are held back, so values don't flicker.
- A reconnecting client reloads the board and replays its unacknowledged changes.
- Undo reverts only your own changes, computed against the board's current state, so redo never clobbers someone else's edit.
- Changes are appended to a journal; every 50 changes the board is compacted into its page and the journal trimmed, so busy boards don't rewrite the page on every drag. Reads apply newer journal rows on top.
- Z-order uses fractional indexes, so two people bringing items to front at once don't collide.

Also on boards:

- **Timer** (1, 3, 5 or 10 min, +1 min, pause, stop), synced across screens, with a soft chime.
- **Voting stamps** (`B`): click again to unvote; stamps move with their note; sections show vote totals; "Clear votes" resets.
- **Pen** (`P`) with smoothing, six inks and three widths; **eraser** (`E`).
- **Laser pointer** (`K`): a fading trail everyone sees, not saved.
- **Images** from the toolbar or by pasting; large ones are downscaled, stored with the page, and count toward quotas.
- **Mind maps** (`M`): `Tab` adds a child, `Enter` a sibling, auto-laid out to the right with a colour per first-level branch and curved links. **Ideas** asks the AI model for branches to keep, edit or delete. Mind maps are ordinary notes and links, so they can grow into a concept map.
- **Style panel** (like Excalidraw) for shapes, strokes and connectors: stroke and fill colour, fill style (hatched, cross-hatched, solid), stroke width and style, sloppiness (neat, hand-drawn, scribbled, via rough.js), corners, arrowheads, opacity and layer order. Sketches are seeded by id, so they look identical everywhere.
- Board text is indexed for search.

## Try a page

This one is editable: type `/` for the block menu, drag blocks by their handle, select text to format it. In an app, pages are saved with history; this demo only lives in this tab.

<sluurp-island src="page-demo" client="visible" isolated class="my-6 block"></sluurp-island>

## Folders: sections made of pages

A folder is a page that contains pages and lists them as rows. That's how a section of an app (say, a catalogue of courses or bookable rooms) is built from pages instead of custom screens.

- **Properties.** The folder defines fields each child page has (`props`): a day, a teacher, a capacity. Each page fills them in (`values`) under its title.
- **Formulas.** A `formula` property is computed from the page's values and its table: `rows` is the row count, `consent.yes` counts rows where `consent` is "yes" (any field/value, lower case), `cost.sum` sums a numeric column. `places - rows` gives remaining places, updating as people sign up.
- **New.** The folder names a template; "New" creates the next page from it inside the folder.
- **Navigation.** A folder marked `nav` becomes an app section for everyone who can read it, with its own icon. Only its creator or a superuser can set this.
- **Icons.** An emoji, a built-in icon (`icon:calendar`), or custom SVG (`svg:<svg…>`). SVGs are sanitised to shapes and paint: no scripts, handlers, links or styles.

```json title="migrations/V6__optionals.json"
{ "pages": [
  { "id": "optionals", "name": "Optionals", "kind": "folder", "nav": true,
    "template": "page:optionaltpl",
    "props": [
      { "name": "places", "title": "Places", "type": "number" },
      { "name": "places_left", "title": "Places left", "type": "formula", "formula": "places - rows" }
    ] },
  { "id": "chess", "name": "Chess club", "parent": "optionals",
    "values": { "places": 12 }, "fields": [{ "name": "pupil", "type": "relation", "relation": "users" }] }
] }
```

Property types: `text`, `number`, `date`, `bool`, `select` (with `values`), `person` and `formula`. Table columns can also be `file`; table views are `table`, `board`, `timeline` or `gallery`.

Columns are defined by their label. In the columns dialog you type the question or heading, e.g. "May your child come?", and it gets a slug (`may_your_child_come`) for rules and filters, with the label kept as the heading. That's how a form's questions become columns. Labels can be edited any time; the slug stays.

The same dialog builds the rest of a form. **Yes or no** creates a two-option select in one click. **Required** is the red star after a question: respondents see it starred, plus a list of what's still unanswered. Reorder questions by dragging the icon (it becomes a handle on hover) or with arrow keys. Stored per column in the page's `columns`: `{ "may_your_child_come": { "title": "May your child come?", "required": true } }`.

A **file** column holds an image or document, shown as a thumbnail in the cell. Tables with a file column can use the **gallery** view: a grid of images with titles. **Add pictures** uploads several at once, one row each. Opened images show full size, with arrow keys or swipe for next/previous.

### Actions

An `action` block (`{ "type": "action", "action": "tell" }`) places a button that runs app code. The app defines the actions; pages just place them:

```ts title="app.ts"
configurePages({
  actions: () => ({
    tell: {
      label: (page) => (page.values?.told ? "Tell them again" : "Tell the families"),
      may: () => isStaff(),
      confirm: () => "Each family gets a message with the trip and the link to answer.",
      run: async (page) => `${await writeToFamilies(page)} families told`,
    },
  }),
});
```

`may` controls who sees the button (default: page editors), `confirm` asks for confirmation, and `run`'s return value is shown as a toast. A school trip page might have two: notify the families, and bill the ones who said yes.

### Panels

Apps can add side panels to any page, opened from a header button: e.g. a discussion thread or a checklist about the page.

```ts title="app.ts"
configurePages({
  panels: () => [{
    name: "discuss",
    label: () => "Discuss",
    icon: () => discussIcon,
    render: (page) => discussion(page.id),
  }],
});
```

`render` runs lazily when the panel opens. `may` controls which pages and users see it (default: all).

### Sign-ups

A page can accept sign-ups: users add their own row to its table without edit rights on the page. E.g. a student joining a course, or a parent signing up their child.

```json title="A page's join"
"join": {
  "rule": "@request.auth.roles ~ \"pupil\" || @request.auth.roles ~ \"parent\"",
  "limit": "places",
  "open": "open",
  "self": { "field": "pupil", "via": "custodies.child.custodian" }
}
```

- `rule`: who can join.
- `limit`: max rows, a number or the name of a page value.
- `open`: a page value that must be true for sign-ups to be open.
- `label`: button text ("Answer" for forms; default "Sign up").
- `self`: the field identifying who a row is for, either the joiner or, with `via`, someone they're responsible for. `custodies.child.custodian` means: a `custodies` row whose `child` is the row's person and whose `custodian` is the joiner.

All of this is enforced server-side, and each row's creator is recorded in `by`. Joiners can edit their own row's answers but not who it's for or who owns it, and can withdraw only their own rows. Editors can add and remove anyone. The last place can't be taken twice by concurrent sign-ups. In the UI, joiners get a "Sign up" button (with a name picker if they sign up someone else) and a list of their sign-ups with a withdraw button.

With `"claim": true`, joiners claim existing rows instead of adding them. Editors create the slots (e.g. 15-minute parent meetings or a volunteer rota); users pick a free one under **Choose a time**, fill it in, and can release it. One each, or up to `limit`. They can't add or delete rows, edit a slot they don't hold, or take someone else's; concurrent claims on one slot can't both win. Editors see who holds each slot; users don't see each other. Over the API, claim with `{"by": "<user id>"}` and release with `{"by": null}`.

Charts in a page are chart blocks; see [Charts](/docs/charts).
