Pages
Declare standalone pages and record pages with page(), fill them with block factories, and reference a record page from a record tab with content.page.
page() declares a page: rows of named blocks on a
12-column grid. You never write coordinates: each .row({...}) shares the 12
columns between its blocks in width steps, .heading() titles a section, and
the builder computes and packs every cell. Blocks come from the content.*
factories, the same namespace a view's tabs use.
import { RECORD_LINKS_BLOCK, page } from "@standards/schema";
import { content, currentActor, query } from "@standards/client";
const active = query("contacts").eq("status", "active");
export const HOME = page("home", "Accueil")
.icon("home")
.home()
.row({ greeting: content.text("Bonjour {{me.firstName}}") })
.heading("Vue d'ensemble", { icon: "chart-bar", description: "Portefeuille du mois" })
.row(
{
activeClients: content.metric(active).count().label("Clients actifs").trend("createdAt", "week"),
patrimony: content.metric(query("households")).sum("patrimony").compare("previous_period"),
},
{ group: {} }
)
.row({
patrimonyByType: content.chart(query("households")).sum("patrimony").by("type").donut(),
birthdays: content.agenda(query("contacts"), "birthday").yearly().next("7d"),
questions: content.inbox().kinds("question", "system"),
})
.heading("Mes contacts")
.row({
myContacts: content
.collection(query("contacts").eq("owner", currentActor()).orderBy("updatedAt", "desc"))
.table()
.span(6),
around: content.custom(RECORD_LINKS_BLOCK, { center: "me" }),
})
.build();Block names are the object keys (activeClients, birthdays…): stable,
never positional. They name the block in the page, in the architect panel and
in errors. A group is named after its smallest member key in alphabetical
order (group_activeClients), a heading after its title (.heading("Vue d'ensemble") is heading_vue_d_ensemble, a repeated title taking _2, _3…
in order). Both stay put when a section is inserted before them; pass name
to keep one when you rename its title or reorder its blocks:
.heading("Pipeline", { name: "pipeline" }), { group: { name: "totals" } }.
The page chain
| Method | Effect |
|---|---|
page(name, label) | A standalone page. name is its API name, label what the sidebar shows. |
.icon(icon) | The page's icon. |
.description(text) | A description. |
.home() | Makes it the workspace Home. Only a standalone page can be; one Home per workspace. |
.for(object) | Makes it a record page of object (an object builder or its name), rendered for one record. |
.row(blocks, { group? }) | Appends a row of blocks, its free columns shared in width steps. { group: { name?, label? } } puts the row's 2 to 4 blocks in one group. |
.heading(title, { name?, description?, icon? }) | Appends a heading on its own full-width row. |
.build() | Validates the page and returns its declaration. |
.home() and .for() exclude each other: calling one after the other is a
type error, and build() refuses the pair for untyped callers.
Widths, heights and groups
A block is 3, 4, 6, 8, 9 or 12 columns wide. .span(n) gives a block one of
those widths; any other value is refused, with the allowed widths named. The
blocks of a row without a span share the free columns in those steps, each at
least its block's minimum width: 4 blocks get 3/3/3/3, 3 get 4/4/4, 2 get 6/6.
A row holds at most 4 blocks without a span.
Heights come from the block's traits, and build() packs the page: a block
rises until it meets the block above it. .height(mode) on any content
overrides how it meets the height of its row:
| Mode | The block |
|---|---|
fit | takes the height its content needs, up to its maximum, then scrolls inside |
flexible | stretches between its minimum and maximum height |
fill | takes the height of the page's scroll area; at most one per page, alone in its rows |
export const GUIDE = page("guide", "Guide")
.row({ body: content.text(markdown).height("fill") })
.build();The traits of the native blocks (sizes in columns × rows):
| Block | Height | Default size | Minimum width | Min / max height | Figure |
|---|---|---|---|---|---|
Indicator (metric) | fit | 3×2 | 3 | — / 3 | yes |
Text (text_block) | fit | 12×2 | 3 | — / 16 | no |
Heading (heading) | fit | 12×1 | 6 | — / 3 | no |
Chart (chart) | flexible | 4×6 | 4 | 4 / 12 | no |
| Agenda, inbox, people lists | flexible | 4×4 | 3 | 3 / 10 | no |
Record card (card) | fit | 4×4 | 3 | — / 10 | no |
Bubble (record_links) | flexible | 6×8 | 6 | 6 / 16 | no |
Records list (collection) | flexible | 12×8 | 6 | 4 / 20 | no |
| Activity, record page | flexible | 12×8 | 6 | — | no |
A block declared with defineBlock takes its traits from its
layout; without one it is 4×4, at least 3 columns
wide, flexible from 2 rows.
{ group: {} } puts a row's blocks in one group on one full-width cell. A
group of figures renders as a band of indicators, any other as one shared
card; { group: { label } } titles it.
build() throws a ValidationError naming the block at fault when:
- the page declares no block;
- a
.span(n)is not one of 3, 4, 6, 8, 9 or 12; - a row holds more than 4 blocks without a span;
- a row's blocks cannot reach their minimum widths side by side (a Bubble,
6 columns at least, beside a
.span(8)); - a grouped row holds fewer than 2 or more than 4 blocks;
- more than one block fills the page, or a
fillblock shares its rows; - a block ends past the page's 1000th row;
- a block is not allowed where it is placed: a standalone page takes blocks
that declare
page, a record page those that declarerecord(content.activity()on a standalone page is refused); - a block's own rule fails, such as a card or a Bubble on a standalone page with no record to show;
- a page references another page.
Block factories
| Factory | Block | Chain |
|---|---|---|
content.metric(query) | Indicator | .count(), .sum(attr), .avg(attr), .min(attr), .max(attr), then .label(), .trend(dateAttr, bucket), .compare("previous_period", period?), .target(n) |
content.chart(query) | Chart | a measure as above, then .by(attr | { attribute, bucket }), .by2(...), .bar() (default; with .by2(), its layers stacked, each bar as tall as their sum), .horizontalBar() (the same bars laid across, the categories down the left: long labels, a ranking), .line(), .area() (with .by2() only: stacked shares), .donut(), .funnel() (grouped by a status: each stage counts what reached it), .treemap() (one grouping), .calendar() (a date grouping by day), .label() |
content.agenda(query, dateAttr) | Agenda | .next("7d" | "30d" | days), .yearly() (month and day only: birthdays), .label() |
content.card(object?) | Record card | .fields(...attrs), .record(id) |
content.collection(query) | Records list | .table(), .list(), .kanban() and the collection settings of Views |
content.text(markdown) | Text | Markdown without raw HTML or remote images, with {{me…}}, {{today}} and {{record…}} |
content.inbox() | Inbox | .kinds(...kinds), .limit(n) |
content.mailbox() | The reader's email threads, newest first | .category(key) (a system category: "direct", "transactional", "notifications", "newsletters", "marketing", or a custom category's id), .important(), .unread(), .limit(n) (1 to 50, 8 by default), .label(); every filter set must match |
content.meetingsUpcoming() | The reader's next meetings | none |
content.custom(block, parameters) | Any block declared with defineBlock, Bubble included | the parameters its inputs declare |
A metric's .compare("previous_period", period?) sets the period so far
against the same stretch of the one before: on the 10th, this month's first ten
days against last month's. Without a period it compares one step above its
trend (a weekly trend compares months), else a month; a period no wider than
the trend's is refused, since the line would keep a single point.
A mailbox block lists each reader's own mail, filed by their own classification: a custom category private to another member lists nothing for the reader.
Measures and groupings are typed from the query: with an object builder,
.sum() takes only number and currency attributes, .by() single-valued ones
and .trend() or agenda() date ones. A wrong name is a type error, then a
ValidationError at sync. A block's query takes the filters, sorts and dynamic
values of query(); it stores no search and no
offset, and a query carrying either is refused.
A card on a standalone page shows a chosen record:
content.card("contacts").record(id). On a record page content.card() shows
the open record.
Bubble on a page
Bubble (record_links) draws the links around a record. It has no factory of
its own: place it with content.custom(RECORD_LINKS_BLOCK, parameters).
import { RECORD_LINKS_BLOCK, page } from "@standards/schema";
import { content } from "@standards/client";
export const RELATIONSHIPS = page("relationships", "Relations")
.row({
mine: content.custom(RECORD_LINKS_BLOCK, { center: "me" }),
alice: content.custom(RECORD_LINKS_BLOCK, {
object: "contacts",
record: "0b8f6a1e-5c1d-4c4e-9d0a-3f2b1c4d5e6f",
depth: "2",
}),
})
.build();| Parameter | Holds |
|---|---|
center | "record" (default) or "me": the viewer is the centre. |
object, record | The chosen record. record requires object. |
depth | "1" or "2": how many rings are expanded on open. |
On a standalone page there is no open record: set { center: "me" } or
{ object, record }, otherwise build() refuses the block. On a record page
content.custom(RECORD_LINKS_BLOCK, {}) centres on the open record.
Record pages and content.page
A record page renders for one record of its object. Its blocks read that
record: card() shows it, and a query scoped with relatedFrom(relation)
lists the records pointing at it.

import { page } from "@standards/schema";
import { content, detailView, query } from "@standards/client";
export const CONTACT_SYNTHESIS = page("contact_synthesis", "Synthèse")
.for("contacts")
.row({ identity: content.card().fields("email", "phone", "status") })
.row({
noteCount: content.metric(query("notes").relatedFrom("contact")).count().label("Notes"),
lastNotes: content
.collection(query("notes").relatedFrom("contact").orderBy("createdAt", "desc"))
.list(),
})
.row({ history: content.activity() })
.build();
export const CONTACT_DETAIL = detailView("contact_detail", "Contact")
.for("contacts")
.tab("synthesis", "Synthèse", content.page(CONTACT_SYNTHESIS))
.build();content.page(declaration) takes a built record page and stores it by name
({ type: "page", page: { object, name } }), so the tab follows the page as it
changes. It is a record tab: a standalone page passed to it is refused with a
ValidationError. The record page must belong to the tab's object. A page a
tab references cannot be deleted or made private until the tab is removed.
Pages in a source
Pass each page's definition next to your views:
import { defineSchemaSource } from "@standards/client";
export const crmSource = defineSchemaSource({
id: "crm",
objects: [CONTACT, NOTE, HOUSEHOLD],
views: [HOME.definition, CONTACT_SYNTHESIS.definition, CONTACT_DETAIL],
});A code page is shared with the workspace, stays shared, and leaves the
workspace when its declaration leaves the source. Only one page may declare
.home().
Custom blocks: inputs and refine
A block you declare with defineBlock is placed with
content.custom(block, parameters). Its inputs type the parameters, and
refine states the rules that span several inputs. refine receives the
parsed parameters and, when the block is placed, where it is ("page",
"record" or "display"); it returns the input at fault and a message, or
undefined.
import { defineBlock, input, page } from "@standards/schema";
import { content, query } from "@standards/client";
export const PIPELINE_VALUE = defineBlock({
name: "pipeline_value",
label: "Pipeline",
icon: "chart-bar",
family: "data",
contexts: ["page", "record"],
inputs: {
deals: input.query("deals"),
measure: input.attribute("deals", { types: ["currency"] }).optional(),
period: input.period().optional(),
compare: input.boolean().optional(),
},
refine: (parameters, placement) => {
if (parameters.compare && parameters.period === undefined)
return { input: "compare", message: "compare needs a period" };
if (placement === "page" && parameters.measure === undefined)
return { input: "measure", message: "On a page, name the measured attribute" };
return undefined;
},
});
export const SALES = page("sales", "Ventes")
.row({
pipeline: content.custom(PIPELINE_VALUE, {
deals: query("deals").toDefinition(),
measure: "amount",
period: "month",
compare: true,
}),
})
.build();Without a placement, refine runs on every parse of the parameters: in
content.custom(...) and at sync. With one, page().build() calls it again for
the page the block sits on, so compare: true without a period fails on the
content.custom line, and a missing measure fails when the block is placed on
a standalone page.
As for a view tab, content.custom names a block registered on the platform,
and sync refuses a block name the workspace does not know. The native blocks
of this page are always registered.
Next steps
- Interfaces / Pages: Home, visibility, sharing and architect mode
- Blocks:
defineBlockand the input kinds - Views: record tabs and collections
- Sync: ship pages with your source
- Book a demo