Skip to content

Commit ea150b7

Browse files
feat(ui)!: a record opened from a list opens in the drawer by default, and the split pane retires (#29108)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbRZcEuiZXb12Z9xsrdSrjyJ)_ Fixes #28915 Clause-②: yes (narrowing) Metadata-protocol: none Skills-docs: follow-up owed — skills/objectstack-ui/rules/record-page.md, skills/objectstack-ui/evals/record-page.json ## 维护者速读 - **改了什么**:从列表打开记录,默认改为右侧抽屉,列表保持全宽、所有列都在;分屏模式整体退役。Display 菜单「打开记录」只剩「抽屉」和「整页」两个选项。 - **为什么改**:维护者裁定「默认改抽屉,分屏彻底退役,只留抽屉和整页」。分屏把列表挤成三列或卡片,读起来比抽屉差。 - **风险与回滚**:读者此前存的 `split` 偏好会按默认(抽屉)读出,不做兼容读;`@object-ui/components` 移除了分屏相关的 7 个导出(BREAKING,changeset 写明迁移)。回滚即 revert 本 PR,无数据或元数据迁移。 - **先看**:`ui/packages/components/src/custom/navigation-overlay.tsx`(分屏外观与 Esc 处理的删除)、`ui/packages/app-shell/src/views/ObjectView.tsx`(去掉 drawer→split 的映射)、`ui/packages/app-shell/src/utils/recordOpenPreference.ts`(偏好只认 drawer / page)。 - **两分钟验证**:`pnpm dev:crm` 打开 Accounts 列表,点一行:记录在右侧抽屉打开,列表不变窄;打开右上 Display 菜单,「打开记录」只有 Drawer 和 Full page。 - **需要你做的**:无,席位落地。 ## What changes A record opened from a list now opens in the non-modal drawer over the list by default, and the list keeps its full width and every view column. The split pane beside the list retires: a record opens only as the drawer or as the full page. - `recordOpenPreference.ts`: `RecordOpenPreference` is `'drawer' | 'page'`, default `drawer`. A stored `split` is an unknown value and reads as the default, with no compatibility read. - `ObjectView.tsx`: the drawer-to-split mapping is gone; the overlay receives the resolved mode as is. The record-count bar beside the pane (`RecordPaneListCount`) goes with it. - `navigation-overlay.tsx`: `NavigationOverlayMode` is `page | drawer | new_window | none`. Removed: the split face, `RECORD_SPLIT_MIN_CONTENT_PX`, the width probe, `useSplitEscape`, `paneBeside` and the narrowed list face. The list under the drawer renders full width inside the active-row provider and goes inert while the drawer is maximized. Kept as they were: the non-modal drawer, a row click swapping the record, the stepper, drag-to-resize, maximize in place, and the modal sheet for a drawer with no list. - `record-list-face.tsx` and `custom/index.ts`: the narrowed-face API leaves (`RecordListFaceProvider`, `useRecordListFace`, `recordListFaceFor`, `RECORD_LIST_CARD_FACE_BELOW_PX`, `RECORD_LIST_NARROW_COLUMN_COUNT`, `RecordListFace`); the active-row store stays. - `ObjectGrid.tsx`: no three-column or compact-card narrowing beside a pane; the grid draws every view column. - `ListView.tsx`: the record-count bar always prints its count; the `list.recordOpenSplit` default is gone. - `ViewSettingsPopover.tsx`: the Open records control has two segments, Drawer and Full page; `selectPage` is gone from the host prop. - i18n: `list.recordOpenSplit` leaves all ten packs. - Docs: `content/docs/frontend/plugins/plugin-view.mdx` (the record drawer section) and `content/docs/ui/views.mdx` (the Open records paragraph). - Changeset `.changeset/28915-record-open-drawer-default.md`: BREAKING, FROM-TO for each of the seven retired exports, ADR-0087 disposition `not-required (runtime-export-retired ...)`. ## Why Maintainer ruling on #28915: 「默认应该是普通列表,右侧弹出。drawer」 and 「默认改抽屉,分屏彻底退役,只留抽屉和整页,立卡。」 This supersedes #27545's Target item 1 and the split half of its item 2. ## Tests The #27545 split tests are converted to the drawer, not deleted: - `navigation-overlay-split-27545` becomes `navigation-overlay-drawer-27545` (drawer over the list, list full width, ✕ closes, retired exports absent from the module). - `ObjectView.recordPaneV4-27545` becomes `ObjectView.recordDrawer-27545` (all five columns kept, stored `split` reads as drawer, list inert while maximized). - `ListView.recordPaneCount-27545` becomes `ListView.recordDrawerCount-27545`; `ObjectGrid.recordPaneFace-27545` becomes `ObjectGrid.recordDrawerList-27545`. - Updated to the drawer: `navigation-overlay-field-escape-28156`, `navigation-overlay-close-i18n`, `chrome-i18n-no-provider-fallback`, `record-top-bar-27524`, `record-list-face-27545`, `RecordPageColumns.enlargedEscapeInPane-27524`, `recordOpenPreference.test.ts`, `ObjectView.recordOpenPreference-26677`, `ObjectView.recordOpenDisplay-26681`, `ObjectView.recordCountFooter-10636`, `objectListTitle-27950`, `titleViewSwitcher-27594`, `viewRefusal-12112`, `phoneRecordPage-27058`, `RecordDetailView.topBar-27524`, `ViewSettingsPopover.recordOpen-26681`. Ablation: decoding a stored `split` as `page` instead of the default turns `recordOpenPreference.test.ts` red (1 failed); restored, tree byte-identical to HEAD. ## Acceptance notes **Screenshots** (examples/app-crm, Accounts, sidebar open; at 1000 px the sidebar is pinned open with Keep sidebar open, since the console folds it to a rail below 1416 px): | | before | after | |---|---|---| | 1440, record open | split pane, list narrowed to 3 columns | drawer over the full-width list | | 1000, record open | drawer (split fell back for want of width) | drawer over the full-width list | | Display menu, Open records | Split / Drawer / Full page | Drawer / Full page | The 12 files are in the project files under `issue-28915/shots/` (`before-` and `after-`, `1440-` and `1000-`, `list`, `display-menu`, `record-open`). **Premise reading, `ui/packages/app-shell/src/layout/consoleSidebarRail.ts` (read-only here).** `CONSOLE_SIDEBAR_RAIL_BELOW_WIDTH` is 1416 px, derived only from split content: `RECORD_SPLIT_MIN_CONTENT_WIDTH = 440 + 720` plus the 256 px expanded sidebar. With the split pane retired that derivation has no remaining reason. The fold itself still has one: #27593 quotes the maintainer 「窄屏幕左侧菜单自动折叠,点击弹出」 with an original target near 1100 px. So the threshold, not the fold, is left without a reason. Out-of-scope finding, carrier: seat. **Other out-of-scope findings (carrier: seat):** - Stale split-pane comments outside the declared surface: `record-top-bar.tsx:11`, `data-table.tsx:3735`, plugin-detail `RecordPageColumns.tsx:135/237`, `useRecordPageTier.ts:31`, `synth/recordPageColumns.ts:16/93/110`, react `useNavigationOverlay.ts:278`, plugin-view `ObjectView.tsx:590/1193`, `RecordDetailView.tsx:943/1003`, `objectViewOpenAsPage.tsx:15`, `appObjectScope.ts:45`, `ObjectGrid.tsx:3202`. - The `console.objectView.recordCount` key family is now read by no source file, and the overlay no longer reads `common.closePanel`. - Skills follow-up: `skills/objectstack-ui/rules/record-page.md` (line 48) and `skills/objectstack-ui/evals/record-page.json` (line 24) still say split is the default; `skills/**` is outside this card. **Deviations:** `content/docs/ui/views.mdx` (one paragraph) is outside the declared file surface, a bounded in-place fix of the same claim; the plugin-detail and `RecordDetailView.topBar` test files are touched because they drove the split mode. Four test files are renamed. The dogfood battery ran as four `vitest --shard` legs because one foreground call is capped at 600 s; the legs cover all 387 files. ## Verification Head `2255b429`. Main was merged twice, at `00d0668d` and at `5d3667e2`, both clean; neither carried a parallel change to the touched files (`ListView.tsx` merged with #29044's reset-to-default change automatically). <details><summary>Gates</summary> `node scripts/pm/dispatch-gates.mjs --commands`: 67 derived, 67 run, all exit 0, reconciled with `--ran` (0 NOT-MEASURED). `check:skill-examples` first exited 3 (needs `@objectstack/client-react` built) and passed after the full build. `check:type-check-debt` ran under the verify lock. `node scripts/check-sdui-manifest.mjs` exit 0. Gates ran at `407e38cf`, before the main merge; the diff they derive from is unchanged by it. </details> <details><summary>Type-check and tests</summary> - `turbo run type-check` for components, i18n, plugin-grid, plugin-list, plugin-detail, plugin-view, app-shell and console: 78 of 78 tasks, exit 0 (at `407e38cf`). - ui vitest at `407e38cf`, `--maxWorkers=2`, locked: plugin-list whole suite 179 files 1725 tests; plugin-grid ObjectGrid set plus components/detail/view set 207 files 1692 tests; app-shell ObjectView set 141 files 1314 tests. All green, 527 files. - At `4b3e19eb`: the 54 test files the branch touches against the base, 377 tests, green. - Dogfood battery at `4b3e19eb`, locked, four shards: 384 files passed, 3 skipped (387); 3166 tests passed, 28 skipped. - At `2255b429` (after the second merge): the touched tests plus every plugin-list test naming the Display menu or the record count, and main's new `ListView.restoreViewDefaults-28948` and data-table row-action tests: 29 files, 228 tests, green; `turbo run type-check` plugin-list and components 51 of 51. - Full `pnpm build`: 117 of 117 tasks. - `ui/` eslint (the Lint UI config) over the 41 touched ts/tsx files: 0 errors. </details> ## Human Minimum - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; `pnpm lint` passes. - [x] 4. One issue, one branch off an up-to-date `main`, one pull request. - [x] 5. A change that publishes carries a changeset, and both declaration lines are filled in here and in the changeset. - [x] 6. `packages/spec` touched: its generated artifacts are regenerated and committed. (not touched) - [x] 7. No hand edits to `content/docs/references/`, `packages/*/CHANGELOG.md` or `content/docs/releases/`. - [x] 8. Title, body and comments are in English. - [x] The *Before Submitting* list in CONTRIBUTING.md is done. 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jRrZ9h4h6RnBzGnhgi --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jRrZ9h4h6RnBzGnhgi)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 3cf7f61 commit ea150b7

46 files changed

Lines changed: 854 additions & 1173 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
---
2+
'@object-ui/components': patch
3+
'@object-ui/plugin-grid': patch
4+
'@object-ui/plugin-list': patch
5+
'@object-ui/i18n': patch
6+
'@object-ui/app-shell': patch
7+
---
8+
9+
feat(ui)!: a record opened from a list opens in the drawer by default, and the split pane beside the list retires
10+
11+
Clause-②: yes (narrowing)
12+
Metadata-protocol: none
13+
14+
<!-- adr-0087: not-required (runtime-export-retired @object-ui/components#RECORD_SPLIT_MIN_CONTENT_PX, @object-ui/components#RecordListFaceProvider, @object-ui/components#useRecordListFace, @object-ui/components#recordListFaceFor, @object-ui/components#RECORD_LIST_CARD_FACE_BELOW_PX, @object-ui/components#RECORD_LIST_NARROW_COLUMN_COUNT, @object-ui/components#RecordListFace) the retired operands are a React context provider, a hook, a helper, two constants and a type of the split pane's narrowed list face, in a published ui package's entry; no packages/spec schema, no registered node type, no ADR-0087 conversion surface and no stored shape names them (the split mode already left `NavigationModeSchema`), so there is nothing for `objectstack migrate meta` to rewrite -->
15+
16+
**BREAKING** for a host that drew the split pane through `@object-ui/components`, read its narrowed list face, or passed the Display menu a `split` choice. The maintainer ruled 「默认改抽屉,分屏彻底退役,只留抽屉和整页」: a record opens in the drawer or as the full page, and the split pane is gone.
17+
18+
- **The Console.** A row click on a drawer-mode object opens the record in the non-modal drawer over the list, which keeps its full width and every view column. A click on another row swaps the record, ↑ ↓ step through the list, the drawer drags from 360 px to 60% of the window, and ⤢ maximizes it in place. A view that declares `navigation.mode: 'page'` and a phone still open the full page. No metadata changes: `navigation.mode` keeps its values.
19+
- **The reader's preference.** The Display menu's 「打开记录」 (Open records) offers Drawer and Full page only. The preference under `os:record-open-mode` is `drawer` or `page`; a stored `split` is an unknown value and reads as the default, the drawer, with no compatibility read.
20+
- **`@object-ui/components`.** `NavigationOverlay` draws the drawer for `mode: 'drawer'` and no longer has a `split` face: `NavigationOverlayMode` is `page | drawer | new_window | none`. Over a `mainContent` list the drawer is non-modal and undimmed, as before, and the list keeps its full width. The open-row store stays (`ActiveListRecordProvider`, `useIsActiveListRecord`, `ActiveListRow`, `useScrollActiveListRecordIntoView`, `listRecordIdOf`). The split pane's 「Close panel」 label is no longer drawn; the drawer's ✕ is 「Close」.
21+
- **`@object-ui/plugin-grid`.** The grid no longer narrows to three columns or to compact card rows beside a record pane; it draws every view column.
22+
- **`@object-ui/plugin-list`.** `ListView`'s record-count bar always prints its count. The `recordOpen` host prop offers two choices: its `preference` is `'drawer' | 'page'`, its `setPreference` takes either, and its `selectPage` member is gone (pass `setPreference('page')`).
23+
- **`@object-ui/i18n`.** `list.recordOpenSplit` leaves all ten packs.
24+
25+
What leaves, and what to write instead:
26+
27+
- FROM `RECORD_SPLIT_MIN_CONTENT_PX` TO nothing: the drawer has no width floor to fall back from.
28+
- FROM `RecordListFaceProvider` TO nothing: no list is narrowed any more, so there is no face to publish.
29+
- FROM `useRecordListFace` TO nothing: a list under the drawer draws its full face; read `useIsActiveListRecord` for the open row.
30+
- FROM `recordListFaceFor` TO nothing.
31+
- FROM `RECORD_LIST_CARD_FACE_BELOW_PX` TO nothing.
32+
- FROM `RECORD_LIST_NARROW_COLUMN_COUNT` TO nothing.
33+
- FROM `RecordListFace` TO nothing.
34+
35+
A host that passed `mode="split"` to `NavigationOverlay` passes `mode="drawer"` with the same `mainContent`. A host of the Display menu that passed `selectPage` drops it and lets `setPreference` receive `'page'`.

‎content/docs/frontend/plugins/plugin-view.mdx‎

Lines changed: 11 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -503,24 +503,22 @@ record as a full page in a new browser tab. Inert rows stay inert: `mode: 'none'
503503
`preventNavigation`, and `operations.read: false` without a `navigation` config
504504
ignore a modifier click too.
505505

506-
#### The Console's record pane
506+
#### The Console's record drawer
507507

508-
In the Console's object pages, a record that opens in the drawer opens
509-
**beside the list** by default. The pane is 720 px wide, and the reader can
510-
drag its divider from 360 px to 60% of the viewport. The list next to it stays
511-
readable and clickable:
508+
In the Console's object pages, a record that opens in the drawer opens in a
509+
drawer **over the list**, and the list keeps its full width. The drawer is
510+
720 px wide, and the reader can drag its edge from 360 px to 60% of the
511+
viewport. It draws no dimming overlay and is non-modal, so the list behind it
512+
stays readable and clickable:
512513

513-
- it shows its first three non-avatar columns, or one card row per record when
514-
it is narrower than 440 px;
515-
- the open record's row is highlighted and follows the pane's ↑ ↓;
514+
- the open record's row is highlighted and follows the drawer's ↑ ↓;
516515
- clicking another row swaps the record.
517516

518-
On a content area narrower than 1000 px, the pane becomes the drawer over the
519-
list. The drawer is non-modal too. Readers who prefer the drawer, or the full
520-
page, choose it in the list's Display menu (Open records). That choice is the
521-
reader's own, stored in the browser, never metadata.
517+
Readers who prefer the full page choose it in the list's Display menu (Open
518+
records: Drawer or Full page). That choice is the reader's own, stored in the
519+
browser, never metadata.
522520

523-
The pane's ⤢ maximizes it in place, up to the sidebar. The URL and history
521+
The drawer's ⤢ maximizes it in place, up to the sidebar. The URL and history
524522
stay as they are, and ⤡ or `Esc` restores it. The full page opens from a row's
525523
↗, or from 「Open as full page」, the last host item of the record header's
526524
`⋯`. 「Collapse to drawer」 on that page returns to the list only when the ↗

‎content/docs/ui/views.mdx‎

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -525,13 +525,13 @@ from. `modal`, `split` and `popover` were removed in @objectstack/spec 18.3.0 (A
525525
a stored value is refused with its fix, and `os migrate meta --from 17` drops it. On a phone
526526
every record opens as a page, whatever the view declares.
527527

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

537537
The record opened from a list is addressable: `?recordId=` on the list's URL, so a

‎ui/packages/app-shell/src/utils/recordOpenPreference.test.ts‎

Lines changed: 24 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,12 @@
99
/**
1010
* objectstack#26677 — the reader's record-open preference, read side.
1111
*
12-
* `split` left `NavigationModeSchema`: the side-by-side pane is a reader's own
13-
* choice, kept per browser under `os:record-open-mode`. These rows pin what
14-
* the console reads: the values it knows, and the default for everything
15-
* else — no value, an unknown one, no storage, a storage that throws.
16-
* objectstack#27545: the default is the split pane, and `page` (the Display
17-
* menu's 「Full page」) is a value it knows.
12+
* How a drawer-mode record opens is a reader's own choice, kept per browser
13+
* under `os:record-open-mode`. These rows pin what the console reads: the
14+
* values it knows, and the default for everything else — no value, an
15+
* unknown one, no storage, a storage that throws. objectstack#28915: the
16+
* drawer is the default, the split pane retired, and a stored `split` is an
17+
* unknown value like any other (no compatibility read).
1818
*/
1919

2020
import { describe, it, expect } from 'vitest';
@@ -30,32 +30,32 @@ const storageWith = (value: string | null) => ({ getItem: (key: string) => (key
3030
describe('readRecordOpenPreference (objectstack#26677)', () => {
3131
it('the key and the default are the ones the Display-menu control will write against', () => {
3232
expect(RECORD_OPEN_PREFERENCE_KEY).toBe('os:record-open-mode');
33-
expect(DEFAULT_RECORD_OPEN_PREFERENCE).toBe('split');
33+
expect(DEFAULT_RECORD_OPEN_PREFERENCE).toBe('drawer');
3434
});
3535

36-
it('reads the three values it knows', () => {
37-
expect(readRecordOpenPreference(storageWith('split'))).toBe('split');
36+
it('reads the two values it knows', () => {
3837
expect(readRecordOpenPreference(storageWith('drawer'))).toBe('drawer');
3938
expect(readRecordOpenPreference(storageWith('page'))).toBe('page');
4039
});
4140

42-
it('answers the split pane for no value, an unknown value, or a retired mode name', () => {
43-
expect(readRecordOpenPreference(storageWith(null))).toBe('split');
44-
expect(readRecordOpenPreference(storageWith('modal'))).toBe('split');
45-
expect(readRecordOpenPreference(storageWith('SPLIT'))).toBe('split');
41+
it('answers the drawer for no value, an unknown value, or a retired mode name — a stored `split` included', () => {
42+
expect(readRecordOpenPreference(storageWith(null))).toBe('drawer');
43+
expect(readRecordOpenPreference(storageWith('split'))).toBe('drawer');
44+
expect(readRecordOpenPreference(storageWith('modal'))).toBe('drawer');
45+
expect(readRecordOpenPreference(storageWith('PAGE'))).toBe('drawer');
4646
});
4747

48-
it('answers the split pane with no storage, and with a storage that throws', () => {
49-
expect(readRecordOpenPreference(null)).toBe('split');
48+
it('answers the drawer with no storage, and with a storage that throws', () => {
49+
expect(readRecordOpenPreference(null)).toBe('drawer');
5050
const throwing = { getItem: () => { throw new Error('SecurityError'); } };
51-
expect(readRecordOpenPreference(throwing)).toBe('split');
51+
expect(readRecordOpenPreference(throwing)).toBe('drawer');
5252
});
5353

54-
it('with no `window` at all (SSR — this file runs in the node project), the default storage is absent: the split pane', () => {
54+
it('with no `window` at all (SSR — this file runs in the node project), the default storage is absent: the drawer', () => {
5555
// The browser's own `localStorage` is read by the console's object page;
5656
// `ObjectView.recordOpenPreference-26677.test.tsx` drives that path.
5757
expect(typeof window).toBe('undefined');
58-
expect(readRecordOpenPreference()).toBe('split');
58+
expect(readRecordOpenPreference()).toBe('drawer');
5959
});
6060
});
6161

@@ -71,18 +71,17 @@ describe('writeRecordOpenPreference (objectstack#26681)', () => {
7171

7272
it('keeps the choice under the key the read side reads', () => {
7373
const storage = memoryStorage();
74-
writeRecordOpenPreference('split', storage);
75-
expect(storage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('split');
76-
expect(readRecordOpenPreference(storage)).toBe('split');
77-
writeRecordOpenPreference('drawer', storage);
78-
expect(readRecordOpenPreference(storage)).toBe('drawer');
7974
writeRecordOpenPreference('page', storage);
75+
expect(storage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('page');
8076
expect(readRecordOpenPreference(storage)).toBe('page');
77+
writeRecordOpenPreference('drawer', storage);
78+
expect(storage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe('drawer');
79+
expect(readRecordOpenPreference(storage)).toBe('drawer');
8180
});
8281

8382
it('writes nothing and throws nothing with no storage, or with a storage that refuses the write', () => {
84-
expect(() => writeRecordOpenPreference('split', null)).not.toThrow();
83+
expect(() => writeRecordOpenPreference('page', null)).not.toThrow();
8584
const refusing = { setItem: () => { throw new Error('QuotaExceededError'); } };
86-
expect(() => writeRecordOpenPreference('split', refusing)).not.toThrow();
85+
expect(() => writeRecordOpenPreference('page', refusing)).not.toThrow();
8786
});
8887
});

‎ui/packages/app-shell/src/utils/recordOpenPreference.ts‎

Lines changed: 19 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -19,17 +19,17 @@ import {
1919
* object opens its records in the drawer — a READER's own preference, not
2020
* metadata (objectstack#26677).
2121
*
22-
* The maintainer's record-page model (ruling 2026-10-27): an object opens its
23-
* records one way — the drawer, which expands to the full page, by default,
24-
* or the full page where the object declares `navigation.mode: 'page'` — and
25-
* the side-by-side pane is each reader's own choice. So `split` left
26-
* `NavigationModeSchema` (an author can no longer fix it for every reader) and
27-
* lives here instead: `split` draws the record page beside the list, `drawer`
28-
* draws it in the drawer over the list, `page` opens the full page.
22+
* The maintainer's record-page model: an object opens its records one way —
23+
* the drawer, which expands to the full page, by default, or the full page
24+
* where the object declares `navigation.mode: 'page'`. A reader of a drawer
25+
* object chooses between two: `drawer` draws the record in the drawer over
26+
* the list, which keeps its full width, and `page` opens the full page.
2927
*
30-
* Since objectstack#27545 (maintainer rulings of 2026-10-29) the pane beside
31-
* the list is the default, and `page` is the third choice, for a reader who
32-
* wants a row click to open the record page itself.
28+
* The drawer is the default (objectstack#28915, maintainer ruling
29+
* 2026-11-02: 「默认改抽屉,分屏彻底退役,只留抽屉和整页」). The pane beside
30+
* the list that objectstack#27545 made the default is retired: a stored
31+
* `split` is an unknown value like any other and reads as the default, with
32+
* no compatibility read.
3333
*
3434
* Both sides live here: {@link readRecordOpenPreference} and
3535
* {@link writeRecordOpenPreference}, and {@link useRecordOpenPreference}, the
@@ -48,17 +48,17 @@ import {
4848
* A thin alias over the console's one preference store,
4949
* `workspacePreference.ts` (objectstack#27052): the preference is
5050
* {@link RECORD_OPEN_PREFERENCE} there, in the `user` scope, and it keeps the
51-
* spelling it was first stored with — a bare `split` / `drawer` / `page` under
51+
* spelling it was first stored with — a bare `drawer` / `page` under
5252
* {@link RECORD_OPEN_PREFERENCE_KEY} — so a reader's earlier choice reads
5353
* back unchanged.
5454
*/
55-
export type RecordOpenPreference = 'split' | 'drawer' | 'page';
55+
export type RecordOpenPreference = 'drawer' | 'page';
5656

5757
/** The `localStorage` key the preference is kept under. */
5858
export const RECORD_OPEN_PREFERENCE_KEY = 'os:record-open-mode';
5959

60-
/** What a reader who never chose gets: the pane beside the list. */
61-
export const DEFAULT_RECORD_OPEN_PREFERENCE: RecordOpenPreference = 'split';
60+
/** What a reader who never chose gets: the drawer over the list. */
61+
export const DEFAULT_RECORD_OPEN_PREFERENCE: RecordOpenPreference = 'drawer';
6262

6363
/** The storage surface this module reads — `localStorage`, or a test double. */
6464
export type RecordOpenPreferenceStorage = Pick<Storage, 'getItem'>;
@@ -75,16 +75,16 @@ export const RECORD_OPEN_PREFERENCE = defineWorkspacePreference<RecordOpenPrefer
7575
name: 'record.open-mode',
7676
storageKey: () => RECORD_OPEN_PREFERENCE_KEY,
7777
codec: {
78-
decode: (stored) => (stored === 'split' || stored === 'drawer' || stored === 'page' ? stored : undefined),
78+
decode: (stored) => (stored === 'drawer' || stored === 'page' ? stored : undefined),
7979
encode: (value) => value,
8080
},
8181
});
8282

8383
/**
8484
* The reader's record-open preference, or {@link DEFAULT_RECORD_OPEN_PREFERENCE}
85-
* when none is stored, the stored value is not one of the three, or there is no
86-
* readable storage. `storage` defaults to the browser's `localStorage`; `null`
87-
* says there is none.
85+
* when none is stored, the stored value is not one of the two (a retired
86+
* `split` included), or there is no readable storage. `storage` defaults to
87+
* the browser's `localStorage`; `null` says there is none.
8888
*/
8989
export function readRecordOpenPreference(
9090
storage: RecordOpenPreferenceStorage | null = workspacePreferenceStorage(),
@@ -114,7 +114,7 @@ export function writeRecordOpenPreference(
114114
* The reader's record-open preference as live state, and the setter the
115115
* Display menu writes it with (objectstack#26681). A write here, in another
116116
* component of this page, or in another tab re-renders every caller, so the
117-
* object page decides its split face from the value the reader just chose.
117+
* object page opens the next record the way the reader just chose.
118118
*/
119119
export function useRecordOpenPreference(): [RecordOpenPreference, (next: RecordOpenPreference) => void] {
120120
return useWorkspacePreference(RECORD_OPEN_PREFERENCE, 'user', DEFAULT_RECORD_OPEN_PREFERENCE);

‎ui/packages/app-shell/src/views/ObjectView.objectListTitle-27950.test.tsx‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -215,8 +215,8 @@ describe('objectstack#27950 — the list title names the object ahead of the vie
215215
expect(crumb()).toHaveTextContent(/^Leads$/);
216216
});
217217

218-
it('a record open beside the list (the split pane) leaves one object label and one switcher', async () => {
219-
localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, 'split');
218+
it('a record open in the drawer over the list leaves one object label and one switcher', async () => {
219+
localStorage.removeItem(RECORD_OPEN_PREFERENCE_KEY);
220220
mount('/apps/crm/crm_lead/view/mine?recordId=lead_1');
221221
await listToolbar();
222222
expect(screen.getAllByTestId('object-list-crumb')).toHaveLength(1);

‎ui/packages/app-shell/src/views/ObjectView.phoneRecordPage-27058.test.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,10 @@
1515
* way;
1616
* - a `?recordId=` link (a shared pane URL) is replaced by the record page,
1717
* so Back does not return to a URL that reopens it;
18-
* - a reader's split or drawer preference draws nothing on a phone, and the
18+
* - a reader's drawer or page preference draws nothing on a phone, and the
1919
* Display menu has no Open records section to set one;
20-
* - control: at 1440 px the same `?recordId=` opens the pane beside the list.
20+
* - control: at 1440 px the same `?recordId=` opens the drawer over the list
21+
* (the split pane beside it retired, objectstack#28915).
2122
*
2223
* The record body is not what this file reads, so `RecordDetailView` is a
2324
* null sink.
@@ -215,7 +216,7 @@ describe('on a phone a record opens as a page, always (objectstack#27058)', () =
215216
expect(screen.queryByRole('dialog')).toBeNull();
216217
});
217218

218-
it.each([['split'], ['drawer']])('a reader who prefers %s: nothing is drawn beside the list, and the Display menu offers no Open records', async (preference) => {
219+
it.each([['drawer'], ['page']])('a reader who prefers %s: nothing is drawn beside the list, and the Display menu offers no Open records', async (preference) => {
219220
window.localStorage.setItem(RECORD_OPEN_PREFERENCE_KEY, preference);
220221
setWidth(PHONE_WIDTH);
221222
mountList();
@@ -232,10 +233,11 @@ describe('on a phone a record opens as a page, always (objectstack#27058)', () =
232233
expect(window.localStorage.getItem(RECORD_OPEN_PREFERENCE_KEY)).toBe(preference);
233234
});
234235

235-
it('control: at 1440 px the same ?recordId= opens the pane beside the list', async () => {
236+
it('control: at 1440 px the same ?recordId= opens the drawer over the list', async () => {
236237
setWidth(DESKTOP_WIDTH);
237238
mountList('?recordId=r2');
238-
await screen.findByTestId('record-split-pane', undefined, { timeout: 5000 });
239+
await screen.findByRole('dialog', undefined, { timeout: 5000 });
240+
expect(screen.getByTestId('record-list-host')).toBeTruthy();
239241
expect(location()).toBe(`${LIST_PATH}?recordId=r2`);
240242
expect(screen.queryByTestId('record-page-route')).toBeNull();
241243
});

0 commit comments

Comments
 (0)