diff --git a/.changeset/27059-skills-record-page-rules.md b/.changeset/27059-skills-record-page-rules.md new file mode 100644 index 00000000000..351fd79aa21 --- /dev/null +++ b/.changeset/27059-skills-record-page-rules.md @@ -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. diff --git a/content/docs/ui/pages.mdx b/content/docs/ui/pages.mdx index e7860c18ba1..efdbd7c029c 100644 --- a/content/docs/ui/pages.mdx +++ b/content/docs/ui/pages.mdx @@ -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 diff --git a/content/docs/ui/views.mdx b/content/docs/ui/views.mdx index 4f376004d55..0d5d4d9d63f 100644 --- a/content/docs/ui/views.mdx +++ b/content/docs/ui/views.mdx @@ -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 diff --git a/scripts/check-skills-token-ratchet.mjs b/scripts/check-skills-token-ratchet.mjs index d6d357352f3..de57562bbd6 100644 --- a/scripts/check-skills-token-ratchet.mjs +++ b/scripts/check-skills-token-ratchet.mjs @@ -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 @@ -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 diff --git a/skills/objectstack-ui/SKILL.md b/skills/objectstack-ui/SKILL.md index 0aebe356f19..83f2216f56a 100644 --- a/skills/objectstack-ui/SKILL.md +++ b/skills/objectstack-ui/SKILL.md @@ -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 @@ -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. --- @@ -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. diff --git a/skills/objectstack-ui/evals/README.md b/skills/objectstack-ui/evals/README.md index 07c61d26061..581d755d9cb 100644 --- a/skills/objectstack-ui/evals/README.md +++ b/skills/objectstack-ui/evals/README.md @@ -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 `` 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). diff --git a/skills/objectstack-ui/evals/record-page.json b/skills/objectstack-ui/evals/record-page.json new file mode 100644 index 00000000000..9f686c7baed --- /dev/null +++ b/skills/objectstack-ui/evals/record-page.json @@ -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" + ] + } + } + ] +} diff --git a/skills/objectstack-ui/rules/navigation.md b/skills/objectstack-ui/rules/navigation.md index a83fd6539b2..6698cd905f5 100644 --- a/skills/objectstack-ui/rules/navigation.md +++ b/skills/objectstack-ui/rules/navigation.md @@ -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`. diff --git a/skills/objectstack-ui/rules/pages.md b/skills/objectstack-ui/rules/pages.md index a6fee474f22..c6e1ad85b62 100644 --- a/skills/objectstack-ui/rules/pages.md +++ b/skills/objectstack-ui/rules/pages.md @@ -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) diff --git a/skills/objectstack-ui/rules/record-page.md b/skills/objectstack-ui/rules/record-page.md new file mode 100644 index 00000000000..5e39054ef43 --- /dev/null +++ b/skills/objectstack-ui/rules/record-page.md @@ -0,0 +1,65 @@ +# Record Page — the object declares it, the Console lays it out + +Read in order — each step is the remedy for the one before it; author `*.page.ts` only at step 3 or 4 (ADR-0085). + +**1 · The object's declarations produce the page.** With no page assigned, the +Console synthesizes the record page from the object alone: `nameField` is the +title, `highlightFields` the strip under it, `stageField` the stage path (each +option declares `kind: 'open' | 'won' | 'lost'`; `false` suppresses it), +`fieldGroups` + `Field.group` the detail sections, `relatedLists` the business +order of the related lists, `editMode` (`'modal'` | `'page'`) how Edit opens. +Layout follows the width of the surface the page occupies, never a key (the +object's `detail` block and `relatedLayout` are removed, ADR-0085): two columns +from 760 px (details | 动态 and the related panels), three-column sections from +1120 px, one column below; a tab row under the header (Overview, 动态, one tab +per panel with its count) enlarges a panel at `?related=object.field`. + +**2 · Relationship flags shape the related column.** On the CHILD object's +reference field: `relatedList: 'primary'` draws the child as a full-width body +table in the details after the first field section (the default for a +`master_detail` with `inlineEdit`); `true` / absent is a panel in the right +column; `false` leaves the child off the page. `relatedListTitle` / +`relatedListColumns` (child field names) replace the derived title / columns; +`relatedListFilter` (Query-DSL, `{ status: { $ne: 'done' } }`) is AND-composed +with the parent condition, and the panel's count honours it. Rows follow the +child's default list view `sort` (`isDefault`, else the first declared); the +parent's `relatedLists` orders tables and panels alike. "Put the tasks first on +the lead page" is `relatedList: 'primary'` on `task.lead` — no page file. + +**3 · A slotted page overrides one slot.** `kind: 'slotted'` replaces only the +slots it names — `header`, `actions`, `alerts`, `highlights`, `details`, +`discussion` — each whole (no deep merge); the synthesizer fills the rest. There +is no `tabs` slot: `slots.tabs`, `slots.related` and `slots.relatedLists` are +refused by name. `isDefault: true` makes it the object's default record page. + +**4 · `kind: 'full'` is the escape hatch.** A record page that looks nothing like +the default — a filtered split (Open vs Closed), a chart tab, a bespoke strip — is +`kind: 'full'`: `regions` drawn as authored ([Pages](./pages.md)), reached for only +when steps 1–3 cannot express the page. + +**5 · The drawer, unless the object declares `page`.** A clicked row opens the +record in the drawer, which expands to the full page (one page at two widths); +`navigation: { mode: 'page' }` on the object's list view is for an object whose +record should open as the full page (`new_window`, `none` are the other members). +`modal` / `split` / `popover` were removed in @objectstack/spec 18.3.0 (ADR-0087 +D2) and are refused with their fix. Field count never decides; a phone always pages. + +**6 · The reader's own choices are not metadata.** ⛔ Never author these: the +Display menu's **Open records** (split beside the list — the default — or drawer, +or full page; offered for a drawer-mode object only), the ⊟ right-column switch +(`⌘⇧,`, per object), the related panels' order and hidden set (⠿ drag, ⌥↑ / ⌥↓, +per user × object), the pane's dragged width and ⤢. They live in the console's +preference store, never in the object; on `navigation` only the `size` bucket +(`sm`…`full`) nudges the drawer's starting width (`width` / `drawerWidth` deprecated). + + +```typescript +import { Field, ObjectSchema } from '@objectstack/spec/data'; + +export const Task = ObjectSchema.create({ + name: 'task', sharingModel: 'controlled_by_parent', + fields: { + lead: Field.masterDetail('lead', { relatedList: 'primary', relatedListFilter: { status: { $ne: 'done' } } }), + }, +}); +```