Skip to content

Commit ee29ac3

Browse files
feat(app-shell,components): on a phone the record is always a page, with a compact header, a bottom action bar and related records pushed as pages (#28477)
domain:ui#5 · os-sales · session_01L6bhgT93xhnhe2yKWX7YPc Fixes #27058 Clause-②: yes (widening) — `@object-ui/components` gains `PageHeaderPhoneContext` (+ type `PageHeaderPhoneFace`) and an optional `PageHeaderActionsProps.face`; `MobileViewSwitcherContext` is not on the `@object-ui/app-shell` entry, so its retirement narrows nothing. Metadata-protocol: none ## 维护者速读 - **改了什么**:手机上(窗口宽度小于 768)记录永远是整页:列表点一行直接进记录页,不再弹全屏抽屉或分屏;列表的视图切换器「全部线索 ▾ · 5」挪到列表工具栏首位,顶部应用栏不再画第二个视图下拉;记录页顶栏只剩一个返回和计数 ↓ ↑,顶层页写「‹ 对象列表」,推入的下一层写父记录名(「‹ Acme Corp」);头部用对象声明的前两个文本高亮做副标题,后面最多 4 个高亮;业务操作放进页面底部的操作栏(主操作、一个次操作、⋯);关联行和查找值整页推入,顶栏下有路径行「线索 Marie Curie › 客户」,‹ 返回上一层并回到原来的滚动位置。 - **为什么改**:#27058(画布 60 的 RecordMobile 板):手机是 v4 记录页压到 390 px,再加三件只有手机才有的事:记录永远是整页、业务操作在底栏、关联记录整页推入带路径行。 - **风险与回滚**:只动 ui 包(`app-shell`、`components`),不碰 spec、不碰服务端;768 px 及以上一切照旧(每个新测试都带桌面对照)。`components` 新增一个宿主插槽和一个可选 prop,是 widening;退役的 `MobileViewSwitcherContext` 从未在包入口导出。回滚即 revert。 - **先看**:`ui/packages/components/src/renderers/layout/containers.tsx` 的 `barQueue`(底栏按钮怎么排、编辑何时进 ⋯);`ui/packages/app-shell/src/views/RecordDetailView.tsx` 的 `phoneBack` / `phonePathRow` 和 `data-record-action-bar` 容器;`ui/packages/app-shell/src/views/objectViewRecordNavigation.ts` 的 `phone` 分支(`?recordId=` 换成整页)。 - **两分钟验证**:`pnpm --dir ui exec vitest run packages/components/src/__tests__/page-header-phone-bar-27058.test.tsx packages/app-shell/src/views/ObjectView.phoneRecordPage-27058.test.tsx packages/app-shell/src/views/RecordDetailView.phoneFace-27058.test.tsx packages/app-shell/src/views/__tests__/recordPagePush-27058.test.tsx packages/app-shell/src/layout/__tests__/AppHeader.phoneNoViewDropdown-27058.test.tsx`,预期 5 个文件 31 个测试全绿。 - **需要你做的**:无,席位落地。 ## What changes Below 768 px (the `useIsMobile` breakpoint, read synchronously by the new `hooks/usePhoneViewport.ts`, because what it decides is mounted, not styled) the console is on a phone. What follows is the v4 record page (#27524) in a container under 760 px plus the three things only a phone adds. No metadata key, no reader preference, no `packages/spec` file. ### Target 1: the record is always a page - `objectViewRecordNavigation.ts` answers the spec's own rule on a phone: `deriveRecordSurface(objectDef, { viewport: 'mobile' })` → `page`. A row tap navigates to the record route with the list as its `from` (and the shown rows as its ↓ ↑ set, the #27524 hand-off). - `ObjectView.tsx` mounts no `ObjectViewRecordOverlay` on a phone (no split pane, no drawer, no maximize), and hands the list no `recordOpen`, so the Display menu has no Open records section and a stored split / drawer preference draws nothing. - A `?recordId=` URL (a shared pane link) is replaced, not pushed, by the record page. - `console/AppContent.tsx`: a record created from the list opens with the same surface rule (`viewport: 'mobile'` on a phone). ### Target 2: one view switcher - Below `sm` (the header's own breakpoint, `useViewTabsFit`) the page header is not drawn, so the #27594 title switcher is handed to the list as `phoneTitle`: it leads the list's toolbar row (`ListView`'s existing `toolbarLeading` slot) beside the filter / sort / display icons, or heads a chart face. The header then gets no switcher, so there is exactly one. - The list's own view-type switcher (`showViewSwitcher`, the Display menu's Layout row) is off there. - `AppHeader` draws the page's name and no view dropdown; `MobileViewSwitcherContext.tsx` is deleted, with its provider in `ConsoleLayout.tsx` and its registration in `objectViewViewCrud.tsx`. ### Target 3: the top bar `RecordDetailView`'s page bar on a phone is `RecordTopBar` with one way back on the left and the counter + ↓ ↑ on the right when the page knows its rows. No ⊟ (the stack has no right column) and no ⤡ (no drawer to collapse to). On a top-level page the way back reads 「‹ OBJECT_PLURAL」 and returns to the list the page came from, else the object's list. On a page one level down it is named by where it returns (Target 8, see Acceptance notes): the parent record's title from the `?from=` trail (「‹ Acme Corp」), or its short id when the trail carries none; it links to the trail's parent, or goes back in the history on a page pushed in this session. ### Target 4: the header For a default (synthesized) page, `views/recordPhoneHeader.ts` takes the subtitle from the first two declared `highlightFields` whose type is `text`, joined by 「 · 」 from the values the reader can see, and keeps at most four highlights after them (`PHONE_HIGHLIGHTS_MAX`). The subtitle reaches `page:header` through the host slot below and is drawn only when the header declares no `subtitle` of its own. An object that declares no highlights gets the derived strip and no subtitle; an authored page draws its own header. ### Target 7: the bottom action bar - `@object-ui/components` gains `PageHeaderPhoneContext` (`renderers/layout/page-header-phone-context.ts`, beside `PageHeaderTrailingContext`): a host slot, never metadata. The record page provides it on a phone with its bar element and the subtitle. - Under it `PageHeaderRenderer` draws no action row: the same `PageHeaderActions` resolver renders in its new `face: 'bar'` and is portaled (`createPortal`) into the host's bar, so the actions keep the record's contexts. Nothing is drawn until the bar element exists. - The bar face holds the primary action, one more and the `⋯` (`PHONE_ACTION_BAR_BUTTONS = 2`; a page's declared `mobileMaxVisible` still wins). Each button shares the bar's width; the host's Delete stays in the `⋯`'s destructive band. - The bar is the page column's last flex row (`border-t`, `pb-[max(env(safe-area-inset-bottom),0.75rem)]`), under the scroller, so the body ends where the bar starts. There is no FAB on a record page (the FAB is the list's). ### Target 8: one level down - `RelatedRecordActionsBridge.openRecord` on a phone navigates to the record route with the `?from=` trail extended (as before) and the history state `recordPagePushState` builds (`views/recordPagePush.ts`): the parent page as `from`, the related list's rows as `from.siblings`, and a `recordPagePushed` marker. No drawer stack is mounted on a phone (`RecordDetailView` returns its page tree without `RecordPageDrawerHost`). - A path row under the top bar draws the trail (「线索 Marie Curie › 客户 Acme Corp › 商机」), each ancestor a link; on a pushed page the parent entry and 「‹ Acme Corp」 go back in the history (`navigate(-1)`). - `useRecordPageScrollMemory` remembers the page scroller's position per history entry and restores it when the reader comes back by Back / Forward, retrying while the panels load, for up to 3 s, until the reader touches the scroller. ### Unchanged 768 px and up; the pane's split / overlay / maximize mechanics (#27545); `navigation-overlay.tsx` and `recordDrawerStack.tsx` (not mounted on a phone, not edited); the record page's layout rule (the stack below 760 px, the tab row's own width count, the enlarged tab's card rows: all already read the page's own width and needed no change); `plugin-list` (its `toolbarLeading` and `recordOpen` props already existed). ### File surface beyond the claim amendment 6548379901 - The phone context lives at `ui/packages/components/src/renderers/layout/page-header-phone-context.ts`, beside `page-header-trailing-context.ts`, not under `custom/` as the amendment spelled it. - `ui/packages/app-shell/src/views/viewTabLabel.ts` and one comment in `ObjectView.tsx`: comment-only lines that named the retired mobile switcher. - Tests in `ui/packages/plugin-detail` and `ui/packages/plugin-grid` (pins only, see Tests). - Not needed and not touched: `objectViewRecordLinks.ts`, `objectViewPageHeader.tsx`, `RecordPageColumns.tsx`, `synth/recordPageColumns.ts`, `RecordTabRow.tsx`, `RelatedList.tsx`, `record-top-bar.tsx`, the i18n packs (no key added; the bar reuses `detail.goBack`, `common.breadcrumb` and the header's own keys). ## Tests New (31): - `components/src/__tests__/page-header-phone-bar-27058.test.tsx` (6): one declared action → [Convert Lead][Edit][⋯] in the host's bar and no row in the header; a declared second action takes slot 2 and Edit folds into the `⋯` after the page's folded action, before Delete; a declared `mobileMaxVisible: 1` sets the budget; nothing is drawn before the bar element exists; the host subtitle stands in for an undeclared one and an authored one wins; control: without the context the row face, Edit inline. - `app-shell/src/views/ObjectView.phoneRecordPage-27058.test.tsx` (5): at 390 a row tap lands on the record route with `from.pathname` the list and `from.siblings` its rows; a `?recordId=` URL is REPLACED by the record route; a stored `split` / `drawer` preference draws nothing and the Display menu has no Open records; control at 1440: the same `?recordId=` opens the split pane. - `app-shell/src/views/RecordDetailView.phoneFace-27058.test.tsx` (10), a real `RecordDetailView` at 390: 「‹ Leads」 to the list, `1 / 3`, no Panels group, no ⤡, no crumb; opened directly: 「‹ Leads」 and no counter; subtitle 「Atlas Construction · Director of Sales」 and four highlights; the bottom bar [Convert Lead][Assign][⋯] with Edit in the `⋯`, below the body, the header with no toolbar; one declared action → [Convert Lead][Edit]…; a pushed page: path row 「Lead Mira CostaTask」, 「‹ Mira Costa」 a button, `1 / 3`, back to the parent, whose own way back reads 「‹ Leads」 again (both texts pinned in one case); the path row's parent goes back too; a `?from=` page from a link: 「‹ Mira Costa」 and the path row both link to the parent; a trail entry with no title: 「‹ #L-1」; control at 1440: ⊟, the header's own row, no bar. - `app-shell/src/views/__tests__/recordPagePush-27058.test.tsx` (9): the bridge at 390 pushes a related row with the extended trail and `{ from: { pathname, label, siblings }, recordPagePushed: true }`, a lookup without siblings; control at 1440: no state; the state helpers; Back restores 286 and a fresh visit starts at 0; `phoneRecordHeader`'s rule (text only, two at most, trimmed, masked values skipped, array-form fields, no declared highlights → no subtitle). - `app-shell/src/layout/__tests__/AppHeader.phoneNoViewDropdown-27058.test.tsx` (1): at 390 on a two-view list, no 「Switch view」 trigger, the page's name only. Pins updated to the phone face, not deleted: - #27524: `RecordDetailView.recordPageTiers-26678` (the phone case now sets a 390 px window as well as a 390 px root, and asserts the bottom bar after the stack with no header toolbar; the 720 split-pane case asserts the header's row and no bar); `plugin-detail/src/__tests__/RecordTabRow-27524` (+1: the 366 px row of a 390 px page shows Overview, Activity and two related tabs, then 「+2」). - #27594: `ObjectView.titleViewSwitcher-27594` (+1: at 390 the one switcher leads the toolbar row with its count, opens the same dropdown, and switches the view). - #26896: `plugin-grid/src/__tests__/groupedMobileCards-26896` (+1: a tapped card hands its row to the host's `onRowClick`, the door the phone's page navigation takes). - The retired channel's tests: `ObjectView.objectListTitle-27950` lost its `MobileViewSwitcherContext` probe; its phone case now pins the one switcher in the toolbar row and none in the hidden header. `ObjectView.chartViewTabs-13114`'s phone case pins the one switcher heading the chart. <details><summary>Ablations (each through scripts/ablation-replace.mjs, restore proven: blob == HEAD and git diff HEAD empty)</summary> At head `1511473e38`: - `PHONE_BELOW_WIDTH = 768` → `0` (the phone signal off): 15 failed | 22 passed over the four phone files + the tiers pin + the 27594 pin. Every phone case red; every control and every unit case green. The 27594 pin stays green there by design: the toolbar title follows the header breakpoint, ablated next. - Four held together, one run: the bar queue with the host's buttons before the page's own (`containers.tsx`), the subtitle reading no field (`recordPhoneHeader.ts`), `phoneTitle` never set (`ObjectView.tsx`), the scroll restore never armed (`recordPagePush.ts`): 10 failed | 35 passed. Red: the Edit-folds case (components) and the bottom-bar case (record page); the three subtitle unit cases and the header case; the three phone switcher pins (27594, 27950, 13114); the Back-restores case. At head `f33dd4f88c`: - The way back one level down named by the object plural again (`RecordDetailView.tsx` `phoneBack`: `? parent.t || shortRecordId(parent.i)` → `? plural`): 3 failed | 7 passed in `RecordDetailView.phoneFace-27058`. Red: the pushed page (「‹ Mira Costa」), the `?from=` link page, the untitled trail entry (「‹ #L-1」); the two top-level 「‹ Leads」 cases stay green. </details> ## Acceptance notes **Screenshots** at 390 × 844 (Chromium mobile emulation, zh-CN) on `examples/app-crm`, run from this branch's own source and server. They are committed in `08421f1c82` and removed in the next commit, so they never reach `main` (an earlier set, `74dd2d29a6`, predates the way-back label change and is superseded). The folder's [README](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/README.md) says how they were made; [`run.log`](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/run.log) records each shot's route, the bar's box, the top bar's way back as drawn and the scroll readings; [`evidence.cjs`](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/evidence.cjs) is the script. | Shot | What it shows | |:--|:--| | [01-lead-list](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/01-lead-list.png?raw=true) | The lead list: 「全部线索 ▾ · 5」 leads the toolbar row with the filter and display icons; card rows; the app bar draws no view dropdown. | | [02-lead-record](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/02-lead-record.png?raw=true) | A row tapped, a top-level page: 「‹ 线索列表」, `5 / 5`, ↓ ↑; name and object chip; four highlights in two columns; the stage path; the tab row; the details; the bar [Convert Lead][编辑][⋯]. | | [03-lead-record-mid-scroll](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/03-lead-record-mid-scroll.png?raw=true) | Scrolled to the middle of the body (scrollTop 106 of 212): the bar stays at the bottom edge (`y 779 + h 65 = 844`). | | [04-lead-record-end](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/04-lead-record-end.png?raw=true) | The end of the stack: the details, then the 动态 panel; the bar under them. | | [05-lead-to-account-pushed](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/05-lead-to-account-pushed.png?raw=true) | The lead's Account lookup tapped: Acme Corp pushed as a page, one level down: 「‹ Marie Curie」 and the path row 「线索 Marie Curie › 客户」. | | [06-account-related-panels](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/06-account-related-panels.png?raw=true) | That page scrolled to its related panels, in their order, with the bar under them. | | [07-account-to-opportunity-pushed](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/07-account-to-opportunity-pushed.png?raw=true) | A related opportunity row tapped: one more level, 「‹ Acme Corp」, 「线索 Marie Curie › 客户 Acme Corp › 商机」, `1 / 3` over the account's shown opportunity rows; the tab row 「概览 · 动态 0 · 活动列表 0 · 线索列表 0 · +1 ▾」. | | [08-back-to-account](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/08-back-to-account.png?raw=true) | 「‹ Acme Corp」: the account where it was left, scrollTop 315 → 315, its own 「‹ Marie Curie」. | | [09-back-to-lead](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/09-back-to-lead.png?raw=true) | 「‹ Marie Curie」: the lead where it was left, 212 → 212, the top-level 「‹ 线索列表」 again. | | [10-account-record](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/10-account-record.png?raw=true) | An account from its list: 「‹ 客户列表」 `2 / 3`; the tab row 「概览 · 动态 0 · 活动列表 2 · 联系人列表 1 · +2 ▾」 at 390 px; the bar [编辑][⋯] (the account declares no action). | | [11-account-related-tab-active](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/11-account-related-tab-active.png?raw=true) | 活动列表 active (`?related=crm_activity.account`): the child object's card rows fill the body under the enlarged toolbar. | | [12-lead-bar-more](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/12-lead-bar-more.png?raw=true) | The bar's ⋯: 刷新, 添加到收藏, 分享, then 删除 in its own band. | | [13-lead-header-declared-highlights](https://github.com/objectstack-ai/objectstack/blob/08421f1c820f2582c1e6c1524d23dfa557df177e/evidence/27058-phone-record-page/13-lead-header-declared-highlights.png?raw=true) | **Rig only.** app-crm declares no `highlightFields`, so this shot patches the served lead metadata in the browser (`page.route`; nothing in the diff) with `company, title, email, phone, source, lead_score`: the subtitle 「Radium Labs · Director of Research」, then four highlights. | **The two points the seat asked to see stated:** - **The app bar (`AppHeader`) is kept on the phone record page.** The board's frame starts at the record's top bar, but on a phone `AppHeader` is the only door to the app menu (the sidebar trigger), search, notifications and the account menu, on every route, the record page included. The card names no other place for them, so retiring the bar on record routes would leave a reader on a record page with no way to another app or to search. What this PR removes from the bar is only the second view dropdown (Target 2). If the preview gate wants the record page without it, that is a separate decision about where those four doors go on a phone. - **Edit and the bar's second slot.** In the bar face the host's Edit is one of the record's actions: it queues after the page's own actions (`barQueue`: the primary, then the authored actions in their order, then the host's non-destructive buttons). So a page that declares ONE action shows [that action][编辑][⋯] (shot 02, Convert Lead); a page that declares a second action shows [primary][second][⋯] and 编辑 is the first host entry in the `⋯`, after the page's own folded actions (pinned in `page-header-phone-bar-27058` and `RecordDetailView.phoneFace-27058`); a page that declares none shows [编辑][⋯] (shot 10). On the desktop row nothing changes: the host's buttons stay inline beside the authored ones. **Readings and deviations** (the first three agreed with the seat before the build): - **No avatar initials** in the phone header (hotcrm#1199, ruling A). An object with a declared picture field still shows its picture through the header's existing chip. - **The subtitle** comes only from declared text `highlightFields` on a default page; no declared list means no subtitle (app-crm declares none: shots 02 and 13). - **The bar is a flex row** at the foot of the page column, not `position: fixed` with a `scroll-pb`. It stays visible while the body scrolls (shot 03, bar box in `run.log` for every record shot), and the body ends where the bar starts, which is what the `scroll-pb` was for. - **The ‹ label: Target 3 governs the top level, Target 8 names the next level** (the seat's reading). Target 3's 「‹ OBJECT_PLURAL」 is the way back of a top-level page, which returns to the object's list. Target 8 says a page one level down returns to its parent record, so its 「‹」 is named by that return target: the parent's title from the `?from=` trail (「‹ Acme Corp」, as the board's pushed frame reads), else its short id. A pushed page and a `?from=` page opened from a link read the same; back on the parent, its own 「‹」 reads the object plural again. Both texts are pinned in one case of `RecordDetailView.phoneFace-27058`. - **The card's lead → task shots.** app-crm has no task object, so the push is shown as lead → account (a lookup) → opportunity (a related row), and the 任务 tab as the account's 活动列表 tab. - **640–767 px** (a large phone in landscape, a small tablet): the record is a page (below 768), and the page header with its title switcher is drawn (from `sm`, 640), as at that width before. - **Not built here:** the counter's group scope, as in #27524 ④ (`ListView` reports rows only for the ungrouped grid). ## Human Minimum Each item is one rule from [CONTRIBUTING.md › Human Minimum](https://github.com/objectstack-ai/objectstack/blob/main/CONTRIBUTING.md#human-minimum), which links the AGENTS.md text that states it. - [x] 1. Schemas start as Zod; `packages/spec` holds no runtime logic. (`packages/spec` is not touched.) - [x] 2. Every name I added uses the casing for its kind. - [x] 3. No formatter ran; eslint on the changed files: 0 errors. - [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 (the 维护者速读 block is the project's Chinese summary). - [x] The *Before Submitting* list in CONTRIBUTING.md is done. ## Verification All local readings are at head `15455616ba` unless named. Its tree is identical to `f33dd4f88c` (`git diff f33dd4f 1545561` is empty: the two commits after it add and remove only `evidence/`), so a reading taken at `f33dd4f88c` is a reading of this tree. Against the merge base `8e2d56dcce` the change is 31 paths, +2,042 / −348; the evidence folders are added and removed inside the range, so they are not among them. `git merge-tree` against `origin/main` (11 commits ahead of the base) is clean. <details><summary>Tests: 643 files, 4624 tests green over every touched surface; tsc 0; eslint 0 errors</summary> - The touched surfaces, one run under the verify lock: `pnpm --dir ui exec vitest run --maxWorkers=3 packages/components/src/__tests__/page-header packages/components/src/__tests__/snapshot-critical.test.tsx packages/app-shell/src/views/ObjectView. packages/app-shell/src/views/RecordDetailView. packages/app-shell/src/views/__tests__ packages/app-shell/src/layout packages/app-shell/src/console packages/plugin-detail/src/__tests__/RecordTabRow packages/plugin-grid/src/__tests__/groupedMobileCards` - `Test Files 643 passed (643)` · `Tests 4624 passed (4624)` · VERDICT command-exit 0 (at `1511473e38` the same set read 643 / 4623; the +1 is the untitled-trail case). - The five phone files plus the tiers pin, at `f33dd4f88c`: `Test Files 5 passed (5)` · `Tests 36 passed (36)`. - `npx tsc --noEmit -p tsconfig.json` in `ui/packages/app-shell` at `f33dd4f88c`: exit 0 (`ui/packages/components`, unchanged since `1511473e38`: exit 0 there). - eslint on every changed source and test file: 0 errors (warnings are the files' existing `no-explicit-any`). - The 34 derived gates include `pnpm --dir ui exec vitest run scripts/`: `Test Files 169 passed | 2 skipped (171)` · `Tests 4901 passed | 2 skipped (4903)`. </details> <details><summary>Derived gate families: 34 derived, 34 run, every exit 0, 0 NOT-MEASURED (at f33dd4f, this tree)</summary> - `node scripts/check-adr-0087-registration.mjs --base origin/main`: exit 0 - `node scripts/check-adr-0087-registration.mjs --self-test`: exit 0 - `node scripts/check-changeset-no-major.mjs --base origin/main`: exit 0 - `node scripts/check-closing-keyword-parity.mjs`: exit 0 - `node scripts/check-closing-keyword-parity.mjs --self-test`: exit 0 - `node scripts/check-comment-mask-corpus.mjs`: exit 0 - `node scripts/check-empty-changeset.mjs --base origin/main`: exit 0 - `node scripts/check-empty-changeset.mjs --self-test`: exit 0 - `node scripts/check-sdui-manifest.mjs`: exit 0 - `node scripts/check-sdui-manifest.mjs --self-test`: exit 0 - `node scripts/pm/release-rehearsal-clone.mjs --self-test`: exit 0 - `node scripts/release-pending-publish.mjs --self-test`: exit 0 - `pnpm --dir ui exec vitest run scripts/`: exit 0 - `pnpm --filter @objectstack/spec run check:duration-unit-keys`: exit 0 - `pnpm check:changeset-gate-self-tests`: exit 0 - `pnpm check:closed-ceremony-text`: exit 0 - `pnpm check:cross-package-test-inputs`: exit 0 - `pnpm check:dispatcher-error-vocabulary`: exit 0 - `pnpm check:driver-memory-census`: exit 0 - `pnpm check:engine-double-contract`: exit 0 - `pnpm check:gitlink-declared`: exit 0 - `pnpm check:nul-bytes`: exit 0 - `pnpm check:pm-changeset-deadline-census`: exit 0 - `pnpm check:published-files`: exit 0 - `pnpm check:query-options-erasure`: exit 0 - `pnpm check:refd-timer-probe`: exit 0 - `pnpm check:skill-identifier-liveness`: exit 0 - `pnpm check:test-source-alias`: exit 0 - `pnpm check:tier-file-adoption`: exit 0 - `pnpm check:type-check-coverage`: exit 0 - `pnpm check:type-check-debt`: exit 0 - `pnpm check:undeclared-dep-imports`: exit 0 - `pnpm check:watch-hint-literal`: exit 0 - `pnpm check:where-matcher`: exit 0 `node scripts/pm/dispatch-gates.mjs --ran` over that record: 34 derived famil(ies) accounted for, 34 run, 0 NOT-MEASURED, 0 UNRUN. </details> CI check-runs: not started at the time of writing. --- _Generated by [Claude Code](https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 82b29fe commit ee29ac3

32 files changed

Lines changed: 2069 additions & 353 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/components': patch
4+
---
5+
6+
feat(console,components): on a phone the record is always a page, with a compact header, a bottom action bar, and related records pushed as pages with a path row
7+
8+
Clause-②: yes (widening: `@object-ui/components` gains the export `PageHeaderPhoneContext`, its type `PageHeaderPhoneFace`, and the optional prop `PageHeaderActionsProps.face` (`'row'`, the default, or `'bar'`); no export is removed, and `MobileViewSwitcherContext` was never on a package entry)
9+
Metadata-protocol: none
10+
11+
Below 768 px (the `useIsMobile` breakpoint) the console is on a phone. Nothing here reads metadata the console did not read before, and no reader preference is added.
12+
13+
- **The record is a page.** The object list passes `viewport: 'mobile'` to `deriveRecordSurface`, so a row tap navigates to the record route, handed the list as its way back and its rows as its ↓ ↑ set. The split pane and the drawer are never mounted; a `?recordId=` link is replaced by the record page; the Display menu has no Open records section, and a stored split or drawer preference draws nothing. A record created from the list opens as a page too.
14+
- **One view switcher.** The list's title switcher 「view ▾ · N」 leads the list's toolbar row below `sm` (or heads a chart), beside the filter and display controls, and the Display menu drops its Layout row there. The app bar no longer draws a second view dropdown: the `MobileViewSwitcherContext` channel is retired.
15+
- **The top bar** is one way back. On a top-level page it reads 「‹ object plural」 and returns to the list the page came from (or the object's list); one level down it is named by where it returns, the parent record (「‹ Mira Costa」). The counter and ↓ ↑ show when the page knows its rows. No ⊟ and no ⤡: the stack has no right column and there is no drawer to collapse to.
16+
- **The header** of a default page takes its subtitle from the first two declared text `highlightFields` (「Atlas Construction · Director of Sales」) and shows at most four highlights after them. An object that declares no highlights draws no subtitle; an authored page draws its own header.
17+
- **The bottom action bar.** The header draws no action row: its actions, resolved by the same `PageHeaderActions`, are drawn into a bar at the foot of the page above the safe area, in their bar face: the primary action, one more and the `⋯`. The host's Edit queues after the page's own actions, so it takes the second slot when the page declares one action and folds into the `⋯` when a declared second action fills the bar. A page's `mobileMaxVisible` still sets the budget. The bar is the page column's last row, so it stays in view while the body scrolls above it.
18+
- **One level down.** A related row or a lookup pushes its record as a page (the `?from=` trail extended, no drawer stack), with a path row 「Lead Mira Costa › Task」 under the top bar; 「‹ Mira Costa」 and the path row's parent go back in the history to the parent, which comes back where it was scrolled. The pushed page's ↓ ↑ step through the parent's related rows.
19+
- **Unchanged.** Every width from 768 px up, the record pane and drawer mechanics, and the record page's layout rule (the stack below 760 px and the tab row read the page's own width, as before).

‎ui/packages/app-shell/src/console/AppContent.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ import { useLauncherApps } from '../hooks/useLauncherApps.js';
3737
import { useSignedInUserLocale } from '../hooks/useUserLocale.js';
3838
import { resolveRecordFormTarget, resolveRecordFormModalLayout, resolveFormViewChrome, selectRecordFormView, resolveNavigateCreateUrl, resolveNavigateEditUrl, resolvePostCreateTarget, recordFormCreateValues, readRecordFormCreateValues, RECORD_FORM_CREATE_VALUES_STATE } from '../utils/recordFormNavigation.js';
3939
import { deriveRecordSurface, deriveRecordFlowSurface } from '@object-ui/plugin-view';
40+
import { isPhoneViewportNow } from '../hooks/usePhoneViewport.js';
4041
import { RECORD_DRAWER_PARAM, RECORD_FORM_PARAM, RECORD_FORM_OBJECT_PARAM, RECORD_FORM_LINK_PARAM } from '../urlParams.js';
4142
import { appAddressSegment, appRouteSegment, canonicalAppPathname, matchAppBySegment } from '../utils/appRoute.js';
4243
import { resolveHref, type NavTemplateContext } from '@object-ui/layout';
@@ -962,7 +963,9 @@ export function AppContent({ extraRoutes, extraRoutesNoApp }: AppContentProps =
962963
baseUrl: appSegment ? `/apps/${appSegment}` : '',
963964
pathname: location.pathname,
964965
search: window.location.search,
965-
surface: deriveRecordSurface(currentObjectDef),
966+
// On a phone the record is always a page (objectstack#27058): the
967+
// spec's own rule, asked with the viewport.
968+
surface: deriveRecordSurface(currentObjectDef, isPhoneViewportNow() ? { viewport: 'mobile' } : {}),
966969
recordId: saved?.id ?? saved?._id,
967970
});
968971
if (target.kind === 'none') return;
Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
import { useEffect, useState } from 'react';
10+
11+
/**
12+
* The phone breakpoint, `useIsMobile`'s 768 px in `@object-ui/components`.
13+
* Below it the console is on a phone (objectstack#27058): a record is always a
14+
* page, never a pane, and its business actions sit in a bottom bar.
15+
*/
16+
export const PHONE_BELOW_WIDTH = 768;
17+
18+
/** Is the window narrower than {@link PHONE_BELOW_WIDTH} right now? */
19+
export function isPhoneViewportNow(): boolean {
20+
return typeof window !== 'undefined' && window.innerWidth < PHONE_BELOW_WIDTH;
21+
}
22+
23+
/**
24+
* `useIsMobile()`, except that the first answer is read synchronously. What
25+
* this decides is mounted, not styled: on a phone the list page mounts no
26+
* record pane, and the record page mounts no drawer host and draws its
27+
* actions in the bottom bar. An answer that only arrived in an effect would
28+
* mount the desktop tree once and then swap it, with the list read twice.
29+
* The same idiom as `useViewTabsFit` in `objectViewChrome.tsx`.
30+
*/
31+
export function usePhoneViewport(): boolean {
32+
const [phone, setPhone] = useState<boolean>(isPhoneViewportNow);
33+
useEffect(() => {
34+
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
35+
const mql = window.matchMedia(`(max-width: ${PHONE_BELOW_WIDTH - 1}px)`);
36+
const onChange = () => setPhone(isPhoneViewportNow());
37+
mql.addEventListener('change', onChange);
38+
// A width that moved between the first render and this effect.
39+
onChange();
40+
return () => mql.removeEventListener('change', onChange);
41+
}, []);
42+
return phone;
43+
}

‎ui/packages/app-shell/src/layout/AppHeader.tsx‎

Lines changed: 6 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -55,9 +55,6 @@ import {
5555
import {
5656
Search,
5757
HelpCircle,
58-
ChevronDown,
59-
Check,
60-
Lock,
6158
LogOut,
6259
Plus,
6360
Layers,
@@ -93,7 +90,6 @@ import { useAuth, getUserInitials } from '@object-ui/auth';
9390
import { useMetadata } from '../providers/MetadataProvider.js';
9491
import { resolveKeyedI18nLabel, preferLocal, matchAppBySegment, appRouteSegment, appStudioRoutePath } from '../utils/index.js';
9592
import { getIcon } from '../utils/getIcon.js';
96-
import { useMobileViewSwitcher } from './MobileViewSwitcherContext.js';
9793
import { useNavigationContext } from '../context/NavigationContext.js';
9894
import { useCommandPalette, useCommandPaletteProviderMounted } from '../context/CommandPaletteProvider.js';
9995
import { useUrlOverlay } from '../hooks/useUrlOverlay.js';
@@ -483,7 +479,6 @@ export function AppHeader({
483479
// object document's own `listViews` (objectui#11295, objectui#11336).
484480
const servedViews = useServedViewItems();
485481
const { currentAppName, recordTitle } = useNavigationContext();
486-
const mobileSwitcher = useMobileViewSwitcher();
487482

488483
/**
489484
* Help menu — lazily-loaded `doc` metadata (ADR-0046) so the "?" menu can
@@ -942,60 +937,12 @@ export function AppHeader({
942937
moreLabel={t('common.more', { defaultValue: 'More' })}
943938
/>
944939

945-
{/* Mobile: current page label or view switcher */}
946-
{mobileSwitcher && mobileSwitcher.views.length > 0 ? (
947-
mobileSwitcher.views.length > 1 ? (
948-
<DropdownMenu>
949-
<DropdownMenuTrigger asChild>
950-
<button
951-
type="button"
952-
className="sm:hidden flex items-center gap-0.5 min-w-0 ml-1 rounded-md px-1.5 py-1 text-sm font-medium hover:bg-accent active:bg-accent/80 transition-colors"
953-
aria-label={t('topbar.switchView', { defaultValue: 'Switch view' })}
954-
>
955-
<span className="truncate max-w-[180px]">
956-
{mobileSwitcher.triggerLabel ??
957-
mobileSwitcher.views.find((v) => v.id === mobileSwitcher.activeViewId)?.label ??
958-
lastSegmentLabel}
959-
</span>
960-
<ChevronDown className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
961-
</button>
962-
</DropdownMenuTrigger>
963-
<DropdownMenuContent align="start" className="min-w-[220px] max-w-[280px]">
964-
{mobileSwitcher.views.map((v) => {
965-
const isActive = v.id === mobileSwitcher.activeViewId;
966-
return (
967-
<DropdownMenuItem
968-
key={v.id}
969-
onSelect={() => {
970-
if (!isActive) mobileSwitcher.onChange(v.id);
971-
}}
972-
className="gap-2"
973-
>
974-
{v.icon ? (
975-
<span className="shrink-0 text-muted-foreground [&>svg]:h-4 [&>svg]:w-4">{v.icon}</span>
976-
) : null}
977-
<span className="flex-1 truncate">{v.label}</span>
978-
{v.locked ? (
979-
<Lock className="h-3 w-3 shrink-0 text-muted-foreground" aria-hidden />
980-
) : null}
981-
{isActive ? (
982-
<Check className="h-4 w-4 shrink-0 text-foreground" aria-hidden />
983-
) : null}
984-
</DropdownMenuItem>
985-
);
986-
})}
987-
</DropdownMenuContent>
988-
</DropdownMenu>
989-
) : (
990-
<span className="text-sm font-medium sm:hidden truncate min-w-0 ml-1">
991-
{mobileSwitcher.triggerLabel ?? mobileSwitcher.views[0].label}
992-
</span>
993-
)
994-
) : (
995-
<span className="text-sm font-medium sm:hidden truncate min-w-0 ml-1">
996-
{lastSegmentLabel}
997-
</span>
998-
)}
940+
{/* Mobile: the current page's label. A list's view switcher is
941+
the list's own title, drawn in its toolbar row on a phone
942+
(objectstack#27058), so the bar draws no view dropdown. */}
943+
<span className="text-sm font-medium sm:hidden truncate min-w-0 ml-1">
944+
{lastSegmentLabel}
945+
</span>
999946
</>
1000947
)}
1001948
</div>

‎ui/packages/app-shell/src/layout/ConsoleLayout.tsx‎

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@ import { UnpublishedAppBar } from '../preview/UnpublishedAppBar.js';
2929
import { ConsoleNotificationBanners } from './ConsoleNotificationBanners.js';
3030
import { UnifiedSidebar } from './UnifiedSidebar.js';
3131
import { AppHeader } from './AppHeader.js';
32-
import { MobileViewSwitcherProvider } from './MobileViewSwitcherContext.js';
3332
import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar.js';
3433
import { primeConsoleSidebarOpen } from './consoleSidebarRail.js';
3534
import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js';
@@ -212,7 +211,6 @@ export function ConsoleLayout({
212211
// One shared, URL-addressable command-palette open state for both the
213212
// AppHeader trigger (navbar) and the CommandPalette (children) — ADR-0054.
214213
<CommandPaletteProvider>
215-
<MobileViewSwitcherProvider>
216214
{/* objectui#13130 — first in the DOM, so it is the first Tab stop. */}
217215
<SkipToContentLink targetRef={mainContentRef} />
218216
<AppShell
@@ -339,7 +337,6 @@ export function ConsoleLayout({
339337
/>
340338
)}
341339
</AppShell>
342-
</MobileViewSwitcherProvider>
343340
</CommandPaletteProvider>
344341
);
345342
}

‎ui/packages/app-shell/src/layout/MobileViewSwitcherContext.tsx‎

Lines changed: 0 additions & 133 deletions
This file was deleted.

0 commit comments

Comments
 (0)