Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions .changeset/27059-skills-record-page-rules.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
---
'@objectstack/skills': patch
---

docs(skills): objectstack-ui teaches the record page as the Console ships it, in one ordered rules file from the object's declarations to the reader's own choices

Clause-②: no
Metadata-protocol: none

- **Before.** `SKILL.md` described a record page with Details / Related / Timeline tabs and a shared Related tab for side cards, `rules/pages.md` and `rules/navigation.md` each carried a sentence of it, nothing taught `relatedListFilter`, the inherited row order, the six slots, `isDefault` or which record-page choices are the reader's, and an agent asked to "put the tasks first on the lead page" reached for a full page.
- **Now.** `rules/record-page.md` says, in order: the object's declarations (`nameField`, `highlightFields`, `stageField`, `fieldGroups`, `relatedLists`, `editMode`) produce the page and the surface's width lays it out (two columns from 760 px, three field columns from 1120 px, a tab row that enlarges one panel); the relationship flags shape the related column (`relatedList`, `relatedListTitle`, `relatedListColumns`, `relatedListFilter`, rows in the child's default list-view sort); a `slotted` page overrides one of six slots and has no `tabs` slot; `kind: 'full'` is the escape hatch; a record opens in the drawer unless the object's list view declares `navigation: { mode: 'page' }` (`modal` / `split` / `popover` are refused); and the reader's Open-records choice, ⊟ right column, panel order and pane width are never metadata. `SKILL.md`, `rules/pages.md` and `rules/navigation.md` keep one pointer each, and `evals/record-page.json` grades the lead-page task as `relatedList: 'primary'` and the full-page ask as the list view's declaration.
- The package's hand-authored markdown grows by 39 lines against a budget of 40, paid with the tab sentences the record-page retirements made false; the docs' "How a list opens a record" names the live modes and the reader's choices. No schema, key or behaviour changes.
5 changes: 4 additions & 1 deletion content/docs/ui/pages.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -203,7 +203,10 @@ pane or the drawer), never by the viewport, and by one rule. From 760 px the det
the right column, 320 px wide; from 1120 px the right column is 380 px and a details section
takes up to three field columns. Below 760 px (the split pane, a phone) the page is one column:
the details, then the right column's panels in their order. The **⊟** switch at the end of the
record header's actions (`⌘⇧,`) hides the right column for the reader, per object.
record header's actions (`⌘⇧,`) hides the right column for the reader, per object. The related
panels are the reader's to arrange, too: drag a panel's **⠿** handle (or **⌥↑** / **⌥↓** on it)
to reorder, hide a panel from its header, and bring hidden panels back from the column's last
line. The arrangement is kept per user and object, never in the object's declaration.

The related panels load in steps, and the details never wait for them. The page asks every
panel's count in one request set, sent beside the record read, and the panels show
Expand Down
30 changes: 21 additions & 9 deletions content/docs/ui/views.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -515,19 +515,31 @@ app page or action, where `features.*` stays bound and stays legal. A record *fi

## How a list opens a record

Clicking a row, card, node, bar or event opens the record in an overlay whose mode is
the view's `navigation.mode`: `drawer`, `modal`, `split` or `popover`. Every list-type
view (grid, tree, kanban, calendar, gantt, list, gallery, map and timeline) opens the
same record panel, so a record reads with its declared labels and typed values, without
its `hidden` fields, whichever view it came from. In `split` the view moves into the
left panel; in `popover` the panel is anchored to what was clicked.
Clicking a row, card, node, bar or event opens the record the one way its object declares:
in the **drawer**, which expands to the full page, unless the view's `navigation.mode` is
`page`, declared only for an object whose record should open as a full page (`new_window`
and `none` are the other two values). Every list-type view (grid, tree, kanban, calendar,
gantt, list, gallery, map and timeline) opens the same record page, so a record reads with
its declared labels and typed values, without its `hidden` fields, whichever view it came
from. `modal`, `split` and `popover` were removed in @objectstack/spec 18.3.0 (ADR-0087 D2):
a stored value is refused with its fix, and `os migrate meta --from 17` drops it. On a phone
every record opens as a page, whatever the view declares.

Whether the record opens beside the list is the reader's choice, not the view's. The list's
**Display** menu offers **Open records**: **Split** (the default) draws the record page in a
pane beside the list, **Drawer** draws it over the list, and **Full page** navigates. The
choice is kept in the browser for every drawer-mode object, and is not offered for an object
declared to open as a page; a record's **↗** always opens the full page. The reader also
drags the pane's edge, and **⤢** or **Open as full page** leaves the pane, while **Collapse
to drawer** returns to the stack it left. The `size` bucket on `navigation` sets only the
drawer's starting width; `width` is deprecated.

The record opened from a list is addressable: `?recordId=` on the list's URL, so a
reload, a shared link or Back reopens it. A record opened inside the drawer (a related
row, a lookup value) opens one level down in the same drawer; those levels are
in-memory, so a reload shows the root record. `⤢` opens a level's full page, and
**Collapse to drawer** returns to the stack it left. A record page's own layout is
described in [Pages](/docs/ui/pages).
in-memory, so a reload shows the root record. A record page's own layout, and the
reader's right-column and panel choices on it, are described in
[Pages](/docs/ui/pages#record-pages).

### A user's list state never writes the view

Expand Down
35 changes: 32 additions & 3 deletions scripts/check-skills-token-ratchet.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -639,7 +639,13 @@ export const CEILINGS = new Map([
// ceil($(wc -c < skills/objectstack-ui/SKILL.md) / 4) == ceil(15424 / 4)
// 3856 -> 3851: A2′ — the release-stamp line left the count (see
// STAMP_LINE_REPIN); the file's count fell by the same 5, headroom unchanged.
['skills/objectstack-ui/SKILL.md', 3851],
// 3851 -> 3544 (#27059, record-page epic #26676 child 8): LOWERED. The two related-list
// paragraphs that taught a Details / Related / Timeline tab strip (false since the
// record page's tab row and right column landed) left for a two-line pointer at
// `rules/record-page.md`; one Detailed Rules link and the CRM blueprint row reworded.
// The file is 14194 bytes less its stamp line -> 3544 tokens; the ceiling follows it
// down to the measurement, the ratchet's ordinary direction.
['skills/objectstack-ui/SKILL.md', 3544],
// 8333 -> 6193: RE-LOCK at the landed count after the #19715 split (decision
// batch #213 item 1, maintainer 「1 2 4 同意」): the worked R1, the report
// template and the v17-canonical shapes block moved VERBATIM into
Expand Down Expand Up @@ -855,13 +861,36 @@ export const CEILINGS = new Map([
// prescription that objectstack-data → Search Fields carries whole — so the file nets -4
// and the ceiling follows it down to the new measurement.
['skills/objectstack-ui/rules/list-views.md', 3129],
['skills/objectstack-ui/rules/navigation.md', 2273],
// 2273 -> 2196 (#27059): LOWERED. The Surface bullet points at `rules/record-page.md`,
// says create / edit is a modal or the `editMode: 'page'` route, and the Overlay-width
// bullet moved there whole (the `size` bucket, the deprecated pixel keys); the ceiling
// follows the file down to the measurement (8784 bytes).
['skills/objectstack-ui/rules/navigation.md', 2196],
// 5501 -> 5692 (#19715, card item 4): a 5-line table of contents at the top,
// anchors to the file's existing headings, nothing else changed. +191 tokens,
// zero headroom to absorb any of it, ceiling +191. Maintainer ruling
// 2026-09-22 on decision batch #213 item 1, verbatim and untranslated:
// 「1 2 4 同意」.
['skills/objectstack-ui/rules/pages.md', 5692],
// 5692 -> 5669 (#27059): LOWERED. The `tabs`-slot sentence is a one-line pointer at
// `rules/record-page.md`; the ceiling follows the file down to the measurement (22673 bytes).
['skills/objectstack-ui/rules/pages.md', 5669],
// objectstack-ui `rules/record-page.md` — the sixth rules file, ordered by card #27059
// (record-page epic #26676 child 8, dispatched by ui seat 1 2026-11-01, with the seat's
// ruling that the record page gets its own rules file rather than rows in the files
// PR #28488 holds open). INITIAL measurement AT its landed count, on the same terms as
// the #14296 rows above: zero headroom, the next token added here is paid for by
// deleting one from this file. What it teaches, in order: the object's declarations
// produce the page, the relationship flags shape the related column, a slotted page
// overrides one of six slots (no `tabs`), `kind: 'full'` is the escape hatch, the
// drawer unless the object declares `page`, and the reader's own choices are not
// metadata. PAID DOWN FIRST by the 404 tokens the three rows above gave up (the tab
// strip the record page no longer has, SKILL.md 307 + navigation.md 77 + pages.md 20, its
// ceiling 23);
// the card's binding budget is the package's hand-authored LINE count, +40 at most,
// landed at +39 (1876 -> 1915), so the package nets +592 tokens, every one of them
// teaching the surface `main` ships today and none of it restating a sibling file.
// ceil($(wc -c < skills/objectstack-ui/rules/record-page.md) / 4) == ceil(3981 / 4)
['skills/objectstack-ui/rules/record-page.md', 996],

// ── 2026-09-22 the #19715 split — decision batch #213 item 1 ─────────────
// The maintainer ruled the four over-500-line published SKILL.md files split
Expand Down
28 changes: 4 additions & 24 deletions skills/objectstack-ui/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -102,29 +102,8 @@ authored on this side: a trailing **ghost** row always exists, so never add an
never persisted); `autofill: false` opts one column out of the typeahead copy;
and the form's `taxRateField` overrides which parent field drives the totals.

**Read side — detail-page related lists.** The mirror of `inlineEdit` is the
related list on the parent's record DETAIL page. You don't author it: every
child relationship is shown as a related list by default (owned `master_detail`
children first). Refine on the relationship:
- `relatedList: 'primary'` — main content: a full-width body table (see layout
below); it places, the parent's `relatedLists` orders (ADR-0085).
- `relatedList: false` — suppress a noisy child from the detail page.
- `relatedListTitle` / `relatedListColumns` — override the derived title /
columns (both optional; columns otherwise auto-derive from the child object's
`highlightFields`). See objectstack-data → Relationships → Detail-page related lists.

**Related-list layout.** On the synthesized record page, each
`relatedList: 'primary'` child (default: inline-edited `master_detail`) is a
body table in the details column after the first field section (in the
Details tab, no tab of its own); every other list is a side card in one shared
**Related** tab, both in the object's `relatedLists` order. Field changes
(`trackHistory`) join the Timeline tab under its Changes filter; there is no
History tab. The object declares no per-surface *layout* hints: the
`detail.relatedLayout` toggle, object-level `detail: {...}` block and app-level
`relatedLayout` are **removed** (ADR-0085). For arrangements the relationship
layer can't express — filtered splits (e.g. Open vs Closed tabs), a chart/report
tab — assign the object a **custom record Page** and lay it out
explicitly with `record:related_list` (or inline-editable `line_items`) blocks.
**Read side — the record page.** Child relationships are related lists on the
parent's record page by default; the flags, slots and open mode: [Record Page](./rules/record-page.md).

### Field Conditional Rules in Forms

Expand All @@ -146,6 +125,7 @@ authoring non-trivial CEL.
- [Navigation & Run Modes](./rules/navigation.md) — `App.create`, the three run modes, record presentation.
- [Dashboards, Reports & Cubes](./rules/dashboards.md) — widgets, dataset binding, filters, `compareTo`, bucketing, `options`, drilldown, report config, cubes.
- [Pages & Docs](./rules/pages.md) — page types, regions, components, print pages, the html/react source tiers, styling, package docs.
- [Record Page](./rules/record-page.md) — declarations → page, relationship flags, slotted / full overrides, drawer vs `page`, the reader's own choices.
- [Actions](./rules/actions.md) — `locations`, visibility, examples, `ctx`, new tab, params.

---
Expand Down Expand Up @@ -207,7 +187,7 @@ Use this CRM-style structure as the canonical UI assembly reference:
| **Analytics dataset** | `src/datasets/*.dataset.ts` | One per object you want reportable — dashboards and reports bind a **declared** dataset by name, so an object without one has no analytics face |
| Dashboards | `src/dashboards/*.dashboard.ts` | Combine KPI + chart + table widgets with shared `dateRange` and `globalFilters` |
| Reports | `src/reports/*.report.ts` | Select `rows` (dimensions) + `values` (measures) from that dataset; tabular/summary/matrix/joined |
| Record pages | `src/pages/*.page.ts` | Compose `regions` + components (`page:header`, `record:highlights`, related lists, tabs) |
| Record pages | — (synthesized) | The object's declarations produce the page ([Record Page](./rules/record-page.md)); `src/pages/*.page.ts` only to override a `slotted` slot or for a `kind: 'full'` layout |
| User actions | `src/actions/*.actions.ts` | Use `flow` for orchestration and `script` for parameterized bulk mutations |

This blueprint is the default for “build a complete metadata app UI” tasks.
Expand Down
4 changes: 4 additions & 0 deletions skills/objectstack-ui/evals/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@ view / page / dashboard / report metadata.
dashboard/report widgets: when a data need fits a `defineDataset`, when it
must escalate to a stored field or app code, and when an ad-hoc
in-page `<ObjectChart>` needs no dataset at all.
- `record-page.json` — the record page as the object declares it: a child
table placed first by `relatedList: 'primary'` (no page file), and
`navigation: { mode: 'page' }` on the list view with the reader's own
open choice left out of metadata.
- `report-period-export.json` — a non-`joined` report's `dateRange` (one
`type: 'date'` dataset dimension) and `exportOptions` (csv / xlsx under the
base object's `allowExport` permission).
Expand Down
40 changes: 40 additions & 0 deletions skills/objectstack-ui/evals/record-page.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
{
"skill_name": "objectstack-ui",
"evals": [
{
"id": 1,
"prompt": "On the lead record page, put the tasks first: our reps want the open tasks table before anything else when they open a lead. The `task` object already has a `lead` master_detail field.",
"expected_output": "No page file. One flag on the child's reference field — `relatedList: 'primary'` on `task.lead` — makes the tasks a full-width body table in the lead's details, after the first field section; `relatedListFilter: { status: { $ne: 'done' } }` keeps it to open tasks, and the lead's `relatedLists: ['task', ...]` puts it before the other lists. Not a `definePage` with `kind: 'full'`, and never a `slots.tabs`.",
"files": [],
"assertions": {
"must_contain": [
"relatedList: 'primary'",
"relatedLists"
],
"must_not_contain": [
"definePage",
"slots.tabs",
"kind: 'full'"
]
}
},
{
"id": 2,
"prompt": "Contracts should open as a full page, not the side drawer. Reps also keep asking for records to open beside the list — add that to the contract object too.",
"expected_output": "One declaration: `navigation: { mode: 'page' }` on the contract list view. The pane beside the list is each reader's own choice in the list's Display menu (Open records: split, drawer or full page), kept in the browser and offered for drawer-mode objects only, so nothing is authored for it; `split`, `modal` and `popover` were removed in @objectstack/spec 18.3.0 and are refused.",
"files": [],
"assertions": {
"must_contain": [
"navigation: { mode: 'page' }",
"Display menu"
],
"must_not_contain": [
"mode: 'split'",
"mode: 'modal'",
"mode: 'popover'",
"definePage"
]
}
}
]
}
13 changes: 4 additions & 9 deletions skills/objectstack-ui/rules/navigation.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,24 +124,19 @@ export const TaskWorkbenchPage = definePage({

A record's create / edit / detail **presents itself adaptively**. You do **not**
author the surface, the overlay width, or the column count — all three are derived
at runtime from how heavy the record is + the client viewport, because an author
at runtime from the client viewport, because an author
(especially an AI) cannot know the client's screen. **Write the data (fields,
`fieldGroups`); let the platform lay it out.**

- **Surface.** A clicked record opens in a **drawer** that expands to the full
page (on mobile, a **page**); create/edit opens in a **modal** for a
field-heavy record, otherwise a drawer. Set `navigation.mode: 'page'` on the
list view only for an object whose record should open as a full page — or,
for bespoke layout, assign a record `Page`.
- **Surface.** A clicked record opens in the **drawer**, which expands to the full
page; `page` only by declaration ([Record Page](./record-page.md) §5–6). Create /
edit opens in a **modal**, or the full-page route when the object declares `editMode: 'page'`.
- **Field width.** **Omit it** (`auto` sizes by widget type × current columns —
textarea / rich-text / file take the row). Both keys are valid: `colSpan`
(1–4) is clamped to the current column count, grid-aligned at every width;
`span: 'full'` resolves to the form grid's full column count, but the shipped
renderer emits it at the widest tier only (a 3-column form: one cell of two
in a modal).
- **Overlay width.** Never author pixels. If you must nudge, use the **`size`**
bucket (`sm` | `md` | `lg` | `xl` | `full`) on `navigation`; the pixel
`width` / `drawerWidth` are deprecated.
- **Column count.** Not authored. The form grid follows its **real rendered width**
via container queries — the same form is 1 column in a narrow drawer and up to 4
on a wide page. Author *grouping* with `fieldGroups` + `Field.group`.
Expand Down
3 changes: 1 addition & 2 deletions skills/objectstack-ui/rules/pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,7 @@ five) or `dashboard` (`header`, `footer`; other regions are grid cells);
`full-width` is retired (a second name for `default`). On a `kind: 'full'` page a region
the template does not place, or a second one for a filled slot, is refused at
its `name` — the left column is `sidebar`, the right `aside`.
A `slotted` record page has no `tabs` slot: shape the strip with `relatedList` /
`relatedList: 'primary'` on the child's reference field, or author `kind: 'full'`.
A `slotted` record page has no `tabs` slot — see [Record Page](./record-page.md).

### Component Catalogue (selection)

Expand Down
Loading
Loading