standards
ViewsInterfaces

Views

Declare the list and detail views of your objects next to their schema, and ship them to the workspace in the same sync.


Views are the screens your team sees in the Standards app. listView is the table of records; detailView is the page one record opens into. The ones you declare here are a starting point: on app.standards.new your users change them, add tabs and columns, and create views of their own, and sync keeps theirs while owning yours. Declare them next to your objects and they ship in the same schema.sync.

A list view in the app with two tabs, All projects and Board, the Board tab open on a kanban grouped by stage: Lead, Proposal, In progress, Client review, Delivered and Unassigned

import { content, detailView, group, listView, query } from "@standards/client";

const IDENTITY = content.form(
  group("identity", "Identity").fields("firstName", "lastName", "email")
);

export const CONTACT_DETAIL = detailView("contact_detail", "Contact")
  .for("contacts")
  .default()
  .tab("general", "Info", IDENTITY)
  .tab("activity", "Activity", content.activity())
  .aside("details", "Details", content.form(group("details", "Details").field("status")))
  .build();

export const CONTACTS = listView("contacts_list", "Contacts")
  .for("contacts")
  .default()
  .tab("all", "All", content.collection(query("contacts")).table().columns("firstName", "email"))
  .build();

The view chain

MethodEffect
detailView(name, label) / listView(name, label)Starts a view in record or collection context
.for(object)The object the view belongs to; required
.default()Opens by default for that object and context; one per pair
.tab(name, label, content)Places a content in the main area
.aside(name, label, content)Places a content in the side panel
.description(text), .icon(name)View metadata
.build()Validates and returns { kind: "view", definition }

Every call returns a new builder. A content has no name, label or placement of its own; the view supplies them. Content names are unique within a view, and at least one main content is required. Contents that need a record (everything except a collection on an object) are refused on a listView at build time.

The ten content types

content.collection(query)     // the records a typed query reads
content.form(...groups)       // fields of the current record, laid out in groups
content.activity()            // the record's activity feed
content.richtext("body")      // one richtext attribute, full width
content.documents()           // the record's document packs
content.forms().object("x")   // submissions of the form system for an object
content.emails("email")       // threads matched on email attributes
content.meetings("email")     // meetings matched on email attributes
content.custom(extension, o)  // a registered content extension
content.page(recordPage)      // a record page of the same object, by name

The block factories (content.metric, content.chart, content.agenda, content.card, content.text…) and content.page are described in Pages.

The attributes content.meetings names also link meetings to records: a synced meeting links every record whose attribute holds one of its participants' addresses, and follows a later change of that value, so a contact whose address is filled in or corrected after the meeting was synced is linked or unlinked without a new sync. Links added by hand are kept.

On a record, the tab strip follows the label of a collection or documents tab with the number of items it lists: a collection counts the records its query and relation select (not what someone typed into its search or filters), and documents count the files and folders at the root of the record's drive (each once, nothing inside a folder). Each count respects what the viewer may see and is read without opening the tab. A tab with nothing in it, or whose count has not arrived, shows only its label. Emails, meetings, a documents tab that hides attachments, and tabs without a list behind them carry no count.

All of them accept .icon(name). content.custom names an extension already registered on the platform; the extension registry itself is not part of the client surface.

The Bubble tab

The default record view of an object created from now on, and the native views (meeting, artifact, skill, memory), include a Bubble tab: a custom content on the record_links block that maps the links around the record (see Blocks). An existing view gains it from the "+" tab menu. A view you declare in code does not get it for free: add it with the helper from @standards/schema, or place the block yourself:

import { RECORD_LINKS_BLOCK, content, detailView, recordLinksTab } from "@standards/schema";

detailView("contact_detail", "Contact")
  .for("contacts")
  .tab(...recordLinksTab()) // name "bubble", label "Bubble"
  // or, with your own name and label:
  .tab("links", "Links", content.custom(RECORD_LINKS_BLOCK, {}))
  .build();

Collections

A collection reads the records of one typed query(...): an object, a relation the current record declares (query(target).related(attribute)), or the records whose own relation points back at the current record (query(object).relatedFrom(attribute)). related needs one concrete target object; polymorphic and universal relations are not a collection source. The query carries the filters and sorts, so the collection has no .object, .related, .relatedFrom, .filter or .sort of its own.

import { content, detailView, query } from "@standards/client";

export const COMPANY_DETAIL = detailView("company_detail", "Company")
  .for("companies")
  .tab("contacts", "Contacts", content.collection(
    query("contacts").relatedFrom("company").orderBy("lastName"))
    .table()
    .columns("firstName", "lastName", "email")
    .openMode("peek")
    .createMode("inline")
    .actions("create", "update", "delete"))
  .build();

query(...) is immutable: every verb returns a new query. It takes an object builder, which checks attribute names, or an object name. Its verbs are eq, neq, gt, gte, lt, lte, contains, notContains, startsWith, endsWith, in, notIn, isEmpty, isNotEmpty, or, orderBy, limit, offset, search, related and relatedFrom. A rule value can be a dynamic value that is resolved for whoever reads the view: currentActor(), today() or now(). A collection stores the query's source, filters, sorts and limit: content.collection(q) refuses a query with search or offset with a ValidationError.

import { content, currentActor, query, today } from "@standards/client";

content.collection(
  query("tasks").eq("assignee", currentActor()).lt("dueDate", today()).orderBy("dueDate")
).list();

where(...rules) appends rules exactly as written, for what the verbs do not cover: any operator, a qualified relation property, or a quantifier on a qualified property.

query("deals").where({ attribute: "contacts", property: "signedDate", operator: "is_not_empty", value: null });

The same query also feeds aggregate().

OptionNotes
.table(), .list(), .kanban(groupByAttribute)One layout; switching clears layout-specific settings
.columns(...attributes)Columns of a table, fields of a card
.actions(...)create, attach, update, delete, detach; .actions() hides every mutation. On a relation collection, attach turns the toolbar's Add button into a single "New" button whose menu offers "Link an existing record" (the relation picker, which links one or several existing records and keeps creation as its last row) and, with create, "Create a new record". Without create the button reads "Link" and opens the picker directly; on a single relation that already holds a record it reads "Change" and replaces the link. With detach, a row's context menu (right-click in a table, the row's ⋯ in a list or board), the selection toolbar and Backspace on checked table rows unlink the record, named after the record being viewed — "Unlink from Acme" — and a table row's name cell shows "Open" on hover with a chevron holding "Unlink from Acme" and, with delete, "Delete record": in a table or a list the rows leave at once (a board card leaves once the board re-reads), the record itself is kept, and the toast ("Link removed between Thomas Leroy and Acme") offers Undo for 8 seconds. An empty relation collection says "No record linked to Acme" and offers Link and Create. Without detach, nothing unlinks a record, the picker included. Omitted, a relation collection draws attach and detach. On a private or workspace tab, update lets people check rows and move them to the other tab; Delete appears only with delete
.openMode("page" | "peek")Omitted means page, and no peek history
.createMode("page" | "peek" | "inline")Omitted follows the resolved open mode
.visibility("workspace" | "private" | "all")Who sees the tab
.displayAttributes(...), .descriptionAttribute(name)Card rendering. On a list, a select, status or multiselect value shows an edit button on hover or focus that edits it in place, where the tab allows update and the viewer may edit the record; clicking the value itself still opens the record. Kanban cards only display
.columnSizing(widths)Table and list only
.kanbanColumnOrder(...), .kanbanColumnVisibility(map), .kanbanPinnedColumns(...)Kanban only

Actions never grant permissions: a user without update on the object does not gain it from a collection.

Forms inside a view

group(name, label) holds fields: .field(attribute, { label?, span?, readOnly? }) or .fields(...attributes), plus .description(text) and .collapsible(collapsed?). content.form(...groups) accepts .formColumns(1 | 2 | 3) and .density("comfortable" | "compact" | "spacious").

content.form( is a layout inside a view: it arranges the record's own attributes. The form system (form(), slots, steps, prefill) and workflows are not pushable through a schema source, so they are not part of this SDK.

Views in a source

Pass views next to the objects. Each one is built and validated locally, then sent with the objects on the next schema.sync.

import { defineSchemaSource } from "@standards/client";

export const crmSource = defineSchemaSource({
  id: "crm",
  objects: [CONTACT, COMPANY],
  views: [CONTACT_DETAIL, CONTACTS, COMPANY_DETAIL],
});

On the workspace a source-owned view is adopted the first time it is pushed, updated on every later change, and released when it leaves the source: the row becomes a custom view and workspace overlays survive. Declaring it again adopts the row back. The Sync page shows these counters in the SyncResult.

@standards/schema exports relationProperty(name) to reference a qualified property in a column list; @standards/client does not re-export it. Column references from a client-only project stay on plain attributes.

Next steps