Pages and charts

sluurp/pages is a Notion-like editor any app can mount. It has blocks, nested pages, a wiki, books, a graph of links, version history and PDF export, and charts written either as a sentence in a small English-like language or as a few lines of JavaScript.

  • Blocks: text, headings, lists, to-dos, quotes, code, tables, images, embeds, charts, and pages within pages.
  • A reading mode, a books view, and a graph of how pages link to each other.
  • Every version is kept in the core change log, with who, when and why, and any of them can be restored.
  • Ask AI and chat with AI about a page, when a model is set up.

Try a page

This one is editable: type / for the menu, drag a block by its handle, select words to format them. In an app, a page is saved with its history; this one lives only in this tab.

Folders: sections made of pages

A folder is a page that holds pages and lists them, one row each. It’s how a section of an app, such as a school’s optionals or the rooms there are to book, is made of pages instead of a screen someone had to code.

  • Properties. The folder says what each page in it has (props): a day, a teacher, how many places. Each page fills them in (values), under its title.
  • Formulas. A formula property is worked out, not filled in, from the page’s values and its table: rows is how many rows it has, consent.yes how many say yes (any field and value, lower case), and cost.sum a number column added up. places - rows is the places left, and goes down as people sign up.
  • New. The folder names a template, and “New” makes the next page from it, inside the folder.
  • In the navigation. A folder marked nav is a section of the app for everyone who may read it, with its own icon. Only its maker or a superuser may set that.
  • Icons. A page’s icon is an emoji, one of the app’s icons (icon:calendar), or an SVG of your own (svg:<svg…>). Before it’s drawn, an SVG is cleaned down to shapes and paint: no scripts, handlers, links or styles.
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 are text, number, date, bool, select (with values), person and formula. A table’s columns may also be file, and its views table, board, timeline or gallery.

A table’s columns are written by their words. In its columns dialog you type what a column asks or holds, “May your child come?”, and it gets a short name of its own (may_your_child_come) for rules and filters, with the words kept as its heading. That’s how a form’s questions are its columns. A column’s words can be changed at any time; its name stays.

The same dialog makes the rest of a form. Yes or no is a choice of the two, made at once. Required marks a question with a star where families answer, and lists what is still to answer until it is. The arrows move a question up or down. Stored per column in the page’s columns: { "may_your_child_come": { "title": "May your child come?", "required": true } }.

A file column holds a picture or a document; its cell shows the picture small, and an editor puts a new one there. A table with a file column can be shown as a gallery: its rows as a grid of pictures, each over its title. Add pictures takes several at once and makes a row for each. A picture opened is shown large, and the next one is an arrow key or a swipe away.

Actions

A page can have buttons that do what the app can do: an action block, { "type": "action", "action": "tell" }. The app says what each action is, and the page only places it:

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 says who sees the button (whoever may edit the page, if left out), confirm asks first, and what run returns is shown. MikroSchool’s trips use two: telling a class’s families, and charging the ones who said yes through Finances.

Sign-ups

A page can take sign-ups: people add a row of their own to its table without being able to edit the page. That’s how a pupil joins an optional, or a parent signs up their child.

"join": {
  "rule": "@request.auth.roles ~ \"pupil\" || @request.auth.roles ~ \"parent\"",
  "limit": "places",
  "open": "open",
  "self": { "field": "pupil", "via": "custodies.child.custodian" }
}
  • rule says who may join.
  • limit is how many rows there may be: a number, or one of the page’s values.
  • open names the value that must be true for anyone to join.
  • label is what the button says: “Answer” for a form, “Sign up” when left out.
  • self is the field saying who a row is for: the person joining, or, with via, somebody they answer for. custodies.child.custodian means a custodies row whose child is that pupil and whose custodian is the person signing up.

The server holds to all of it and fills in who added each row (by). A joiner can change their own row’s answers, but not whom it’s for or whose it is. Someone who joined can take their own row back, but no one else’s. Staff and editors add and remove anyone. Two people taking the last place at once can’t both get it. In the app, a joiner sees a “Sign up” button, with a choice of names when they answer for someone, and their own sign-ups, each with a button to take it back.

With "claim": true, joiners take rows instead of adding them. Editors set out the times, such as a parents’ evening in quarters of an hour or a volunteer rota. A family picks a free time under Choose a time, answers on it, and can give it back. They take one each, or up to limit. They can’t add or delete rows, answer on a time they don’t hold, or take one somebody else holds; two people choosing the same time at once can’t both get it. Editors see who took each time, and families don’t see other families. Over the API, a joiner claims a row with {"by": "<their id>"} and gives it back with {"by": null}.

The chart language

A chart block reads a collection’s rows, as whoever is looking, and shapes them for drawing in one of two ways:

  • A sentence such as mean value by subject sorted. It is parsed, never run as code, so it needs no worker and cannot do harm. This is the language below.
  • A few lines of JavaScript, for anything a sentence can’t say: return count(rows, "status"). They run in a worker of their own, with the rows and some helpers and nothing else (not the page, not the session), and are stopped after two seconds.

The block tells the two apart by itself: text that starts with a measure and has no code punctuation is a sentence.

return groupBy(rows, "class").map(([label, list]) => ({ label, value: mean(list, "value") }));

The sentence language:

count grades by subject
mean value from grades by subject sorted
percent by status

# stacked, one series per status
count by month of date and by status
count by subject top 5                            # the rest as "Other"
mean value by week of date rolling 4
sum (first_half + second_half) / 2 by month of date
values value against date where subject = Mathematics   # a scatter

# as the marks stood then
mean value by subject as of 2026-06-30
PartForms
Measurescount percent sum mean median min max distinct values
Groupingsby field, by month|week|weekday|year of date, by n every 5; a second grouping after “and” makes series: by class and by subject
Conditionswhere a = x and b >= 3 and roles has pupil
Endingstop n, sorted, cumulative, rolling n, as of 2026-06-30

Linked records are shown by name; month charts run from the first month with data to the last, and empty months and bins between stay in: a count there is 0, while an average of nothing is a gap in the line, not a fall to 0. The language has its own unit tests, which run in Node with no build.

Live tables, shared charts

A page’s table can be Live: its editors switch it on in the table’s toolbar. A live table reads its rows through sync, so everyone’s changes appear as they’re made. That’s what a kanban board, a trip’s planning or a sign-up sheet wants when several people work on it at once. While it’s small (up to 2,000 rows), it’s also kept on the device in IndexedDB, for the person signed in, so a reload shows it at once. Signing out removes it. A table that isn’t live is read when the page opens.

All the charts on a page share their data: each collection, or the slice of it a chart asks for, is read once for the page. In a chart’s settings:

  • Only rows where narrows its first source with the filter language (class = "class4a0000000"), so only that slice is read.
  • Live draws it again whenever its rows change, here or at someone else’s desk, through sync. Off by default: a chart sums up many rows, and one that doesn’t move is easier to read.

Try it

Type a sentence, or pick one, and it is read and drawn over a year of a small shop’s orders (category, country, shipper, date, total, shipped), in the spirit of Northwind:

Charts with Plotly

For charts a sentence or the kit does not draw (heatmaps, sunbursts, box plots, maps, 3D), a JavaScript chart block returns a Plotly figure, { plotly: { data, layout } }, and it is drawn in the page’s look and palette. Plotly is in the binary, fetched only when such a chart comes into view. Any page or island can draw one too, with plot() from sluurp/plotly; these three are islands, over the same orders: