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}`;
}

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:

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] });

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.