Repository navigation
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
Conversation
…h a bottom action bar and pushed related pages Below 768 px the object list opens a record as a page (deriveRecordSurface with viewport 'mobile'): no split pane or drawer is mounted, a ?recordId= link is replaced by the record page, and the Display menu drops Open records. The list's title switcher leads its toolbar row below sm, and the app bar's second view dropdown (MobileViewSwitcherContext) retires. The record page on a phone: a top bar with one way back and the counter, a header whose subtitle is the first two declared text highlights with at most four highlights after them, and the header's actions drawn into a bottom bar (PageHeaderPhoneContext, PageHeaderActions face 'bar': primary, one more, the overflow menu). A related row or a lookup pushes its record as a page with a path row; going back restores the parent's scroll position. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc
…xt commit) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc
… parent record On a phone the top bar's way back of a top-level record page still reads the object plural; a page under a `?from=` trail (pushed from a related row or a lookup, or opened from a link) is named by where it returns, the parent record's title, or its short id when the trail carries none. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc
… way-back label change (removed in the next commit) Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc
Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc
|
domain:ui#5 · os-sales · session_01L6bhgUhKx1XHzkoikGjRB4 Contract reviewServed-tier: ① Derived judgmentsThe face is the ② Semver levelpatch for both packages, as the changeset declares: one new export pair and one optional prop; a host that provides no phone context draws the header's action row exactly as before (pinned by the control case in ③ Boundary flagsOn a phone the record page mounts no Implemented-by: VERDICT: PASS Generated by Claude Code |
…ss literal; pass phone to the list-rows test The record-header width e2e reads the page header's action tail as the last class literal before the toolbar role, so the row face and the phone bar face each draw their own toolbar element, the row's first, with the row's literal as on main. The list-rows navigation test passes the hook's new `phone` flag. Co-Authored-By: Claude <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc
|
domain:ui#5 · os-sales · session_01L6bhgUhKx1XHzkoikGjRB4 Contract reviewServed-tier: ① Derived judgmentsThe face is the ② Semver levelpatch for both packages, as the changeset declares: one new export pair and one optional prop; a host that provides no phone context draws the header's action row exactly as before (pinned by the control case in ③ Boundary flagsOn a phone the record page mounts no Implemented-by: VERDICT: PASS Generated by Claude Code |
|
domain:ui#5 · os-sales · session_01L6bhgUhKx1XHzkoikGjRB4 ACCEPT · 2026-10-31T22:42Z 席位核对
Generated by Claude Code |
domain:ui#5 · os-sales · session_01L6bhgT93xhnhe2yKWX7YPc
Fixes #27058
Clause-②: yes (widening) —
@object-ui/componentsgainsPageHeaderPhoneContext(+ typePageHeaderPhoneFace) and an optionalPageHeaderActionsProps.face;MobileViewSwitcherContextis not on the@object-ui/app-shellentry, so its retirement narrows nothing.Metadata-protocol: none
维护者速读
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
useIsMobilebreakpoint, read synchronously by the newhooks/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, nopackages/specfile.Target 1: the record is always a page
objectViewRecordNavigation.tsanswers 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 itsfrom(and the shown rows as its ↓ ↑ set, the Record page v4: same-shaped related panels with no collapse, a persistent tab row under the header (the enlarged state is the active tab), Activity as the first panel and a tab, one top bar on every face, tiers by container width #27524 hand-off).ObjectView.tsxmounts noObjectViewRecordOverlayon a phone (no split pane, no drawer, no maximize), and hands the list norecordOpen, so the Display menu has no Open records section and a stored split / drawer preference draws nothing.?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
sm(the header's own breakpoint,useViewTabsFit) the page header is not drawn, so the Console lists: the list-view switcher becomes a dropdown on the list title (search, pinned default, recent), the flat view-tab row retires, and a starred view appears in the sidebar's 收藏 group #27594 title switcher is handed to the list asphoneTitle: it leads the list's toolbar row (ListView's existingtoolbarLeadingslot) beside the filter / sort / display icons, or heads a chart face. The header then gets no switcher, so there is exactly one.showViewSwitcher, the Display menu's Layout row) is off there.AppHeaderdraws the page's name and no view dropdown;MobileViewSwitcherContext.tsxis deleted, with its provider inConsoleLayout.tsxand its registration inobjectViewViewCrud.tsx.Target 3: the top bar
RecordDetailView's page bar on a phone isRecordTopBarwith 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.tstakes the subtitle from the first two declaredhighlightFieldswhose type istext, joined by 「 · 」 from the values the reader can see, and keeps at most four highlights after them (PHONE_HIGHLIGHTS_MAX). The subtitle reachespage:headerthrough the host slot below and is drawn only when the header declares nosubtitleof 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/componentsgainsPageHeaderPhoneContext(renderers/layout/page-header-phone-context.ts, besidePageHeaderTrailingContext): a host slot, never metadata. The record page provides it on a phone with its bar element and the subtitle.PageHeaderRendererdraws no action row: the samePageHeaderActionsresolver renders in its newface: '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.⋯(PHONE_ACTION_BAR_BUTTONS = 2; a page's declaredmobileMaxVisiblestill wins). Each button shares the bar's width; the host's Delete stays in the⋯'s destructive band.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.openRecordon a phone navigates to the record route with the?from=trail extended (as before) and the history staterecordPagePushStatebuilds (views/recordPagePush.ts): the parent page asfrom, the related list's rows asfrom.siblings, and arecordPagePushedmarker. No drawer stack is mounted on a phone (RecordDetailViewreturns its page tree withoutRecordPageDrawerHost).navigate(-1)).useRecordPageScrollMemoryremembers 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.tsxandrecordDrawerStack.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(itstoolbarLeadingandrecordOpenprops already existed).File surface beyond the claim amendment 6548379901
ui/packages/components/src/renderers/layout/page-header-phone-context.ts, besidepage-header-trailing-context.ts, not undercustom/as the amendment spelled it.ui/packages/app-shell/src/views/viewTabLabel.tsand one comment inObjectView.tsx: comment-only lines that named the retired mobile switcher.ui/packages/plugin-detailandui/packages/plugin-grid(pins only, see Tests).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 reusesdetail.goBack,common.breadcrumband 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 declaredmobileMaxVisible: 1sets 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 withfrom.pathnamethe list andfrom.siblingsits rows; a?recordId=URL is REPLACED by the record route; a storedsplit/drawerpreference 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 realRecordDetailViewat 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:
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」).ObjectView.titleViewSwitcher-27594(+1: at 390 the one switcher leads the toolbar row with its count, opens the same dropdown, and switches the view).grouping) keeps its desktop grid — titles cut mid-word, other columns behind the pinned Actions column — while the same console turns a plain list into cards #26896:plugin-grid/src/__tests__/groupedMobileCards-26896(+1: a tapped card hands its row to the host'sonRowClick, the door the phone's page navigation takes).ObjectView.objectListTitle-27950lost itsMobileViewSwitcherContextprobe; 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.Ablations (each through scripts/ablation-replace.mjs, restore proven: blob == HEAD and git diff HEAD empty)
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.containers.tsx), the subtitle reading no field (recordPhoneHeader.ts),phoneTitlenever 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:RecordDetailView.tsxphoneBack:? parent.t || shortRecordId(parent.i)→? plural): 3 failed | 7 passed inRecordDetailView.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.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 in08421f1c82and removed in the next commit, so they never reachmain(an earlier set,74dd2d29a6, predates the way-back label change and is superseded). The folder's README says how they were made;run.logrecords each shot's route, the bar's box, the top bar's way back as drawn and the scroll readings;evidence.cjsis the script.5 / 5, ↓ ↑; name and object chip; four highlights in two columns; the stage path; the tab row; the details; the bar [Convert Lead][编辑][⋯].y 779 + h 65 = 844).1 / 3over the account's shown opportunity rows; the tab row 「概览 · 动态 0 · 活动列表 0 · 线索列表 0 · +1 ▾」.2 / 3; the tab row 「概览 · 动态 0 · 活动列表 2 · 联系人列表 1 · +2 ▾」 at 390 px; the bar [编辑][⋯] (the account declares no action).?related=crm_activity.account): the child object's card rows fill the body under the enlarged toolbar.highlightFields, so this shot patches the served lead metadata in the browser (page.route; nothing in the diff) withcompany, 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:
AppHeader) is kept on the phone record page. The board's frame starts at the record's top bar, but on a phoneAppHeaderis 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.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 inpage-header-phone-bar-27058andRecordDetailView.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):
highlightFieldson a default page; no declared list means no subtitle (app-crm declares none: shots 02 and 13).position: fixedwith ascroll-pb. It stays visible while the body scrolls (shot 03, bar box inrun.logfor every record shot), and the body ends where the bar starts, which is what thescroll-pbwas for.?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 ofRecordDetailView.phoneFace-27058.sm, 640), as at that width before.ListViewreports rows only for the ungrouped grid).Human Minimum
Each item is one rule from
CONTRIBUTING.md › Human Minimum,
which links the AGENTS.md text that states it.
packages/specholds no runtime logic. (packages/specis not touched.)main, one pull request.packages/spectouched: its generated artifacts are regenerated and committed. (Not touched.)content/docs/references/,packages/*/CHANGELOG.mdorcontent/docs/releases/.Verification
All local readings are at head
15455616baunless named. Its tree is identical tof33dd4f88c(git diff f33dd4f88c 15455616bais empty: the two commits after it add and remove onlyevidence/), so a reading taken atf33dd4f88cis a reading of this tree. Against the merge base8e2d56dccethe 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-treeagainstorigin/main(11 commits ahead of the base) is clean.Tests: 643 files, 4624 tests green over every touched surface; tsc 0; eslint 0 errors
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__/groupedMobileCardsTest Files 643 passed (643)·Tests 4624 passed (4624)· VERDICT command-exit 0 (at1511473e38the same set read 643 / 4623; the +1 is the untitled-trail case).f33dd4f88c:Test Files 5 passed (5)·Tests 36 passed (36).npx tsc --noEmit -p tsconfig.jsoninui/packages/app-shellatf33dd4f88c: exit 0 (ui/packages/components, unchanged since1511473e38: exit 0 there).no-explicit-any).pnpm --dir ui exec vitest run scripts/:Test Files 169 passed | 2 skipped (171)·Tests 4901 passed | 2 skipped (4903).Derived gate families: 34 derived, 34 run, every exit 0, 0 NOT-MEASURED (at f33dd4f, this tree)
node scripts/check-adr-0087-registration.mjs --base origin/main: exit 0node scripts/check-adr-0087-registration.mjs --self-test: exit 0node scripts/check-changeset-no-major.mjs --base origin/main: exit 0node scripts/check-closing-keyword-parity.mjs: exit 0node scripts/check-closing-keyword-parity.mjs --self-test: exit 0node scripts/check-comment-mask-corpus.mjs: exit 0node scripts/check-empty-changeset.mjs --base origin/main: exit 0node scripts/check-empty-changeset.mjs --self-test: exit 0node scripts/check-sdui-manifest.mjs: exit 0node scripts/check-sdui-manifest.mjs --self-test: exit 0node scripts/pm/release-rehearsal-clone.mjs --self-test: exit 0node scripts/release-pending-publish.mjs --self-test: exit 0pnpm --dir ui exec vitest run scripts/: exit 0pnpm --filter @objectstack/spec run check:duration-unit-keys: exit 0pnpm check:changeset-gate-self-tests: exit 0pnpm check:closed-ceremony-text: exit 0pnpm check:cross-package-test-inputs: exit 0pnpm check:dispatcher-error-vocabulary: exit 0pnpm check:driver-memory-census: exit 0pnpm check:engine-double-contract: exit 0pnpm check:gitlink-declared: exit 0pnpm check:nul-bytes: exit 0pnpm check:pm-changeset-deadline-census: exit 0pnpm check:published-files: exit 0pnpm check:query-options-erasure: exit 0pnpm check:refd-timer-probe: exit 0pnpm check:skill-identifier-liveness: exit 0pnpm check:test-source-alias: exit 0pnpm check:tier-file-adoption: exit 0pnpm check:type-check-coverage: exit 0pnpm check:type-check-debt: exit 0pnpm check:undeclared-dep-imports: exit 0pnpm check:watch-hint-literal: exit 0pnpm check:where-matcher: exit 0node scripts/pm/dispatch-gates.mjs --ranover that record: 34 derived famil(ies) accounted for, 34 run, 0 NOT-MEASURED, 0 UNRUN.CI check-runs: not started at the time of writing.
Generated by Claude Code
🤖 Generated with Claude Code
https://claude.ai/code/session_01L6bhgT93xhnhe2yKWX7YPc