Skip to content

Regions

A page is a list of regions. A region reads one table and renders it in one shape. Every region declares which table it is about, and can narrow the page’s access rule for itself.

page members "Members" {
route: /members
access: board
report of members "All members" {
columns: name, email, fee_group
search: name, email
sort: name
page_size: 25
}
}

Each property is written once per block. A second page_size: in the same report, or a second clear: in the same duplicate block, is a compile error at the second line, and the message gives the line of the first. A property that takes a list takes it on one line: columns: name, email, fee_group. This rule applies to every block: app, table, column, page, region, field, and access rule.

Three region kinds render rows in a table, and they differ in where the columns come from and whether a cell accepts typing:

Region Columns come from Editable Status
report the schema no shipped
sheet the schema yes shipped, save, add, and delete, in an editing grid
matrix the data not yet shipped, read-only

Rows of one table, for reading. A report supports search, filters, sortable columns, pagination, export, row links, and an empty state.

Property Meaning
columns which columns, in which order
search columns that one search box searches
filter columns offered as filters
sort the initial order
page_size rows per page. Without it, a report shows every row.
where a fixed condition on top of the access rule
export any of csv, xlsx, and ics, comma separated
export_name the name in the export’s address. Default: the region’s table.
export_button true puts the downloads under their own Export control. Default false: they are in the menu of the first button.
striped true draws every second row on a faint background. Default false.
empty the text shown when there are no rows

striped: true is the one property that changes only how a report looks.

report of members "Members" {
columns: last_name, first_name, fee, joined
striped: true
}

Use it on a wide table of short values: a band keeps the eye on one row while it reads across. Leave it off for a short table, or one whose cells hold sentences — there the band is noise. No rule can tell the two apart, so the engine does not guess.

striped: belongs to a report and to nothing else. A list draws cards and a detail draws one record, so neither has rows to alternate; the compiler refuses the property there and names the region that draws a table.

A report draws one row between its heading and its table. The search box and the filters are on the left, and the region’s actions are on the right. When the report has no search box and no filter, the actions are in the region’s heading.

The filters are behind one Filters button. It opens a panel with one control for each column in filter:, and Apply reloads the list. The control depends on the column’s type:

Type Control Rows shown
enum, ref a checkbox for each value rows with any of the ticked values. No tick shows all rows.
date From and To date fields rows from the first day to the last day, both included. Leave one field empty to leave that end open.
bool All, Yes, or No all rows, only yes, or only no
other types a text field rows whose value is equal to the text

Two filters narrow together: a row must match both. A filter’s address is part of the page’s address, so a filtered list can be bookmarked. ?status=active&status=passive ticks two values, and ?joined_on.from=2026-01-01&joined_on.to=2026-03-31 sets a range. The engine ignores a value that the column cannot hold. Each filter that is set shows as a chip next to the button, for example Status: Active ×, and the button shows how many are set. A chip is a link to the same list without that filter: the search, the sort, and the other filters stay. Clear filters in the panel removes every filter and keeps the search. Enter in the search box submits the search together with the filters that are set.

The first button is the region’s primary action, and it is always visible. Every other action is in a menu that opens from the arrow beside it: the other buttons first, then the downloads.

report of members "Members" {
export: csv, xlsx
button "New member" -> member
button "Invite" -> invitation
}

This report shows New member with an arrow. The arrow opens a menu with Invite, CSV export, and Excel export. A report with only one button and no export shows the button without an arrow.

Write export_button: true to give the downloads their own Export control beside the primary action. Use it where readers download often, for example a report that somebody exports every month.

report of members "Members" {
export: csv, xlsx
export_button: true
button "New member" -> member
}

With one download format, the control is a single link, such as CSV export. With two, it is an Export menu. A report without a button always shows its downloads in this way. export_button: needs export: csv or export: xlsx. A calendar feed (export: ics) has its own block under the heading, so the compiler refuses export_button: on a region whose only export is ics.

The menus open and close without JavaScript. With JavaScript, Escape closes a menu, and so does a press outside it.

export: csv and export: xlsx each draw a download control. Neither implies the other. A CSV is the format a program reads; a workbook is the format a person opens, and its cells carry a type, so a number adds up and a date sorts in every locale. See Export a listing.

An export’s address is the page’s own address with ?mtd_export=<name> for a CSV and ?mtd_export=<name>.xlsx or ?mtd_export=<name>.ics for the other two. The name is the region’s table, so report of members { export: csv } is at ?mtd_export=members. The name does not change when you add a note or a region above the listing. This matters for a calendar, because a phone asks for the same address for years.

Write export_name: only when one page has two exports of the same table. The compiler refuses such a page and names the lines:

list of events "Upcoming" {
where: starts_at >= now
export: ics
at: starts_at
}
list of events "Past" {
where: starts_at < now
export: csv
export_name: past
}

The first list is at ?mtd_export=events.ics and the second at ?mtd_export=past. export_name: is one name without quotation marks. It cannot be a number or contain a dot, and it needs export:.

With export: ics, ten more properties say how a row becomes a calendar entry. Each one names a column of the region’s table, except alarm_before, which may read a value from the page, url, which takes one word, and url_note, which takes a sentence.

Property Column kind Entry
at date or datetime. Required with export: ics. when it starts
ends the same kind as at when it ends
place text the location
details text, long text, or markdown the description
repeat three columns: an enum over none/daily/weekly/monthly/yearly, an int, a date the repeating series, as one rule
all_day bool a true row is a day with no time
alarm bool a true row may remind the reader. Absent means every row may.
alarm_before an int column, or :<data row>.<int column> minutes before. 0, a negative number, or nothing sends no reminder.
url the word page the entry links back to the page
url_note a sentence in quotation marks written above that address in the description

A reminder is sent only when alarm and alarm_before both say yes. alarm: without alarm_before: is a compile error. all_day: beside a date at: is a compile error too. A date has already said that every row lasts all day. url_note: without url: is a compile error as well: there is no address for the sentence to introduce.

Status: a reminder in a subscribed calendar is honoured by Apple Calendar, ignored by Google Calendar, and handled differently by Outlook versions. See Export data for what to tell your readers.

The title of an entry is the table’s display. Which rows are in the calendar is the listing’s own query: the page’s access rule, the region’s where, and the reader’s filters.

Every column in columns is sortable. Sorting and filtering go through the compiled model. A request parameter never reaches SQL as text.

A row -> link and a row_action link are drawn only where the reader can open the target page with that row. The engine checks the target page’s access rule once, and the target’s row conditions for each row: its access filter, its data … where, and the access rule of a form or detail on it. The report still lists a row that the target page would not show. The row has no link, and its identity cell is plain text.

A multi and a list cannot be columns. Both are sets of rows rather than values. Count one instead, or ask whether it is empty.

The same rows as a report, in a layout for reading rather than for administration. A list has no search box and no filters. Use a list for a public page, and a report for a screen that somebody works in.

One row, as a record. A detail shows fields in a metadata line rather than in cells, so it can show a multi or a list as a set of links. It is the right region for an article, a member file, or any page that is about one row.

Counts as tiles. A stats region groups a table and shows the totals.

A crosstab whose columns are generated from the data. The rows come from one table, the columns from another, and each cell is a row of a junction table.

matrix of grades "Grades" {
rows of enrolments { where: course = :course.id label: student }
columns of assessments { where: course = :course.id label: name }
cell: points
key: enrolment, assessment
empty: "—"
}

key: names two ref columns of the cell table, one for each axis. The compiler checks exactly that.

A matrix is read-only today. Writing a cell needs upsert-on-key, because a cell addresses a row that can be absent.

NOTE: Both axes cannot read the same table. The two keys do not say which axis they address, and source order is not allowed to decide it. The compiler refuses that shape and names the reason.

The history of the page’s record: every version of its versioned columns, newest first.

page post "Post" {
route: /posts/{id}
access: editor, author
data post of posts
form of posts { fields: title, body }
versions of posts "History" { access: editor }
}

A versions region takes access: and nothing else. Its record is the page’s, and its columns are the table’s versioned columns. The compiler refuses it when the table keeps no versions, and when the page’s route does not bind a record of the table.

Each version shows:

Column Shows
Saved the time of the save, in the page’s time zone
By the person’s profile name, the role when there is no profile, “A former user” when the login was deleted, and “A visitor without a sign-in” for a save without one. Never an e-mail address
Changed the versioned columns that differ from the version before
Status then the record’s lifecycle state after the save, on a table with a lifecycle

Compare shows the change from the version before at ?mtd_compare=<a>,<b>, as an ordinary page. Text, long text, Markdown, and e-mail columns are compared line by line. A Markdown column compares its source text. Each line is marked with <ins> or <del>, a + or − sign, and the word “Added” or “Removed” for a screen reader, so the colour is never the only signal. Other columns show the value before and after. When two texts are too different to compare within the limits (20,000 lines on a side, or 600 changed lines), the two texts stand side by side instead.

Restore opens the page’s own form at ?mtd_restore=<version>. The versioned fields hold the version’s values, and every other field holds the current value. Nothing changes until the person presses Save, and the save is an ordinary save: every rule, deny when, and hook runs, a ref must name a record the person may choose, and a Markdown image must be a picture they may open. The save adds a new version; it never rewrites an old one.

If somebody saved the record after the restore form was opened, the first Save is refused with a message, and the form comes back with what was typed. Pressing Save again restores anyway.

Seeing and restoring are two permissions.

  • Seeing needs the region’s access: and the page’s record. A region without its own access: follows the page’s rule, except that a visitor without a sign-in never sees it. Write access: public to show a history to everybody.
  • Restoring also needs a form on the page that edits this record for this person and has a versioned field that is not readonly:. Without one, the Restore link is not drawn and the address answers 404.

A version of another record, like a version that does not exist, answers 404. So does a malformed address.

One row, with a control per field. A form is the region for creating and editing.

Property Meaning
fields which columns, in which order. Without it, every stored column except a slug, an unguessable, a derived column, the lifecycle state and the audit columns
actions any of save, delete, duplicate
readonly fields shown as values rather than controls
success the message after a save

A save is a POST, then a redirect, then a GET. The message survives the redirect, so a reload never repeats a write.

A field whose storage the engine owns is never a control. A control that the write path will refuse can only lose somebody’s work.

A form open to visitors cannot show a ref control

Section titled “A form open to visitors cannot show a ref control”

A ref control lists up to 200 rows of the referenced table by their display: text. A table has no read rule of its own, so a form on a public page would show those names to every visitor. The compiler therefore refuses a form that a visitor with no login can open when it shows a ref control. This applies to create forms and to edit forms on a token route.

A ref does not count as a control when a set fills it or when it is in readonly:. Fill it from the page’s row, which is the usual case for a sign-up:

form of event_signups {
fields: guest_name, guest_email, note
set event = :event.id
}

The other repairs are to remove the field or give the form a rule that needs a login, for example form of event_signups { access: board … }. The engine refuses the same model again when the organization’s file is opened.

NOTE: A multi field on a public form is not refused yet. Its boxes list every row of the referenced table, up to 200. Put a multi on a public form only when every row of that table may be public.

actions: duplicate and the duplicate block

Section titled “actions: duplicate and the duplicate block”

duplicate adds a link that opens the page’s new_route: with the values of the row on screen. The action is opt-in: a form that writes no actions: gets save and delete.

form of events {
fields: title, starts_at, ends_at, place, capacity
actions: save, delete, duplicate
duplicate {
clear: starts_at, ends_at
label: "Copy as template"
}
}
Property Meaning Default
clear columns the copy leaves empty, in addition to the ones it never carries none
label the link’s text an engine word

A copy fills in the controls the form has. It therefore carries no engine-owned column, no lifecycle state, no set column, no derived column, and no sensitive column the role may not read. It also leaves a readonly: field, a picture, and a column that is unique on its own empty. A column that is unique only in combination is carried.

clear: only removes. Naming a column the copy would not have carried is a compile error.

The link opens an ordinary page: a record the reader may not see answers 404, and the save that follows is an ordinary create. The compiler refuses duplicate on a page with no new_route:, on a route that carries scope, on a page about no record, and on a form that writes a different table from the one the page is about. See Duplicate a record.

A field block overrides one column’s presentation for this form. It accepts add_via, empty_label, help, label, min, max, quick_picks, rows, show_when and suggest.

form of events {
fields: title, alliance
field alliance { empty_label: "All alliances" }
}

A default: and a value: are refused there. Both are data, so both stay on the column. See Column types for each property.

Declare each column in one field block. A form resolves a field by its first block, so a second block for the same column never reaches the page. The compiler refuses the second block and names the line of the first. Put every property for one column together:

field ends_at {
suggest: starts_at + 1h
help: "Enter the date and time in UTC."
}

show_when: takes a condition over the form’s own fields. While the condition is false, the browser hides the field.

form of events {
fields: title, repeat, every, until
field every { show_when: repeat != none }
field until { show_when: repeat != none }
}

The condition uses the same expressions as where: and check. The engine sends it to the browser as data, and one engine-owned script reads it. An application supplies no JavaScript.

The hidden field keeps its control, its name and its value. A save therefore sends the same values whether the reader has JavaScript or not. The server does not read the condition: every check, require ... when, set, deny when and hook sees the same request.

A reader with JavaScript turned off sees all fields. Nothing is hidden, and the form still saves.

show_when: accepts a smaller set of conditions than where:, because the browser must give the same answer as the database.

You can write You cannot write
a field of this form, or a fixed value of that field’s type a path such as alliance.name, :route/:user/:app, today, now, a function, a count, a computed column
a source column of type enum, bool or int text and long text (the engine compares text by Unicode rules itself, and a browser compares bytes), date, datetime and money (the control holds a spelling and the server holds the value)
a ref with is empty or is not empty ref = <value>, because the value would be a row id
=, !=, is empty, is not empty, and, or, not <, <=, >, >=

Each refusal names the repair. Three more conditions are refused:

  • A required column cannot be hidden. The browser refuses to save a hidden empty required control, and the reader sees no message. Make the column optional and write require <column> when <condition> on the table. The same condition then runs on the server.
  • A field cannot name itself. A control that hides itself cannot be shown again.
  • show_when: works in a form only. In a sheet or a report, one field is a column of many rows.

An empty value never makes a comparison true. repeat != none is false while repeat is empty, both on the server and in the browser. Write is empty to test for an empty value.

If a sensitive column is not shown to this role, the condition that reads it is not sent, and the field stays visible. See Control who sees what.

add_via: creates the row a ref field is missing

Section titled “add_via: creates the row a ref field is missing”

add_via: names the page that creates a row of the table a ref field points at. The engine draws a control beside the dropdown.

form of events {
fields: title, category
field category { add_via: admin_category }
}

The reader opens it, fills in the new category, and saves. The category becomes the selected option, and everything they had already typed in the event form is still there.

The named page decides everything about that save: who may make one, which fields they see, what a refusal says. add_via: adds none of it. So a reader who may not open the create page sees no control at all — not a disabled one, and no message. The field itself is unchanged.

In a browser the page is fetched without its frame and opened as a dialog. With JavaScript turned off the control is a link to that page, the reader creates the row there, and comes back. That is the same walk they take without the declaration, so nothing depends on the script.

Each of these is a compile error, and each names the repair:

  • the field is not a ref — a multi has no single option for a new row to become;
  • the page does not exist;
  • the page has no new_route:, so there is no address a row is created at;
  • the new_route: carries a route parameter, and the form that opens it has no value for it;
  • the page’s form writes a different table from the one the ref points at;
  • the page has no form at all;
  • the page is the page that declares add_via:;
  • add_via: is written outside a form.

suggest: offers a value for an empty field

Section titled “suggest: offers a value for an empty field”

suggest: fills an empty datetime field from another datetime field of the same form.

form of events {
fields: title, starts_at, ends_at
field ends_at { suggest: starts_at + 1h }
}

Write one field of this form, a +, and one duration. A duration is a whole number with one unit: min, h or d. Write 30min, 1h or 7d. There is no compound spelling, so write 90min and not 1h30min. The shortest duration is 1min and the longest is 7d.

The engine sends the field name and the number of minutes to the browser as data. One engine-owned script reads them. An application supplies no JavaScript.

Three rules decide when the browser writes a value:

  • On a new row only. The engine sends the hint on the page that creates a row. It never sends it on a page that edits one. An empty end time on a stored row is an answer somebody already gave.
  • Into an empty field, or into the value the script wrote last. A value somebody typed stays. If the reader clears the field, the field is empty again, and the next change to the source fills it again.
  • On the spelling, not on an instant. The script adds minutes to the wall clock in the control. It does not know the page’s time zone. The server still decides which instant that wall clock names. See Set the time zone.

A reader with JavaScript turned off sees an empty field and fills it in. The form saves the same values either way.

Each of these is a compile error, and each names the repair:

  • The field it fills, or the field it reads, is not a datetime. A date has no hours, and suggest: over a date is not built.
  • The field suggests from itself.
  • The form lists the source under readonly:, fills it with a set, or leaves it out of fields:.
  • The duration is 0min, is subtracted, or is longer than 7d.
  • The suggest: is in a sheet or a report. A value written into the same column of forty rows is a bulk edit and not an offer.

If a sensitive source column is not shown to this role, the engine sends no hint, and the field is an ordinary empty control.

quick_picks: puts a value into a field with one press

Section titled “quick_picks: puts a value into a field with one press”

quick_picks: shows small buttons under a field. When the reader presses one, the browser writes its value into the field. The reader can still change the value, and the form’s Save button saves it.

form of events {
fields: title, starts_at, location, minutes
field starts_at { quick_picks: now }
field location { quick_picks: "Kingdom", "Castle" "The castle" }
field minutes { quick_picks: 15, 30 "Half an hour" }
}

Write a value, and then the text of the button if you want one. Separate the picks with commas. If you do not write a text, the engine names the button: now shows “Now”, today shows “Today”, an enum member shows its label, and any other value shows itself.

The field’s type decides which values you can write:

Field Value
datetime now
date today
enum the name of a member
int a whole number
text, long text, email quoted text

now and today read the clock when the reader presses the button, not when the page loaded. They use the time zone of the page, not the time zone of the reader’s device. On a page in UTC, now writes the current UTC time. See Set the time zone. On an enum, now and today are member names.

The buttons appear on the page that creates a row and on the page that edits one. They need JavaScript. A reader with JavaScript turned off sees no buttons and types the value. The form saves the same values either way, and the server checks a picked value like a typed one.

Each of these is a compile error, and each names the repair:

  • The field is a bool, a ref, a multi, money, or another type not in the table.
  • A value does not fit the field’s type, for example today on a datetime.
  • An enum pick names a member that the enum does not have.
  • The same value appears twice.
  • A value is a duration, a :user… or :route… parameter, or a path.
  • A button text is empty.
  • The form does not show the field, or lists it under readonly:.
  • The quick_picks: is in a sheet or a report.

An int pick outside the field’s min: or max: compiles. The save refuses it with the same message as a typed value.

A table that a person edits like a spreadsheet, with one save for every change. Use a sheet when writing one row at a time is the wrong shape, such as entering a mark for every student in a class.

sheet of members "Maintain members" {
columns: name, email, fee_group, active
where: left_on is empty
sort: name
page_size: 50
actions: save, add, delete
row -> member
}
Property Meaning
columns which columns, in which order. Required.
page_size rows per page of the list that a reader without JavaScript gets. Required.
row -> <page> the page whose form edits one row. Required.
actions any of save, add, and delete. Default save.

search, filter, sort, where, and empty are a report’s and mean the same thing. A sheet’s rows are the listing’s rows, from the same query. export: and limit: are refused in a sheet, each with the line to write instead.

A read region can default its columns and leave its rows unbounded. A write region cannot: defaulting columns: makes every column of the table editable, including columns that the author never named.

row -> must name a page that holds a form of the sheet’s own table, on a route that carries the row. The compiler refuses a target that only shows the row, and a form that creates a new row. The target is never derived.

A sheet has two renderings, and you declare neither.

  • With JavaScript, the sheet is an editing grid. The grid loads the rows 200 at a time while the reader scrolls, so it holds every row of the list and draws no pager.
  • Without JavaScript, the sheet is exactly a report: the table, the search box, the filters, the sort links, and a pager of page_size: rows. No cell is editable. The identity cell of each row links to the row -> page, where its form edits that one row.
  • On a coarse pointer below 40rem, such as a phone, the grid does not mount, and the reader gets the list with its row links. The engine asks both questions once, when the page loads.

A column’s help: is shown in the grid. The column’s header has a help button with a question-mark icon. Press it to open the text below the header, and press it again, press Escape, or press anywhere else to close it. A screen reader reads the text as the button’s description, so the reader does not have to open it. The list without JavaScript does not show help:.

A row link is drawn only when the reader can open the target page with that row, as in a report. A row that the target page would not show stays in the list without a link. So a sheet that shows an author every note of a folder links only her own notes, if the note page shows an author only her own.

The page accepts a save only as the grid’s JSON request. A form-encoded save of a sheet is refused before anything in it is read.

One list of columns decides which cells are editable. A cell is read-only when the engine owns the column, when the column is derived, or when a role’s rule does not grant it. A save that names a read-only column does not write it.

One save is one transaction. If any cell is wrong, nothing is written, and the grid shows each message on its cell and keeps every edit. The grid sends only the rows that somebody changed. The engine compares each sent row with storage and writes only the rows that really changed.

Each row carries a stamp. If a row changed after the grid loaded it, the save is refused and names the rows that moved. A sheet is the screen two people open at once, so a silent overwrite is the wrong default.

One save carries at most 200 rows. If a before hook runs on each row, one save carries 50 rows. The count is the changed rows, the removed rows, and the new rows together. A before delete hook counts only when actions: includes delete, and a before insert hook only when it includes add. A save over the limit is refused before anything is written, and the grid says so before it sends the save. page_size: is independent of this limit.

actions: delete lets the reader mark rows for removal: the Delete rows button, or the same entry in the right-click menu. The save removes the marked rows first, in the same transaction as the cell changes. A marked row is not checked against any rule of its table. The row stamp still applies: a marked row whose values changed is refused rather than removed. A removal that another table’s reference blocks is refused with a message that names the row. The slug of a removed row stays reserved.

actions: add lets the reader add rows: the Add row button, Insert row in the right-click menu, or a paste past the last row. One save can create any number of new rows. The engine creates them last, in the order they were sent, in the same transaction as the cell changes and the removals. If one new row is refused, none of them is created.

A new row starts with each column’s default:, the default lifecycle state, and the application’s scope column. A set on the region fills its column too. A read-only cell stays empty until the save.

A new row whose cells all still hold their defaults is not a request. The grid does not send it, and the engine drops it when it arrives. A new row with one cell filled in is a row somebody started. Every rule of the table applies to it, and each message comes back on its cell. A message about the row as a whole names it, for example “New row 3”. Two new rows with the same unique value are refused on the later row.

An empty sheet shows its empty: text above the grid, and the engine’s own sentence for a filter that matched nothing. A sheet with add still offers the grid, so that somebody can create the first row.

A sheet with add must be able to fill every required column of its table. Four things satisfy that: the column is writable in columns:, it has a default:, the region has a set for it, or the engine owns it. A sheet that leaves one required column unsatisfiable is refused when the tenant is opened. The message names the column and all four repairs.

A sheet whose where: reads a route parameter cannot use add, and the engine refuses it when the tenant is opened. The new row has no cell for the column that the condition is about, so every added row would be refused. Create the row in a form on its own page instead.

The message after a save appears above the grid. It is built from up to three counts, in one order: rows changed, rows added, rows deleted. A count of zero is left out. A save that did none of the three says so.

You declare nothing for the grid. No property turns it on, and no property turns it off.

The grid has two modes. In navigate mode the arrow keys move between cells. Typing, F2, or a double click starts an edit in the active cell, and the arrow keys then belong to the text. Typing replaces the value. F2 and a double click keep it. Escape discards the edit. Enter and Tab commit the edit and move.

Key Effect in navigate mode
Arrow keys move one cell
Shift + arrow keys extend the selected range
Ctrl + arrow keys jump to the last filled cell before an empty one, or to the edge
Home, End the first and the last cell of the row
Ctrl + Home, Ctrl + End the first and the last cell of the grid
PageUp, PageDown one screen of rows up and down
Tab, Shift + Tab the next and the previous cell. Tab wraps to the next row and stops at the last cell.
Enter, Shift + Enter one row down and up
F2 edit the cell and keep its value
Space tick or clear a checkbox cell. In another cell, start an edit with a space.
Ctrl + A select every cell
Ctrl + C, Ctrl + X, Ctrl + V copy, cut, and paste the range
Delete, Backspace clear the selected cells
Ctrl + Z undo the last step
Ctrl + Y, Ctrl + Shift + Z redo it

On a Mac, use Command where the table says Ctrl.

The row-number column at the left selects whole rows. Click a number to select its row, or drag down the numbers to select several rows. The corner above the numbers selects every cell. A right click opens a menu with Insert row, Delete rows, Clear contents, Undo, and Redo.

Copy and paste work with Excel. A copy writes tab-separated text with Excel’s quoting, so a cell that holds a tab or a line break survives the round trip. A paste reads the same format. A paste never writes a read-only cell, and columns past the last column are not pasted. Rows past the last row are added when the sheet offers add, and are not pasted when it does not. The line under the grid reports how many cells were pasted and how many rows were added.

A dropdown cell copies its label, not the stored value behind it. A paste matches the text to an option’s label exactly, including upper and lower case, and then to its stored value. If neither matches, the cell keeps the value it had. The line under the grid names that cell, for example Status has no option called Nope. When more than one cell does not match, it gives the number, for example 3 cells matched no option and kept their value. In a multi-select cell, one part that does not match keeps the whole cell. An empty pasted cell clears a dropdown. A ref dropdown offers at most 200 rows of the target table, so a label past that limit cannot be matched.

A fill continues a series. Select the source cells, then drag the small square at the corner of the selection. A whole number, an amount, and an ISO date continue a constant step. One date steps one day. Task 1 becomes Task 2, and any zero padding is kept. A datetime, a checkbox, a dropdown, and text without a trailing number repeat instead. A datetime does not step, because a step across a clock change is the page time zone’s question. A fill to the side into a different dropdown column carries the label that is shown, and matches it like a paste: a label with no option keeps the cell’s value, and the line under the grid reports it.

Ctrl+Z undoes one step: an edit, a paste, a fill, a clear, an added row, or a removal mark. The grid keeps up to 200 steps. Ctrl+Y puts a step back.

Sorting and filtering reload the grid. A header click follows the list’s own sort link, so the server sorts every row and not only the loaded ones. The filter form reloads the list too. If the grid holds unsaved edits, it asks before it follows either one, and before the reader leaves the page.

A row that another person moves into a part of the list that the grid already loaded appears after the next reload.

Status: actions: save, actions: add, and actions: delete all work. An img column in columns: is accepted by the compiler and refused when the tenant is opened, because there is no editor for it yet. Edit a picture in a form on the row’s own page. The grid is tested in Chromium and WebKit against the real engine on every change. It has not been tested with a screen reader. See Project status for what that means and what can still change.