Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
e07200b
refactor(plugin-detail): BuildPageOptions loses the dead surface hint…
claude Oct 28, 2026
81962fe
wip(plugin-detail,components,app-shell): record page v3 face — framel…
claude Oct 28, 2026
1aa0191
wip(plugin-detail,app-shell): compact related cards and the reader's …
claude Oct 28, 2026
402a990
wip(plugin-detail,app-shell): enlarge a related card in place, addres…
claude Oct 28, 2026
f5b98ab
wip(plugin-detail,app-shell): body tables in the details column after…
claude Oct 28, 2026
e544f87
wip(plugin-detail,app-shell): the timeline as one stream and the v3 t…
claude Oct 28, 2026
43ff8bc
wip(plugin-detail,i18n): timeline stream tests, ten-pack copy, supers…
claude Oct 28, 2026
8d1c79c
wip(plugin-detail,app-shell): the tabbed tier's related tab in the re…
claude Oct 28, 2026
f92d10f
chore(changeset): record page v3
claude Oct 28, 2026
0fffdda
fix(plugin-detail): type errors the package build caught
claude Oct 28, 2026
15dac89
fix(plugin-detail,app-shell): the stream windows the newest entries; …
claude Oct 28, 2026
aef002f
test(plugin-detail,app-shell): pins follow the timeline tab and the f…
claude Oct 28, 2026
c9f2290
Merge origin/main into claude/issue-27055-record-page-v3-layout
hotlong Oct 28, 2026
49b9a99
test(plugin-detail): drop the authored-tabs-slot pin the slots.tabs r…
hotlong Oct 28, 2026
e435ef3
fix(plugin-detail,i18n,e2e): the five UI gates the record page v3 bra…
hotlong Oct 28, 2026
349cd56
test(app-shell): drop the unused screen import Lint UI reds on
hotlong Oct 28, 2026
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
28 changes: 28 additions & 0 deletions .changeset/27055-record-page-v3-layout.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
'@object-ui/plugin-detail': patch
'@object-ui/components': patch
'@object-ui/app-shell': patch
'@object-ui/i18n': patch
---

feat(plugin-detail,app-shell,components): the record page v3 — frameless details, one timeline, compact related cards the reader arranges, body tables, enlarge in place

Clause-②: no
Metadata-protocol: none

The synthesized default record page (an object with no assigned page) takes the v3 layout. Nothing an author writes changes: the page is still drawn from the object's semantic roles (ADR-0085), and every change below is the renderer's.

What changes for a reader:

- **Header.** One filled primary action, two outline actions, the rest in the `⋯` menu. The header's Refresh moved into that menu as its first item. Up to six highlights, drawn as an equal-width grid on the default page.
- **Details.** Sections are frameless: a title over a hairline, then the fields in one to three columns by how many fields the section shows. Every titled section collapses from its title. Sections with no value at all fold into one line at the foot, such as 「Address · Conversion · Extra · 3 empty sections · Expand」. The created / updated line closes the details column and also names the owner and the record id.
- **Timeline.** One stream: comments, `sys_activity` rows and field changes (`trackHistory`), newest first, with the composer on top and the chips All / Activity / Discussion / Changes. Field changes and system events are one muted line each. 「View all N →」 shows the rest. The separate History tab, the Activity tab and the discussion footer are gone. With `enable.feeds: false` the stream keeps activity and changes and offers no composer.
- **Related cards.** In the related column each related list is a compact card: title, count and + in one row, no table header, up to three rows of the record's name and one status or date chip. The column is 320 px, beside details and the timeline at 1.25fr : 1fr. 「查看全部」 is gone from the card.
- **The reader arranges the cards.** On desktop the title row drags a card, with a drop line. The card's `⋯` menu has Move up, Move down, Collapse and Hide. A collapsed card shows its title and count and reads no rows. The order, collapsed and hidden state are kept per user and object in the console's workspace preferences, the same store as the column switches, and apply in the tabbed tier's Related tab too. A reader who never dragged sees the default order. A later change to the default does not reorder a dragged column; the column then says 「Default order updated · Restore default」. A relationship added since the reader's last drag lands after its nearest default neighbour and carries a 「New」 badge for 48 hours. Hidden cards are listed at the column's foot (「N hidden · Show」).
- **Enlarge in place.** A card's ⤢ or its count opens the list as a full-width table over the page body, with every column, paging, sort and inline edit. The address is `?related=object.field`; Esc or ‹ returns to the columns.
- **Body tables.** A related list that is the record's own body is drawn as a table inside the details column, right after the first field section, instead of a side card. A body list is a `master_detail` FK declaring `inlineEdit`, or a list marked `relatedList: 'primary'`. A body table opens on ten rows and has a totals line for its numeric columns over the rows on the page.
- **Tabs.** The drawer and narrow widths show Details / Timeline / Related / (Approvals). The Timeline is second for a worked object and last otherwise. Attachments close the Related tab.

**BREAKING** (`@object-ui/plugin-detail`, TypeScript only): `BuildPageOptions` no longer accepts `hideHighlights`, `hidePath`, `showReferenceRail`, `hideReferenceRail`, `hideRelatedTab`, `relatedLayout`, `recordChrome`, `showActivity`, `history`, or the `slots.rightRail` slot. No console caller passed any of them. A caller still passing one gets a compile error naming the key, and the page it draws is the same with the key or without it. The `record:reference_rail` renderer stays for authored pages. `buildDefaultHeader` takes `{ actions }` only, and `buildDefaultHighlights` drops its two hide flags.

<!-- adr-0087: not-required (runtime-interface-only ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts#BuildPageOptions) a renderer's options interface: no Zod schema, no spec declaration and no stored representation reads it, so the compiler is the channel that reaches every caller -->
2 changes: 1 addition & 1 deletion content/docs/protocol/objectql/security.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -601,7 +601,7 @@ Set `deterministicEncryption: true` to allow equality queries on the ciphertext,

Object- and field-level history is opt-in metadata, not a free-form `enable.audit` block.

- **Object history tab** — set `enable.trackHistory: true` on the object (the `ObjectCapabilities` block in `object.zod.ts`) to surface the record History tab.
- **Object history** — set `enable.trackHistory: true` on the object (the `ObjectCapabilities` block in `object.zod.ts`) to surface the record's field changes. On the default record page they join the one Timeline stream (its Changes filter); an assigned page shows them where it places a `record:history` block.
- **Per-field history** — set `trackHistory: true` on an individual field to render that field's value changes on the record activity timeline. (The former per-field `auditTrail` flag was pruned in 2026-06 — it had no runtime consumer.)
- **RLS audit** — removed (2026-07, ADR-0056 D8): the `RLSAuditConfigSchema`/`RLSAuditEventSchema` shapes were never emitted or read by the runtime RLS path and were deleted from the spec. Enforcement decisions surface today through the security plugin's fail-closed warnings and the standard audit log (`plugin-audit`), not a dedicated RLS event stream.

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ const host = {

const EN: I18nConfig = { defaultLanguage: 'en', detectBrowserLanguage: false };

function openRequestPage() {
function openRequestPage(search = '') {
const ds = createApprovalRequestsDataSource({
host: host as unknown as DataSource,
scope: { kind: 'awaiting_me', approverIds: ['u1'] },
Expand All @@ -107,7 +107,7 @@ function openRequestPage() {
} as never;
render(
<I18nProvider config={EN} persistLanguage={false}>
<MemoryRouter initialEntries={[`/apps/demo/${APPROVAL_REQUEST_OBJECT}/record/${REQ}`]}>
<MemoryRouter initialEntries={[`/apps/demo/${APPROVAL_REQUEST_OBJECT}/record/${REQ}${search}`]}>
<MetadataCtx.Provider value={metadata}>
<RecordDetailView
dataSource={ds as never}
Expand Down Expand Up @@ -158,7 +158,9 @@ describe('the record page over the routed approvals source (objectui#12032)', ()

it('control: a found request renders, read once from the get route with no query', async () => {
answer = 'ok';
openRequestPage();
// Opened on the Timeline tab: the synthesized page's feed is a tab since
// #27055 (no footer), and its reads are the side reads this pins.
openRequestPage('?tab=timeline');
expect((await screen.findAllByText('invoice_approval', undefined, { timeout: 5000 })).length).toBeGreaterThan(0);
await waitFor(() => expect(screen.queryByTestId('record-load-failed')).toBeNull());
expect(new Set(getReads)).toEqual(new Set([`/api/v1/approvals/requests/${REQ}`]));
Expand Down
2 changes: 0 additions & 2 deletions ui/content/docs/guide/slotted-pages.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,8 +130,6 @@ if you'd authored nothing else. The default tab strip is **Details** → one
tab per related list flagged `relatedList: 'primary'` on its relationship → a
shared **Related** tab for the rest → **History**; so promoting a child table to
its own tab is a one-word change on the relationship, not a page-authoring task.
`relatedLayout: 'tabs' | 'stack'` remains an app-level override (force
all-own-tabs / all-stacked).

That strip is the page at its narrow width. The console lays the same page
out by the width of the view it occupies (the drawer, the split pane or the
Expand Down
10 changes: 6 additions & 4 deletions ui/e2e/record-header-title-width.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -574,16 +574,18 @@ const PAGE_HEADER = {
'sm:flex-row',
]),
titleColumn: classBefore(containersSrc, CONTAINERS_FILE, '<RecordTitleChip', ['flex-col', 'min-w-0']),
// The tail as it renders when the host wires `refresh` (the console does).
tail: classAt(containersSrc, CONTAINERS_FILE, 'if (!refreshButton) return actions;', 1, ['shrink-0']),
// The tail is `PageHeaderActions`' toolbar — the one row whether or not the
// host wires `refresh`, which is the first item of its `⋯` menu since
// objectstack#27055 (no separate `⟳` button).
tail: classBefore(containersSrc, CONTAINERS_FILE, 'role="toolbar"', ['shrink-0']),
chip: classByPattern(chipSrc, CHIP_FILE, /<div className=\{cn\('([^']+)', className\)\}>/, ['min-w-0']),
chipRow: classBefore(chipSrc, CHIP_FILE, '{icon && (', ['flex-wrap']),
// #1 after the anchor is the icon's own span; #2 is the h1, #3 the object label.
h1: classAt(chipSrc, CHIP_FILE, '{icon && (', 2, ['truncate']),
objectLabel: classAt(chipSrc, CHIP_FILE, '{icon && (', 3, ['shrink-0']),
};

/** The mobile tail: one inline action, the `⋯` trigger and the `⟳` chrome. */
/** The mobile tail: one inline action (`mobileMaxVisible` 1) and the `⋯` trigger, which carries refresh. */
const PAGE_HEADER_TAIL_BOX = 'h-9 w-12 shrink-0';

const pageHeaderFixture = (title: string) =>
Expand All @@ -598,7 +600,7 @@ const pageHeaderFixture = (title: string) =>
`<h1 class="${PAGE_HEADER.h1}">${title}</h1><span class="${PAGE_HEADER.objectLabel}">Case</span>`,
),
),
) + box(PAGE_HEADER.tail, Array.from({ length: 3 }, () => box(PAGE_HEADER_TAIL_BOX)).join('')),
) + box(PAGE_HEADER.tail, Array.from({ length: 2 }, () => box(PAGE_HEADER_TAIL_BOX)).join('')),
);

const PAGE_HEADER_CANDIDATES = [...Object.values(PAGE_HEADER), PAGE_HEADER_TAIL_BOX]
Expand Down
8 changes: 8 additions & 0 deletions ui/packages/app-shell/src/urlParams.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,13 @@ export const RECORD_FORM_LINK_PARAM = 'formLink';
/** Active record-detail tab (objectui#2257; stable semantic values). */
export const RECORD_DETAIL_TAB_PARAM = 'tab';

/**
* The record page's enlarged related list, `object.field` — the child object
* and its FK field (objectstack#27055): the list drawn as a full-width table
* over the page body. Read by `RecordDetailView` in the column tiers.
*/
export const RECORD_RELATED_ENLARGE_PARAM = 'related';

/** Command palette overlay (ADR-0054 Phase 1; alias `cmdk`). */
export const COMMAND_PALETTE_PARAM = 'palette';

Expand Down Expand Up @@ -138,6 +145,7 @@ export const RESERVED_URL_PARAMS: readonly string[] = [
RECORD_FORM_OBJECT_PARAM,
RECORD_FORM_LINK_PARAM,
RECORD_DETAIL_TAB_PARAM,
RECORD_RELATED_ENLARGE_PARAM,
COMMAND_PALETTE_PARAM,
KEYBOARD_SHORTCUTS_PARAM,
RECORD_TRAIL_PARAM,
Expand Down
23 changes: 23 additions & 0 deletions ui/packages/app-shell/src/utils/workspacePreference.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@
*/

import { useCallback, useSyncExternalStore } from 'react';
import {
relatedArrangementFromRecord,
relatedArrangementToRecord,
type RelatedArrangement,
} from '@object-ui/plugin-detail';

/**
* The console's per-reader workspace preferences — how a reader likes the
Expand Down Expand Up @@ -151,6 +156,24 @@ export const RECORD_RELATED_COLUMN_PREFERENCE = defineWorkspacePreference<Prefer
codec: switchCodec,
});

/**
* How the reader arranged the record page's related cards, per object
* (scope `object:OBJECT_NAME`, objectstack#27055): `{ v, order, collapsed,
* hidden, basedOn }` — plus `seen`, the first-seen stamps of relationships new
* since the last drag (the 48 h 「新」 badge needs a clock). The value is a
* DIFF against the default order; `resolveRelatedArrangement` in
* `@object-ui/plugin-detail` lays it over the default. With no choice the
* host passes `EMPTY_RELATED_ARRANGEMENT`: the default order, every card open.
*/
export const RECORD_RELATED_ARRANGEMENT_PREFERENCE = defineWorkspacePreference<RelatedArrangement>({
name: 'record.related.arrangement',
codec: versionedPreferenceCodec<RelatedArrangement>({
version: 1,
toRecord: relatedArrangementToRecord,
fromRecord: relatedArrangementFromRecord,
}),
});

/** The storage surface a read takes — `localStorage`, or a test double. */
export type WorkspacePreferenceReadStorage = Pick<Storage, 'getItem'>;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -149,7 +149,7 @@ describe('the record page feed names the actor of an id-only activity row (objec
it('reads `sys_activity` with `actor_id` expanded and shows the user, not "System"', async () => {
const dataSource = makeDataSource();
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=timeline`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={dataSource}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,7 +180,7 @@ function makeMetadata() {

function tree(dataSource: any) {
return (
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=timeline`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={dataSource}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -204,7 +204,7 @@ function makeMetadata() {
/** Render the console record page over the seeded `sys_activity` rows. */
const renderWith = (rows: Array<Record<string, any>>) =>
render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=timeline`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={makeDataSource(rows)}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@

import * as React from 'react';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { cleanup, render, screen } from '@testing-library/react';
import { cleanup, render, waitFor } from '@testing-library/react';
import { MemoryRouter } from 'react-router-dom';
import { MetadataCtx } from '@object-ui/react';
import { I18nProvider, LocalizationProvider } from '@object-ui/i18n';
Expand Down Expand Up @@ -135,7 +135,7 @@ function renderHistory(locale: string) {
return render(
<I18nProvider config={{ defaultLanguage: 'en', detectBrowserLanguage: false }} persistLanguage={false}>
<LocalizationProvider value={{ locale }}>
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=history`]}>
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}?tab=timeline`]}>
<MetadataCtx.Provider value={makeMetadata()}>
<RecordDetailView
dataSource={makeDataSource()}
Expand All @@ -152,17 +152,28 @@ function renderHistory(locale: string) {
);
}

/** The rendered diff line for `close_date`, under an ENGLISH UI with `locale` as the display locale. */
/**
* The rendered diff line for `close_date`, under an ENGLISH UI with `locale` as
* the display locale. Since objectstack#27055 the record page draws the audit
* entries in the Timeline tab's one stream, each change as one compact line
* (`data-stream-change`).
*/
async function diffLine(locale: string): Promise<string> {
renderHistory(locale);
const label = await screen.findByText('Close date', { selector: 'li > span' });
const text = (label.closest('li')?.textContent ?? '').replace(/\s+/g, ' ').trim();
let text = '';
await waitFor(() => {
const line = Array.from(document.querySelectorAll('[data-stream-change]')).find((el) =>
(el.textContent ?? '').startsWith('Close date'),
);
expect(line).toBeTruthy();
text = (line!.textContent ?? '').replace(/\s+/g, ' ').trim();
});
cleanup();
return text;
}

const expected = (locale: string) =>
`Close date: ${new Date(`${FROM_DATE}T00:00:00Z`).toLocaleDateString(locale)} → ${new Date(`${TO_DATE}T00:00:00Z`).toLocaleDateString(locale)}`;
`Close date ${new Date(`${FROM_DATE}T00:00:00Z`).toLocaleDateString(locale)} → ${new Date(`${TO_DATE}T00:00:00Z`).toLocaleDateString(locale)}`;

beforeEach(() => {
// Unrelated chrome on this view reaches for the platform API; answering it
Expand All @@ -187,13 +198,13 @@ afterEach(() => {
describe('RecordDetailView — the audit history dates follow the display locale (objectui#10442)', () => {
it('formats the date diff as de-CH under an English UI with a de-CH display locale', async () => {
const text = await diffLine('de-CH');
expect(text, `got: ${text}`).toBe('Close date: 3.3.2020 → 4.3.2020');
expect(text, `got: ${text}`).toBe('Close date 3.3.2020 → 4.3.2020');
expect(text).toBe(expected('de-CH'));
});

it('control: formats the date diff as en-US under an en-US display locale', async () => {
const text = await diffLine('en-US');
expect(text, `got: ${text}`).toBe('Close date: 3/3/2020 → 3/4/2020');
expect(text, `got: ${text}`).toBe('Close date 3/3/2020 → 3/4/2020');
expect(text).toBe(expected('en-US'));
});

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -185,9 +185,9 @@ function makeMetadata(objects: any[], pages: any[]) {
} as any;
}

function renderDetail(opts: { objects: any[]; pages: any[]; comments?: Array<Record<string, any>> }) {
function renderDetail(opts: { objects: any[]; pages: any[]; comments?: Array<Record<string, any>>; search?: string }) {
return render(
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}`]}>
<MemoryRouter initialEntries={[`/app/demo/${OBJECT_NAME}/${RECORD_ID}${opts.search ?? ''}`]}>
<MetadataCtx.Provider value={makeMetadata(opts.objects, opts.pages)}>
<RecordDetailView
dataSource={makeDataSource(opts.comments ?? [])}
Expand Down Expand Up @@ -281,15 +281,16 @@ describe('objectui#7298 — the discussion panel is composed, never appended', (
expect(composerCount()).toBe(0);
});

it('the SYNTHESIZED default page still shows the panel — it declares the node', async () => {
// No authored page ⇒ `buildDefaultPageSchema`, which composes
// `record:discussion` itself. The ruling removes the host's append, not the
// default page's panel, so the out-of-the-box record page is unchanged.
renderDetail({ objects: OBJECTS_FEEDS_ON, pages: [], comments: ONE_COMMENT });
it('the SYNTHESIZED default page still shows the discussion — its Timeline stream declares it', async () => {
// No authored page ⇒ `buildDefaultPageSchema`, which composes the timeline
// stream itself (objectstack#27055: discussion, activity and changes in the
// Timeline tab's one `record:activity`). The ruling removes the host's
// append, not the default page's discussion.
renderDetail({ objects: OBJECTS_FEEDS_ON, pages: [], comments: ONE_COMMENT, search: '?tab=timeline' });
await settled();

await waitFor(() => expect(count(COMMENT_MARKER)).toBeGreaterThan(0));
expect(count(PANEL_HEADING)).toBeGreaterThan(0);
expect(composerCount()).toBeGreaterThan(0);
});
});

Expand Down
Loading
Loading