standards
PagesInterfaces

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

MethodEffect
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:

ModeThe block
fittakes the height its content needs, up to its maximum, then scrolls inside
flexiblestretches between its minimum and maximum height
filltakes 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):

BlockHeightDefault sizeMinimum widthMin / max heightFigure
Indicator (metric)fit3×23— / 3yes
Text (text_block)fit12×23— / 16no
Heading (heading)fit12×16— / 3no
Chart (chart)flexible4×644 / 12no
Agenda, inbox, people listsflexible4×433 / 10no
Record card (card)fit4×43— / 10no
Bubble (record_links)flexible6×866 / 16no
Records list (collection)flexible12×864 / 20no
Activity, record pageflexible12×86—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 fill block 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 declare record (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

FactoryBlockChain
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)Charta 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)TextMarkdown 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 meetingsnone
content.custom(block, parameters)Any block declared with defineBlock, Bubble includedthe 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();
ParameterHolds
center"record" (default) or "me": the viewer is the centre.
object, recordThe 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.

A record page in the app, shown through the Overview tab of a project: a card with the client, stage, discipline, budget and due date, above the record's activity

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