Components
aquamarine.ui builds the parts most tabs need, in the app's own ext-*
classes, so they follow the person's theme. Each builder returns HTML that
a view's render can return or put inside its own markup. The ones that
need the page (pickers, charts, sortable lists, confirmations) are drawn
by the app after it cleans the HTML; the extension sees only what the
person submits.
Start with ui.crud when a tab is a list of one collection's
records. Use the smaller builders when it is not.
ui.html
A tagged template that escapes every value, so text from a record can go straight in:
const { html } = aquamarine.ui;
return html`<h2>${book.data.title}</h2><p>${book.data.notes}</p>`;
The output of another ui call is not escaped again, so builders nest:
html`<header><h1>Books</h1></header>${aquamarine.ui.stats([{ label: "Read", value: 12 }])}`
Arrays are joined, and null, undefined, true and false render
nothing, so ${done && htmlDone} works. ui.raw(text)
marks a string as HTML already, and ui.escape(text) escapes one value
into a plain string.
ui.crud
Registers a whole tab over one collection:
| Path | Shows |
|---|---|
"" |
The list, with search, sorting, filters and "Show more" |
new |
A form for a new record |
<id> |
The record, with Edit and Delete |
<id>/edit |
The form for that record |
aquamarine.ui.crud({
id: "books",
name: "Reading list",
icon: "book",
collection: "books",
singular: "book",
subtitle: (r) => r.data.author,
sorts: [
{ id: "recent", label: "Recently changed", key: (r) => r.updated_at, desc: true },
{ id: "title", label: "Title", key: (r) => r.data.title }
],
filters: [
{ id: "read", label: "Status", options: [{ value: "yes", label: "Read" }, { value: "no", label: "Unread" }],
match: (r, v) => !!r.data.read === (v === "yes") }
]
});
The form and the record page come from the collection's schema (see
ui.form). Saving checks the schema's types, limits and
required fields and shows what is wrong next to each field. Delete asks
first, then shows "Deleted book" with Undo, which creates the record again
under a new id.
| Option | Default | Does |
|---|---|---|
id, name, icon |
As for views.register; id also names the list's controls |
|
collection |
A collection the manifest declares | |
singular |
"item" |
Used in "New book", "Delete this book?" |
title(record) |
data.name or data.title |
The record's name in the list and on its page |
subtitle(record) |
A second line in the list | |
columns |
Show the list as a table with these columns instead | |
sorts, filters, text, placeholder |
As for ui.list | |
pageSize |
50 | Records shown before "Show more" |
fields |
every property | The properties the form and record page show, in order |
defaults |
{} |
Values a new record's form starts with |
detail(record) |
a list of the fields | The record page's body, as HTML |
beforeSave(data) |
Returns the data to save, for derived fields | |
empty |
"No … yet." | Text when the collection is empty |
action(name, data, ctx) |
Runs any other action the view's HTML sends |
The list reads every record, 500 at a time, so search and sorting see the
whole collection. Press n on the list for a new record, e on a record
to edit it and Escape to go back.
ui.list
A search box, sort menu and filter menus for a list the view renders
itself. The app runs them, so the view's action never sees their
actions.
const list = aquamarine.ui.list({
id: "books",
placeholder: "Search books",
pageSize: 50,
sorts: [{ id: "title", label: "Title", key: (r) => r.data.title }],
filters: [{ id: "author", label: "Author", options: (all) => [...new Set(all.map((r) => r.data.author).filter(Boolean))], match: (r, v) => r.data.author === v }]
});
async function render() {
const { items, active, toolbar, more } = list.apply(await aquamarine.records.list("books"));
return html`${toolbar}<ul class="ext-rows">${items.map((b) => html`<li>${b.data.title}</li>`)}</ul>${more}`;
}
text(item)returns the strings search matches; by default every string initem.data.- Each sort is
{ id, label, key(item), desc }. The first is the default, and items without a key go last. - Each filter is
{ id, label, all, options, match(item, value) }.optionsis a list of strings or{ value, label }, or a function of every item that returns one. apply(items)answers{ items, total, active, toolbar, more }: the matching items in order (only the first pages withpageSize), how many matched, whether a search or filter is set, the toolbar, and the "Show more" button (empty when everything is shown).list.search,list.sortandlist.filter(id)read the current state. A button withdata-actionset tolist.clearActionclears the search and filters.
ui.table
aquamarine.ui.table(books, {
id: "books",
columns: [
{ id: "title", label: "Title" },
{ id: "pages", label: "Pages", align: "end" },
{ id: "read", label: "Read", value: (r) => (r.data.read ? "Yes" : "") },
{ id: "added", label: "Added", value: (r) => r.created_at, html: (r) => aquamarine.ui.date(r.created_at) }
],
row: (r) => r.id,
empty: "No books yet."
});
A column shows item.data[id] unless it has value(item); html(item)
draws the cell instead while value still sorts it. With an id, each
header sorts the table, and pressing it again reverses the order; a
column with sort: false does not. row(item) makes each row open that
path. align: "end" lines numbers up on the right.
ui.form
A form from a JSON Schema, or from a declared collection's schema by name:
aquamarine.ui.form("books", book?.data, { action: "save", title: "Edit book", cancel: "" });
Then in action:
if (name === "save") {
const { data, errors, ok } = aquamarine.ui.readForm("books", fields);
if (!ok) { problems = errors; return; }
await aquamarine.records.update("books", path, data);
}
Pass errors (an object of field → message) and error (one message for
the form) back to ui.form to show them. Options: action (default
"save"), submit (button text), cancel (a path for a Cancel button),
title, fields (which properties, in order) and confirm (ask this
first).
readForm answers { data, errors, ok } with the values converted to the
schema's types. A field left empty is null in data, which
records.update reads as "remove"; ui.crud leaves them out when it
creates a record.
Each property becomes a field:
| Property | Field |
|---|---|
string |
Text, or a text area when maxLength is over 200 |
string with format date, date-time, email or uri |
Date, date and time, email or web address |
integer, number |
Number, with minimum and maximum |
boolean |
Checkbox |
enum |
Menu |
array of enum |
A checkbox for each |
array of string |
A text area, one item per line |
title labels the field (otherwise the property name, capitalized) and
description goes under it. Properties marked readOnly are left out.
Two keywords the server ignores tune the form:
"x-input":"textarea","tags"(a tag picker),"note"(a page picker),"hidden"or any field kind from the table, such as"text"."x-labels": labels forenumvalues, as{ "value": "Label" }.
The fields are named f.<property>, which keeps a property called name
or id from being removed with the form properties of the same name.
ui.confirm
A button that asks in the app's own dialog before it runs an action:
aquamarine.ui.confirm({ label: "Delete", action: "delete", value: book.id, message: "Delete this book?", body: "Its notes go too.", danger: true });
confirm is the dialog's button text (the label by default). Any button
or form can do the same with data-confirm="<question>", plus
data-confirm-body and data-confirm-label.
Toasts and Undo
An action can answer a line for the status bar, with a button that runs another action:
async action(name, data) {
if (name === "archive") {
await aquamarine.records.update("books", data.value, { archived: true });
return { toast: { text: "Archived", undo: { action: "unarchive", value: data.value, label: "Undo" } } };
}
if (name === "unarchive") await aquamarine.records.update("books", data.value, { archived: null });
}
{ toast: "Saved" } shows text alone. The text is plain, up to 200
characters.
ui.date
aquamarine.ui.date("2026-11-05") // 5 Nov
aquamarine.ui.date(record.updated_at, { relative: true }) // 3 hours ago
A <time> with the full date and time on hover. A date without a time
(YYYY-MM-DD) is that day wherever the person is. The year shows when it
is not this year.
ui.stats
aquamarine.ui.stats([
{ label: "Read this year", value: 14 },
{ label: "Pages", value: "4,210", hint: "≈ 12 a day" }
]);
ui.progress
aquamarine.ui.progress(3, 10, { label: "Chapters" }) // Chapters ▬▬▬──── 3 / 10
text replaces "3 / 10".
ui.checklist
aquamarine.ui.checklist(steps, { action: "tick", label: (s) => s.text, checked: (s) => s.done, value: (s) => s.id });
Ticking calls action("tick", { value: true, item: "<id>" }). value(item)
defaults to item.id.
ui.note
aquamarine.ui.note(page.id, page.title)
A link that opens one of the workspace's pages. Getting a page's title
needs read:notes; the link itself needs no scope. Widgets can use it
too.
ui.notePicker
html`<label class="ext-field"><span>Related page</span>${aquamarine.ui.notePicker("page", book.data.page)}</label>`
A text box that suggests the workspace's pages by title as the person
types, and sends the chosen page's id as page. Options: placeholder,
required. The extension sees only the id of the page the person picks;
reading it needs read:notes.
ui.tagPicker
aquamarine.ui.tagPicker("tags", book.data.tags)
Chips with a text box that suggests the workspace's tags. Enter or a
comma adds what was typed, Backspace removes the last chip. It sends
tags[], so the action gets tags as a list.
ui.sortable
aquamarine.ui.sortable(steps, { action: "reorder", label: (s) => s.text, value: (s) => s.id });
The person drags an item, or focuses it and presses Alt+Up or Alt+Down.
Each move calls action("reorder", { order: ["<id>", ...] }) with every
item's value in the new order.
ui.chart
aquamarine.ui.chart({ type: "bar", label: "Books a month", data: [{ label: "Sep", value: 3 }, { label: "Oct", value: 5 }] });
aquamarine.ui.chart({ type: "spark", data: [3, 5, 2, 6, 4] });
type:bar,lineorspark(a small line with no axes, to sit in a row or a widget).data: numbers, or{ label, value }. Up to 120 points.format(value): the text the tooltip and table show for a value.
The app draws the chart in the theme's accent color, with a tooltip on hover and a table for screen readers. Charts work in widgets as well as tabs.
Keyboard shortcuts
Any button with data-key is clicked when the person presses that key
while the tab is on screen and they are not typing in a field:
<button class="btn" data-go="new" data-key="n">New book</button>
Use a single letter or a key name such as Escape. Shortcuts with Ctrl,
Alt or Cmd stay the app's.
Paging records
records.list answers up to 500 records. To read past them, pass
offset:
const next = await aquamarine.records.list("books", { limit: 500, offset: 500 });
ui.crud does this itself.