Skip to content

feat(plugin-detail)!: one record page laid out by its own width; 动态 is the right column's first panel - #28116

Merged
os-zhuang merged 10 commits into
mainfrom
claude/project-thread-f9tvxt
Oct 31, 2026
Merged

os-zhuang merged 10 commits into
mainfrom
claude/project-thread-f9tvxt

Conversation

@objectstack-fleet

@objectstack-fleet objectstack-fleet Bot commented Oct 30, 2026 •

Copy link
Copy Markdown
Contributor

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Requested by Bill · project thread
Part of #27524
Clause-②: yes (narrowing)
Metadata-protocol: none
Skills-docs: content — content/docs/ui/pages.mdx described the synthesized record page's tab strip, its column tiers, the related rail and the timeline column; it now describes the details and the right column, 动态 first, laid out by one rule. No file under skills/** teaches the record page's layout (a git grep for the tiers, the switch and the stream names nothing there).

维护者速读

  • 改了什么:合成记录页只按容器宽度一条规则排版。≥ 760 px 详情 | 右栏 320 px;≥ 1120 px 右栏 380 px、详情字段 3 列、动态 5 条;< 760 px(分屏 720、手机)单列:详情,再按顺序接右栏各面板。三列档、页签档、「相关 N」导轨、常驻时间线列和它的 ⊞ 开关全部退役。动态(原时间线)成为右栏第一个面板:一行输入框、最新 3 条(宽档 5 条)、「查看全部 N 条」打开动态页签(?related=timeline,完整流:筛选、回复、加载更多)。右栏开关 ⊟(「右栏」,⌘⇧,)只剩一个图标。
  • 为什么改: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 的 Target 4、5、6(画布 60 定稿),四个串行 PR 中的第二个;⊟ 移到顶栏在 PR ④,页签行在 PR ③。
  • 风险与回滚:只动 ui 包(plugin-detail、components、app-shell、十个语言包)、文档和两个夜间 live 测试 spec。合成页的动态在每个宽度都开页即读(原先窄档要等页签)。detail.timeline 的值改成 动态 / Activity,所以手写页面的 Timeline 页签不再映射到它,仍读「时间线」。回滚即 revert 本 PR。
  • 先看:ui/packages/plugin-detail/src/RecordPageColumns.tsx(档位与右栏);RecordTimelinePanel.tsx + RecordActivityTimeline.tsx 的 peek 面;synth/recordPageColumns.ts(宽度规则);app-shell RecordDetailView.tsx(⊟ 与开页读)。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/RecordTimelinePanel-27524.test.tsx packages/app-shell/src/views/RecordDetailView.recordPageTiers-26678.test.tsx packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx,预期 3 个文件、23 个测试全绿。
  • 需要你做的:本 PR 单独改动 3571 行(+2052 / −1519,71 个文件),超过 3000 行人工合并线(AGENTS.md §7(c)),按 Tier H 落地:需要 os-zhuang 或 hotlong 的 APPROVED 审查,或你亲自合并。三成多是退役代码和按新结构改写的测试(验收第 4 条),第 5、6 条共用时间线列的退役,拆开反而要造一个只为删除而存在的过渡布局。
  • 审查:契约审查 6523984004 在 a38376b7be 上判 FAIL。④-1:⌘⇧, 会串到抽屉下面的页面。④-2:在抽屉里按 Esc 会关掉整个抽屉,而不是先退出放大面。两个修复提交之后,复审 6525247890 判 PASS。预览审查两次 PASS(6524207275、6525118794)。遗留 Record pane: Esc that a field already handled still steps the pane back a level (or restores it from maximized) #28156:抽屉最大化或在下层时,字段内的 Esc 会被抽屉接走;那段代码属 Record drawer v4: a bounded peek over the list — md cap instead of the field-count bucket, no dim overlay, the list stays readable and clickable, ⤢ maximizes in place at the same URL; the full page is reached only from the row ↗ and the header ⋯ #27545。

What changes

The synthesized record page is laid out by one rule, the width of the container it occupies, and 动态 becomes the right column's first panel (record page v4, canvas 60). This is PR ② of four serial PRs for #27524, approved by the seat as a split. It restarts from main after PR ① merged and leaves main coherent on its own.

Target item Where
1, 2, 3, 10, 11, 12 PR ① (merged)
4 ⊟ the right-column switch this PR, at the end of the record header's action row; PR ④ moves it to the top bar
5 动态: a panel and a tab, never a column this PR (the tab is the interim ?related=timeline face; PR ③ makes it a tab of the tab row)
6 tiers by the container's own width this PR
8, 9 the tab row, enlarged = active tab PR ③
7 the top bar PR ④, the one that closes the card

6. One rule (synth/recordPageColumns.ts, useRecordPageTier.ts, RecordPageColumns.tsx). recordPageTierFor(width) answers wide from 1120 px (RECORD_PAGE_WIDE_MIN_WIDTH), two-column from 760 px, stack below it and for an unknown width. The embedded record (split pane, overlay drawer, maximized) and the phone take the same answer: useRecordPageTier lost embedded. Both column tiers draw details | the right column, 320 px (grid-cols-[minmax(0,1fr)_20rem]) or 380 px when wide (_23.75rem). The stack is one column: the details, then the right column's panels in their order. A details section takes up to three field columns when wide and two beside a 320 px column (DetailsColumnCapContext, read by framelessSectionColumns). The three-column tier, the tabbed tier, the 「相关 N」 rail (relatedRail, record-related-rail-toggle), the resident timeline column, its ⊞ promotion and recordTimelineDefaultReason / recordPageColumnDefaults / recordPageArrangementFor retire. The right column is an <aside aria-label="右栏"> in every tier.

5. 动态 is a panel and a tab (RecordTimelinePanel.tsx, renderers/record-activity.tsx, RecordActivityTimeline.tsx). The panel leads the right column and has a related panel's header: 「动态」, the stream's count (data-record-timeline-count, absent while the feed loads), and the header row as one stretched button that opens the 动态 tab (the census rule from PR ①). Its body is the page's own record:activity block in a peek face, chosen by RecordTimelinePanelContext:

  • a one-line composer (「写一条备注… @ 提到同事」, detail.timelineComposer). Pressed, it becomes the full composer, focused; a sent note folds it back;
  • the newest 3 roots (5 when wide), one line each: icon, text, time. A reply is not a line of its own;
  • 「查看全部 N 条 →」 when the stream holds more than it draws.

The 动态 tab is ?related=timeline, the existing enlarged face: the whole stream with the 全部 · 活动 · 讨论 · 变更 chips, threaded replies and "Load more" (data-stream-load-more, one page per press; it replaces the stream's own 「查看全部」). The synthesized tab strip, where a page keeps one, orders details / 动态 / related for every object; the worked-object promotion is gone.

4. ⊟ (app-shell RecordDetailView.tsx, components panel-toggles.tsx). One toggle, 「右栏」, drawn at the end of the record header's action row only where the tier has a right column and the page has a panel for it. ⌘⇧, flips it; ⌘⇧. (the timeline column's) is gone. Off, the details take the row. The stored preference keeps its name, record.columns.related, so a reader's earlier related-column choice holds; record.columns.timeline is no longer read. PanelToggles draws icon-only toggles named by their label (no border group, h-8 w-8).

Naming. detail.timeline is 「动态」 / "Activity" in all ten packs (the values detail.timelineFolded had). detail.relatedColumn is 「右栏」 / "Right column"; console.shortcuts.toggleRelated follows. detail.timelineFolded and console.shortcuts.toggleTimeline leave every pack; detail.timelineComposer arrives in all ten.

Tab labels (components containers.tsx). With detail.timeline now naming the stream, the strip's well-known-token map could no longer send the bare word Timeline to it: the served approval page's history tab is labelled Timeline, and in en it read "Activity" (approvalRequestsDataSource.servedPage went red on exactly that). The synthesizer writes its own token, Activity stream, for its stream tab (read as detail.timeline), and an authored Timeline reads console.objectView.viewTypeTimeline, the pack's word for a timeline (zh-TW keeps its dict form). Pinned in page-tabs-builtin-label-i18n-4645.

Open waves. The 动态 panel is on screen at open in every tier, so a synthesized page reads its feed, the mention list and the audit log at open at every width (before, the narrow tier waited for the Timeline tab). Opening the 动态 tab reads none of them again. An authored page with tabs still waits for the tab that holds them (recordOpenWaves-13010 keeps those cases unchanged).

Review fixes. The contract review 6523984004 on a38376b7be was FAIL, with must-fix ④-1 and should-fix ④-2. The delta review 6525247890 on 90828f16cb is PASS.

Declared. Clause-②: yes (narrowing). Two changesets:

  • 27524-record-page-tiers-activity-panel.md (plugin-detail, components, app-shell): eight exports and three members leave the plugin-detail entry, under not-required (runtime-export-retired …) with one FROM → TO line each. The gate verifies all eleven. RecordPageTier becomes 'stack' | 'two-column' | 'wide', and recordPageTierFor takes one argument. Both are said in prose.
  • 27524-record-page-activity-i18n.md (i18n): TranslationKeys loses the two keys, under not-required (runtime-interface-only …#TranslationKeys), as PR ① did.

Size: over the human-merge threshold. This PR changes 3,571 lines (+2,052 / −1,519 over 71 files, additions + deletions), over the 3,000 of AGENTS.md §7(c), so it lands the way a Tier H surface does: an authorized APPROVED review (GOVERNED_APPROVERS) and then the owning seat, or the maintainer's merge. It is not split further. More than a third of it is retired code and pins rewritten to the new anatomy (acceptance item 4), and Target 5 and 6 share one retirement, the timeline column: a split would have to build an interim layout that exists only to be deleted by the next PR.

Source re-measured. The card names record.timeline.* keys. No such namespace exists: the record page's labels are detail.timeline (value changed), detail.timelineFolded and console.shortcuts.toggleTimeline (both retired). The 1200 px board draws the details grid at three columns; this follows the card's rule, two columns beside the right column below 1120 px.

File surface. Outside the card's list: components/src/custom/panel-toggles.tsx (⊟ is icon-only), components/src/renderers/layout/containers.tsx (the Timeline token above), app-shell/src/utils/workspacePreference.ts (the timeline switch's preference retires), RichTextCommentInput.tsx (autoFocus for the expanded composer), DetailSection.tsx / defaultRecordPage.ts (the details column cap), the plugin-detail README, content/docs/ui/pages.mdx, three items of docs/qa/platform-checklist/areas/records-forms.json that located the stream by the retired column, and ui/e2e/live/record-history-display.spec.ts (opens ?related=timeline; on main #28009 had re-pointed it at the folded Activity card that this PR retires).

Seat-approved addition (the dispatch keeps examples/** off limits): examples/app-showcase/e2e/detail-shapes.spec.ts, the Contoso case only. #28009 pinned it to the tabbed tier at a 780 px window, and Target 6 retires that tier, so the nightly showcase smoke would go red on this PR's merge. The case now reads the one-column stack tier instead (seat 2 widened the surface to this one file; the rest of examples/** is untouched).

Tests

New:

  • plugin-detail/src/__tests__/RecordTimelinePanel-27524.test.tsx (7): the real record:activity block in the panel. Header 「Activity」 and the whole stream's count, the header opens the tab; the newest three roots (a reply is not a line), no chips, no load-more, 「查看全部 8」 opens the tab; five when wide; no 「查看全部」 when the stream holds no more; no count while the feed loads; the one-line composer expands focused and folds after a sent note; CONTROL: outside the panel the same block is the full stream.

  • plugin-detail/src/__tests__/RecordPageColumns.enlargedEscapeInPane-27524.test.tsx (6). It runs through the real NavigationOverlay (its Radix sheet and its split listener), with the page's blocks stubbed:

    • in the drawer, Esc on the 动态 tab returns to the record's page, and the next Esc dismisses the drawer;
    • below the drawer's root, the face closes and the level stays;
    • in the split pane beside a list, the face closes and the pane stays;
    • an open menu owns Esc;
    • a text field in the face gets its Esc while the face and the pane stay, in the drawer and in the split pane.

Rewritten to the new anatomy, not deleted (acceptance item 4):

  • RecordDetailView.recordPageTiers-26678 (6): 1440 and 1200 wide (380 px, details grid at three), 1100 two-column (320 px, two), 720 and 390 one column with 动态 after the details, embedded at 1440 and 720 by the same rule, ?tab= addresses no strip and ?related=timeline is the full stream.
  • RecordDetailView.panelToggles-27052 (10): one icon 「Right column」 at the end of the action row; off and on; survives a remount, per object; non-worked and container objects open with the right column; ⌘⇧, by key and by character, inert in a text field; ⌘⇧. is not the page's; not drawn in the one column; drawn for the embedded record at 1440; with a 1000 px drawer over a 1440 px page, ⌘⇧, flips the drawer's switch alone, and the page's once the drawer is gone.
  • recordPageV3Face-27055 (the details column cap: two beside a 320 px right column), RecordPageColumns.test, RecordPageColumns.switches-27052, synth/recordPageColumns.test, synth/recordPageColumns.switches-27052, RelatedCardStack-27055, recordTimelineStream-27055, buildDefaultPageSchema.* (order details / 动态 / related for every object), panel-toggles.test, workspacePreference.*.
  • examples/app-showcase/e2e/detail-shapes.spec.ts, the Contoso case (nightly showcase smoke): at 780 px (732 px of view) the page is stack, data-record-columns-shown="details", no Details / Related tab; Invoices and Projects are body tables in the details with their rows read at open; the right column's first panel is 动态, Invoices and Projects are not panels; the Contacts panel reads 「Carl Fox」 once scrolled into view. The flat-details, money-strip and footer assertions are unchanged.
  • The app-shell pins that opened the tabbed tier read the laid-out page: approvals (approvalLock-12220, approvalLock-25934, approvalRecallGate: the first of the page's probes), the feed and audit pins (?related=timeline), the related-list wire pins (relatedBadgeArity-8882 and relatedListFilter-4664 read the panel's count and rows; relatedListInheritedSort-5795 and childWriteRefreshesParent-12244 open the list's whole face), relatedCountsFirst-27056, relatedListsOrder-27241, bodyTablePredicate-27478 (the right column), accessibleNames-12625 (the right column and its panels are named), recordOpenWaves-13010 (the synthesized cases read at open), and console approvalRequestsDataSource.decisionPage (the slotted request page is laid out; its actions list opens by ?related=).
Runs

Head 90828f16cb (the two review-fix commits on a38376b7be), under the verify lock:

  • pnpm --dir ui exec vitest run over 105 test files (by git ls-files: RecordDetailView.*, the drawer tests, navigation-overlay* and console approvalRequestsDataSource.*) plus packages/plugin-detail → Test Files 452 passed | 1 skipped (453), Tests 3820 passed | 18 skipped (3838), VERDICT command-exit 0.
  • type-check of @object-ui/plugin-detail and @object-ui/app-shell: each exit 0. eslint on the four changed files: 0 errors.
  • The new Esc pin was red on d98543cb28: the drawer's setIsOpen(false) was called for an Esc in a field inside the face. It is green on 90828f16cb.
  • CI on 90828f16cb: all 61 check-runs concluded success, or skipped by path filter or opt-in. Test UI (16/16), Type Check UI, Lint UI and Live E2E UI are among them.

Head a38376b7be, on main f0857d29c1 (PR ① merged as ad030d805b; #27953 is in), under the verify lock unless said:

  • pnpm build → Tasks: 115 successful, 115 total, VERDICT command-exit 0.
  • pnpm --dir ui exec vitest run packages/plugin-detail packages/i18n plus 322 test files across ui/: the 311 that name a module or id this PR changes (RecordDetailView, RecordPageColumns, useRecordPageTier, record:activity, page:tabs, PanelToggles, workspacePreference, detail.timeline and the rest; derived by git grep on this head) and the 11 test files feat(ui): record pane v4 mechanics — split beside the list, non-modal drawer over a list, maximize in place, narrowed list faces #27953 added or changed for the pane → Test Files 760 passed | 1 skipped (761), Tests 9018 passed | 31 skipped (9049), VERDICT command-exit 0. The whole ui/ suite is left to CI Test UI: a full local run outlives this container's background ceiling.
  • type-check (tsc --noEmit + tsc -p tsconfig.test.json) of @object-ui/i18n, @object-ui/components, @object-ui/plugin-detail, @object-ui/app-shell, and @objectstack/example-showcase typecheck (its tsconfig includes e2e/): each exit 0. eslint on the 64 touched ts/tsx files under ui/: 0 errors (unlocked).
  • The nightly harness itself, after pnpm console:build: cd examples/app-showcase && OS_TEST_TIERS=nightly pnpm exec vitest run test/showcase-smoke.live.test.ts test/console-live-specs.live.test.ts → Test Files 2 passed (2). The showcase smoke's Playwright run: 53 passed, the three detail-shapes cases among them (Contoso on the stack tier). The console live specs: 19 passed, record-history-display among them (the Changes chip on the 动态 tab).

Before the restart, on c888a82d32 (PR ①'s 8b20d10d4c): the same derivation gave 742 files green. That run found one type-check error, in plugin-detail's test project: recordPageV3Face-27055 passed framelessSectionColumns straight to Array.map, which now hands it the index as the new cap. c888a82d32 (now 81683b80b7) calls it with the count and pins the cap. Three console approval page pins (decisionPage, servedPage, servedPageMemberPermissions-13324) were red on the first run, before the Tab labels fix above and the decisionPage rewrite.

Acceptance notes

  • Screenshots: on examples/app-crm, not HotCRM (deviation, as the order says); eleven shots pr2-*.png in /mnt/project-files/ui-seat2/card-27524/shots/ (project files, not a repository branch), zh, through the console's dev server on c888a82d32 (before the restart onto main; the record page code is unchanged by it). Measured in the page (container width → tier, right column width, 动态 lines):

    • pr2-01-account-1440 (container 1184 → wide, right column 380, 动态 shows 5 of 6 and 「查看全部 6 条 →」), pr2-02-account-1200 (944 → two-column, 320, 3 lines), pr2-03-account-1000 (952 → two-column, 320).
    • pr2-04-account-720 and pr2-09-account-390 (phone, full page): one column, the details, then 动态, then the related panels; no ⊟.
    • pr2-05-activity-panel, pr2-06-composer-open (the one-line composer pressed: the full composer, focused), pr2-07-activity-tab (「查看全部」 → ?related=timeline: chips 全部 · 活动 · 讨论 · 变更, threaded entries), pr2-08-right-column-off (⊟ off at 1440: the details take the row).
    • pr2-10-lead-1440 (details grid at three columns beside 380) and pr2-11-lead-1000 (two beside 320). app-crm's account shows no details body at all: every field it holds a value for is a highlight, the same on main.
  • The tier follows the container, not the window: with the sidebar open, a 1200 px window gives a 944 px page, which is two-column.

  • Preview review (proxy for epic:epic: record page v2 — one default open mode per object (drawer by default, page by declaration; modal / split / popover retired as metadata); the drawer and the page are one page at two widths; related records open in place #26676#1, per ruling B 6520722066):

    • PASS on a38376b7be (6524207275). It covered Target 4, 5 and 6, plus a regression pass on ①, at 1440, 1200, 1000, 720 and 390 and in the pane opened from a list.
    • PASS on 90828f16cb (6525118794). This was a delta re-check of the two pane keys, Esc and ⌘⇧,, at 1440 and 720.

Verification

Gates and the dogfood run

Head 90828f16cb, change set vs the merge base f0857d29c1: 71 paths, 3,571 changed lines (OVER the 3,000 line).

  • node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack on 90828f16cb → 84 commands, the same list as on a38376b7be. Each was run and its exit code captured before any pipe. All 84 are :: exit 0, node scripts/check-sdui-manifest.mjs among them. One more was run beyond the derivation, pnpm --dir ui run check:i18n-drift :: exit 0.
    • pnpm check:type-check-debt first answered exit 3, NOT MEASURED: its closure build queued behind the dogfood run for the verify lock. Rerun under the lock, it exits 0 (0 raw tsc error(s) total, none above its recorded number). That rerun is the code recorded below.
  • --ran reconcile: 84 derived, 84 run, 0 NOT-MEASURED, 0 UNRUN.
  • Dogfood, once on 90828f16cb (the title has !), under the verify lock: pnpm --filter @objectstack/dogfood test → VERDICT command-exit 1, Test Files 1 failed | 359 passed | 1 skipped (361), Tests 1 failed | 2891 passed | 9 skipped (2901). This is the same single red as on a38376b7be: api-action-door-forward.dogfood.test.ts, "the switch holds: ban_user OFF answers 409 ACTION_DISABLED…".
  • NOT MEASURED here, CI's own: the six path-scheduled CI jobs and the five type-check lanes the derivation names. The per-package type-check runs above stand in for the lanes of the four packages this PR changes.
  • main has moved 33 commits past f0857d29c1 since the restart, and git merge-tree with it is clean. The shared paths are the ten locale packs, RecordDetailView.tsx and containers.tsx, in different hunks. CI's merge ref covers it, and no merge was pushed (seat, 13:12Z).
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-adr-links.mjs :: exit 0
node scripts/check-adr-links.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-adoption.mjs :: exit 0
node scripts/check-comment-mask-adoption.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-doc-route-spelling.mjs --advisory :: exit 0
node scripts/check-doc-route-spelling.mjs --self-test :: exit 0
node scripts/check-docs-section-name.mjs :: exit 0
node scripts/check-docs-section-name.mjs --self-test :: exit 0
node scripts/check-dts-emitted.mjs --self-test :: 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/check-section-landing-index.mjs :: exit 0
node scripts/check-section-landing-index.mjs --self-test :: exit 0
node scripts/ci/assert-turbo-remote-cache.mjs --self-test :: exit 0
node scripts/ci/plan-test-core-shards.mjs --self-test :: exit 0
node scripts/ci/prune-turbo-cache.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/lint run check:doc-formula-expressions :: exit 0
pnpm --filter @objectstack/lint run check:doc-security-posture :: exit 0
pnpm --filter @objectstack/spec run check:docs :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm --filter @objectstack/spec run check:empty-state :: exit 0
pnpm --filter @objectstack/spec run check:liveness :: exit 0
pnpm --filter @objectstack/spec run check:skill-examples :: exit 0
pnpm --filter @objectstack/spec run check:strictness-ledger :: exit 0
pnpm --filter @objectstack/spec run check:variant-docs :: exit 0
pnpm --filter @objectstack/spec run check:yaml-examples :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:ci-filter-parity :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:corpus-claim-drift :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:doc-anchors :: exit 0
pnpm check:doc-authoring :: exit 0
pnpm check:doc-frontmatter :: exit 0
pnpm check:docs-audit-scope :: exit 0
pnpm check:docs-redirects :: exit 0
pnpm check:docs-single-h1 :: exit 0
pnpm check:docs-spec-enumerations :: exit 0
pnpm check:docs-transcript-drift :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:dts-closure :: exit 0
pnpm check:dual-build-cjs-loads :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:examples-live-imports :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:keyed-text-bounds :: exit 0
pnpm check:lean-entry-closure :: exit 0
pnpm check:logger-receiver-detach :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:org-identifier :: exit 0
pnpm check:page-declaration-shape :: exit 0
pnpm check:platform-checklist :: exit 0
pnpm check:pm-changeset-deadline-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:published-readme-links :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:react-page-adapter-contract :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:registry-log-declared :: exit 0
pnpm check:rest-log-spy-declared :: exit 0
pnpm check:role-word :: exit 0
pnpm check:skill-identifier-liveness :: exit 0
pnpm check:sourcemap-no-sources-content :: exit 0
pnpm check:system-context-census :: 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:vendor-version-stamps :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
pnpm --dir ui run check:i18n-drift :: exit 0

Body refreshed 2026-10-30T14:49Z for head 90828f16cb: the two review fixes, their runs, the preview re-check and the gates and dogfood run on this head.


Generated by Claude Code

… the right column's first panel

Two tiers by the container's width: at 760 px and up, details beside a
right column (320 px; 380 px with a 3-column details grid at 1120 px and
up); below 760 px, one column. The three-column, tabbed and rail tiers,
the resident timeline column and its ⊞ switch retire. 动态 is the first
panel of the right column: a one-line composer, the newest 3 entries (5
when wide) and 「查看全部 N 条 →」 to the full stream. ⊟ shows or hides
the right column.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
…e by width, the 动态 panel and tab, the ⊟ switch

The pins that opened the tabbed tier now read the anatomy that replaced
it: the stack in jsdom, the right column's panels, `?related=timeline`
for the whole 动态 stream and `?related=<object>.<field>` for a related
list's whole face. Changesets for plugin-detail, components, app-shell
and i18n.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
…hored Timeline tab stays a timeline

`detail.timeline` names the record page's 动态 stream now, so the tab
strip no longer maps the bare word `Timeline` to it: that word is an
author's too (the served approval page's history tab). The synthesizer
writes `Activity stream` for its stream tab, read as `detail.timeline`;
an authored `Timeline` reads the pack's own word for a timeline. The
slotted approval request page is laid out, so its actions list opens
by `?related=`.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
…动态 first, laid out by one rule

The pages guide and the three QA checklist items that located the
stream by the retired timeline column, its switch and the tabbed tier
now point at the 动态 panel and its tab (`?related=timeline`).

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
…e, and pin its two-column cap

The section-columns helper took a second argument (the cap beside a 320 px
right column), so passing it straight to Array.map handed it the index as a
cap: a type error in the test project, and a cap nobody meant at runtime.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
…tack tier

The tabbed tier this case pinned is retired: below the 760 px view floor
the record page is one column, the details then the right column's panels.
The case asserts the stack and the absent tab strip, Invoices and Projects
as body tables in the details, 动态 as the right column's first panel, and
the Contacts panel reading its rows once scrolled into view.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions github-actions Bot added size/xl documentation Improvements or additions to documentation tests tooling labels Oct 30, 2026
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Contract-review-claim: session_01Utp6CY2n1JmZZKrHgtp9jC · head a38376b · 2026-10-30T13:13Z

Full at-tier review (CONTRACT_REVIEW_TIER, the narrowing face) of PR ② of #27524, by a reviewer with no development context.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: a38376b7beb368c726c179e82b91391bcd095216
Local-runs: none
Scope: docs/qa/platform-checklist/areas/records-forms.json, examples/app-showcase/e2e/detail-shapes.spec.ts, ui/apps/console/src/services/approvalRequestsDataSource.decisionPage.test.tsx, ui/e2e/live/record-history-display.spec.ts, ui/packages/app-shell/src/utils/workspacePreference.ts, ui/packages/app-shell/src/utils/workspacePreference.test.ts, ui/packages/app-shell/src/utils/workspacePreference.hook.test.tsx, ui/packages/app-shell/src/views/RecordDetailView.tsx, ui/packages/app-shell/src/views/RecordDetailView.{accessibleNames-12625,activityActorId-12067,approvalLock-12220,approvalLock-25934,approvalRecallGate,auditDisplayLocale-10442,auditTenantCurrency-27640,bodyTablePredicate-27478,childWriteRefreshesParent-12244,discussionExplicitComposition-7298,discussionFallbackPipeline-8983,panelToggles-27052,recordOpenWaves-13010,recordPageTiers-26678,relatedBadgeArity-8882,relatedCountsFirst-27056,relatedListFilter-4664,relatedListInheritedSort-5795,relatedListsOrder-27241,replyCountOnRead-12144}.test.tsx, ui/packages/components/src/tests/page-tabs-builtin-label-i18n-4645.test.tsx, ui/packages/components/src/custom/panel-toggles.tsx, ui/packages/components/src/custom/panel-toggles.test.tsx, ui/packages/components/src/renderers/layout/containers.tsx, ui/packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.ts, ui/packages/plugin-detail/README.md, ui/packages/plugin-detail/src/DetailSection.tsx, ui/packages/plugin-detail/src/RecordActivityTimeline.tsx, ui/packages/plugin-detail/src/RecordPageColumns.tsx, ui/packages/plugin-detail/src/RecordTimelinePanel.tsx, ui/packages/plugin-detail/src/RichTextCommentInput.tsx, ui/packages/plugin-detail/src/defaultRecordPage.ts, ui/packages/plugin-detail/src/index.tsx, ui/packages/plugin-detail/src/recordTimelineStream.ts, ui/packages/plugin-detail/src/renderers/record-activity.tsx, ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts, ui/packages/plugin-detail/src/synth/recordPageColumns.ts, ui/packages/plugin-detail/src/useDetailTranslation.ts, ui/packages/plugin-detail/src/useRecordPageTier.ts, ui/packages/plugin-detail/src/tests/{RecordPageColumns,RecordPageColumns.switches-27052,RecordTimelinePanel-27524,RelatedCardStack-27055,recordPageV3Face-27055,recordTimelineStream-27055}.test.tsx, ui/packages/plugin-detail/src/synth/tests/{buildDefaultPageSchema,buildDefaultPageSchema.bodyTables-27055,buildDefaultPageSchema.strictPayload,recordPageColumns,recordPageColumns.switches-27052}.test.ts + ui/packages/app-shell/src/views/recordDrawerStack.tsx, ui/packages/components/src/custom/navigation-overlay.tsx, ui/packages/plugin-view/src/recordSurface.ts, ui/packages/app-shell/src/chrome/advertisedShortcuts.ts, ui/packages/plugin-detail/src/RelatedCardStack.tsx, ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx, scripts/check-adr-0087-registration.mjs

Read: card #27524 (body, all ten comments incl. the split plan 6513066595, the ruling-B status 6520776586, the ① landing 6521929290 and the ② dev report 6523663352), PR #28116 body and file list (70 files, +1795/−1511), the net diff against main at the merge base f0857d29c1, the claim line 6523701146, and the check-runs on the head. The narrowing face: Clause-②: yes (narrowing) on the PR body and both changesets, so the scope floor is every path of the diff outside the prose faces (the two changesets and content/docs/ui/pages.mdx are not reviewed here).

Check-runs on a38376b7be at read time: success — TypeScript Type Check, Type Check · workspace, Type Check · consumer gates, Type Check · source gates, Type Check · debt ledger, Lint & Repo Gates, Lint · ESLint, Lint · repo & tooling gates, Lint · docs & code guards, Lint · PM dispatch-gates, Lint · CI, release & engine gates, the three scoped lints, Build Core, Build Docs, Build UI Deps, Build & E2E UI, Live E2E UI, Test Core shards 2–5, Test UI (13/16), Dogfood Regression Gate, Check Changeset, Check Documentation Links, Check PR Size, Spec property liveness, Governed Surface Queue Guard, PR-body guards, Plan Test Core shards, Auto Label, filter. NOT yet concluded (in progress): Type Check UI, Lint UI, Test Core (1/8), Test UI shards 1–12 and 14–16. Skipped by path filter or opt-in: UI Published Artifacts, Dogfood Verify CLI, the dogfood matrix shards, Temporal Conformance, the schedule gate, the two opt-in harnesses. No check-run has failed. Check PR Size is success although the diff is over the 3,000-line human-merge threshold — see ③.

① Derived judgments

Public surface of @object-ui/plugin-detail (entry src/index.tsx, published through exports["."]), each change against the changeset 27524-record-page-tiers-activity-panel.md:

  • Eight exports leave the entry — RECORD_PAGE_THREE_COLUMN_MIN_WIDTH, recordPageArrangementFor, recordPageColumnDefaults, recordTimelineDefaultReason, and the types RecordPageColumnSwitches, RecordPageArrangement, RecordPageObjectLike, RecordTimelineDefaultReason — right: Target 5 retires the timeline column, its ⊞/⊟ promotion and recordTimelineDefaultReason; Target 6 retires the three-column tier and the rail those helpers arranged. Each is named in the ADR-0087 marker and has a FROM → TO line. The entry's diff removes exactly these and nothing else (checked on the head's index.tsx; the gate judges the claim as made, not its completeness — its header says so — so the completeness was checked here by reading: no other export left @object-ui/plugin-detail, @object-ui/components (panel-toggles.tsx keeps PanelToggles, PanelToggleItem, PanelTogglesProps; containers.tsx changes two private maps) or @object-ui/app-shell (RECORD_TIMELINE_COLUMN_PREFERENCE was never on that package's entry — index.ts on the base names no preference)).
  • Three members leave — DefaultPageColumns.relatedCount, UseRecordPageTierOptions.embedded, RecordPageColumnsProps.switches — right, named as dotted Type.member operands, each with its FROM → TO line (relatedCount → columns.related.length; embedded → no option, Target 6's one rule; switches → rightColumn: boolean).
  • RecordPageTier becomes 'stack' | 'two-column' | 'wide'; recordPageTierFor(width) loses its second argument; useRecordPageTier answers null (not 'tabs') when disabled or unmeasured; RecordPageColumnsProps.tier takes every tier — right, all four said in the BREAKING paragraph. The unknown-width answer (!(width > 0)) is 'stack', the narrowest tier — the honest default for a width that was never measured.
  • New exports RECORD_PAGE_WIDE_MIN_WIDTH (= 1120, the old constant's value under its new name), recordPageTierHasRightColumn, RECORD_PAGE_TIMELINE_PANEL = 'timeline' — right, named in the changeset's last line. 'timeline' can collide with no related list id, which is always object.field.
  • Widenings (not narrowings, correctly undeclared as such): RecordActivityTimelineProps.variant gains 'peek' and onViewAll; RichTextCommentInputProps.autoFocus; framelessSectionColumns(count, cap = 3) keeps its one-argument call. RecordTimelinePanel, RecordTimelinePanelContext, DetailsColumnCapContext are not exported from the entry (grep on the head), so no surface judgment is owed on them.
  • buildDefaultTabs now writes the stream tab's label as Activity stream (was Timeline) and places it second for every object — a change to the synthesized page a Studio seed stores. Declared in the changeset's "Tab labels" bullet and the PR body; the strip reads the token back as detail.timeline (「动态」) and an authored Timeline as console.objectView.viewTypeTimeline (「时间线」), which exists in all ten packs (checked). Right, and the body's own risk line says what a hand-written Timeline tab now reads.

Stored shapes (app-shell workspacePreference.ts): record.columns.related keeps its name and codec ({"value":"on"|"off","v":1}), so a reader's earlier related-column choice becomes the ⊟ choice — right and declared. record.columns.timeline is no longer read; its rows stay in the store unread — declared in the body ("no longer read"), harmless. record.related.arrangement is untouched.

@object-ui/i18n (changeset 27524-record-page-activity-i18n.md): detail.timelineFolded and console.shortcuts.toggleTimeline leave all ten packs (grep: zero hits on the head); detail.timelineComposer arrives in all ten; detail.timeline, detail.relatedColumn and console.shortcuts.toggleRelated change value in all ten — right, each named. The BREAKING paragraph names the package, says "TypeScript only", names both keys and says a t() call naming one still compiles and reads the key back — the shape the ① review of this card asked for and got. TranslationKeys is named in the marker under runtime-interface-only ui/packages/i18n/src/locales/en.ts#TranslationKeys, as PR ① did.

ADR-0087 markers against scripts/check-adr-0087-registration.mjs, by reading: both changesets carry a **BREAKING** banner, a ! summary and the (narrowing) arm — three of the gate's four BREAKING signals — so each owes exactly one marker and has one. runtime-export-retired (#22918): predicate 1 (each operand left the published entry between base and head — the eleven above do), predicate 2 (no metadata surface names them — git grep over packages/spec/** and *.zod.ts on the head: nothing), predicate 3 (the FROM → TO block names exactly the named operands — eleven FROM lines, eleven operands, no extra). runtime-interface-only on TranslationKeys: an exported type alias of an as const object, not a Zod projection, not under packages/spec, unreferenced by any metadata surface. Check Changeset and Lint & Repo Gates are success on the head, which is the gate's own verdict on the same claims.

Accept-set changes inside the tree, each judged:

  • recordPageTierFor: wide ≥ 1120, two-column ≥ 760, stack below and for an unknown width; no embedded arm — right (Target 6, one rule by the container's own width; the 1440-with-sidebar and 1024-with-sidebar derivations keep the two constants unchanged, Record page: the related switch decides whether the column exists, the panel toggles move into the header action row, and a 1440 px window with the sidebar is three columns #27241/fix(plugin-detail,app-shell,components): record page round 2 — related OFF draws no column or rail, one segmented panel control in the header row, three columns in a 1440 window with the sidebar #27473).
  • useRecordPageTier: measures whenever enabled, regardless of embedding — right; RecordDetailView no longer passes embedded to it, so the split pane, the overlay drawer and the maximized pane take the width rule.
  • RecordPageColumns: drawsPanels = hasPanels && (!besideDetails || rightColumn) — the stack ignores ⊟ and always draws its panels after the details; the two column tiers read it — right (Target 4: ⊟ applies only where a right column exists). data-record-columns-shown is details right or details; data-record-page-tier names the tier.
  • DetailsColumnCapContext: 2 only when sideBySide && !wide, else 3 — right (Target 6: three field columns at ≥ 1120, two beside the 320 px column). In the stack the cap stays 3 and the section's own container queries (@sm:grid-cols-2 @lg:grid-cols-3) decide, so a 390 px phone draws one or two columns by width — holds.
  • record-activity.tsx in the peek: roots only (a reply whose parent is in the stream is not a line), the newest entries, the chip forced to all, hasMore: false; the count reported is the whole stream's size, null while loading or when a read was refused — right (Target 5: newest 3 / 5, 「查看全部 N 条」, a refused read is not "none"). streamShowAll becomes streamPages, so the 动态 tab pages limit at a time by "Load more" (data-stream-load-more) instead of one jump to everything — a change to the tab the card describes as "paging"; right.
  • RecordDetailView: feedConsumerOnScreen / historyConsumerOnScreen now read pageLaidOut (any tier) instead of the column tiers, so the feed, mention and audit reads go out at open at every width of a synthesized page — right: the 动态 panel is on screen in every tier. An authored page with tabs keeps pageShowsNodeOnOpen (recordOpenWaves-13010 keeps those cases).
  • containers.tsx KNOWN_LABEL_KEYS: 'Activity stream' → detail.timeline, Timeline → console.objectView.viewTypeTimeline; zh-TW dict gains 'Activity stream': '動態' — right; the pinned console approval-page case (servedPage, labelled Timeline) reads a timeline again.

② Semver level

  • Title carries !; PR body and both changesets carry Clause-②: yes (narrowing) and Metadata-protocol: none. Correct: exports, members and i18n keys leave published packages (a narrowing) while the same diff adds exports, a variant and a key (a widening) — yes (narrowing) is the arm the gate's own battery names for "a diff that widens AND narrows". No metadata key is added, removed or re-meant; packages/spec/** is untouched (the card's ⛔).
  • Levels: patch for @object-ui/plugin-detail, @object-ui/components, @object-ui/app-shell and patch for @object-ui/i18n. Per root AGENTS.md Post-Task Checklist step 3 the level follows the Metadata-protocol kind alone: none takes patch, and "a non-protocol change, widening or breaking, ships patch and keeps its BREAKING banner and ADR-0087 disposition". Both changesets keep the banner, the migration (FROM → TO per operand; the key pair with "nothing replaces them") and one marker each. check-changeset-no-major and check-adr-0087-registration run under Check Changeset — success on the head. No CHANGELOG edit (release-owned). Skills-docs: content — content/docs/ui/pages.mdx is rewritten (prose face, not reviewed here); a grep of skills/** on the head for the tier names, the switch and the stream names nothing — the PR body's claim holds.
  • One package not in either changeset: @object-ui/plugin-view is unchanged (read, not touched). examples/app-showcase publishes nothing. Holds.

No finding in ②.

③ Boundary flags

PR user.login is objectstack-fleet[bot] — no relay note owed.

Dev report 6523663352 — open_questions: []; seven deviations, each judged:

  1. Over the human-merge threshold (3,306 changed lines; AGENTS.md §7(c)) — escalated, standing: this record's PASS or FAIL does not land the PR. The seat routes an authorized APPROVED review (GOVERNED_APPROVERS) or the maintainer's own merge; Check PR Size is success on the head, so that gate measures something else and the threshold is enforced by the seat's routing alone. The body states the threshold, the reason it was not split (Target 5 and 6 share the timeline column's retirement) and the path; both hold against the tree (about 40% of the diff is retired code and rewritten pins, counted by the stat).
  2. File surface widened by the seat to examples/app-showcase/e2e/detail-shapes.spec.ts, the Contoso case only — answered and checked: the hunk changes the docblock, the one test's title, its comment and its assertions (the stack tier, data-record-columns-shown="details", no Details / Related tab, Invoices and Projects as body tables in [data-record-column="details"], the right column's first child data-record-timeline-panel, the Contacts panel scrolled into view). No fixture, seed, data or harness line changes: test-only. The rest of examples/** is untouched (file list).
  3. Restart from main after ①'s squash merge, one conflict in ui/e2e/live/record-history-display.spec.ts resolved to the ?related=timeline form — answered: the head's spec opens the 动态 tab by URL and reads [data-related-enlarged="timeline"] [data-stream-filter="changes"], which is what the tree draws (EnlargedFace id timeline, the stream variant's chips).
  4. Not merged with the newer main (ten commits; git merge-tree clean, shared paths the locale packs at different keys) — answered: Build & E2E UI, Live E2E UI and the lint families ran on the merge ref and are success; Type Check UI and most Test UI shards have not concluded at read time, which the record reports rather than presumes.
  5. Screenshots on examples/app-crm, not HotCRM; taken on c888a82d32 before the restart — answered by ruling B (6520722066, quoted in 6520776586: the seat verifies and accepts; the epic seat re-checks the end state on main and files cards). The record page code is unchanged by the restart (the five commits were cherry-picked; the head's diff is the one judged here). Acceptance 3's cadence question from 6513066595 is closed by the same ruling.
  6. Clause-②: yes (narrowing), eleven + two operands, title ! — answered, see ① and ②.
  7. Part of #27524 (② of 4; ④ closes) — answered: PR-body guards (… Part-of …) success on the head.

Source re-measured by the dev (the card's record.timeline.* keys do not exist; the record page's labels are detail.timeline, detail.timelineFolded, console.shortcuts.toggleTimeline) — answered: checked on the base packs; the card's intent (ids kept, values changed) holds for detail.timeline, and the two retired keys named no surviving control.

Out-of-scope finding (dogfood red api-action-door-forward, #28082) — main's own, not this diff's; Dogfood Regression Gate is success on the head.

④ Design judgment

Scope files, each against the code they cite and Target items 4, 5 and 6 (1, 2, 10–12 landed in ①; 7–9 are ③ and ④; the interim faces — ⊟ at the end of the header's action row, the 动态 tab as ?related=timeline — are the split plan's and hold in the tree).

synth/recordPageColumns.ts, useRecordPageTier.ts, RecordPageColumns.tsx (Target 6). The tier rule, the two grid templates (minmax(0,1fr)_20rem = 320 px, minmax(0,1fr)_23.75rem = 380 px), the stack's one column with the aside after the details, the cap context, the 动态 panel first in the aside, then RelatedCardStack under RelatedListCompactContext. The rail, its toggle, FoldedTimelineCard and the resident timeline column are gone (grep on the head: no record-related-rail-toggle, data-record-folded-timeline, 'three-column' tier or relatedRail outside the page-template vocabulary, which is a different 'three-column'). Tried to break it:

  • ResizeObserver loop. judge sets state only when the tier flips, and the measured element is the view root (h-full overflow-hidden flex flex-col), whose width is its parent's; the scroller (flex-1 overflow-auto) is INSIDE the root, so a scrollbar that appears when the stack grows taller cannot narrow the measured width and feed back into the tier. No loop. (Checked on the head's RecordDetailView.tsx.)
  • Width read before mount. The root div is always rendered (pageTree), its callback ref lands before the layout effect, the first reading is taken in the layout effect, and the body is held (pageColumns && recordPageTier ? … : pageColumns ? null : …) for that one unpainted commit. On a remount measured.element !== element yields null for one commit again. Holds.
  • Stale tier on pane drag. The observer is on the root, which fills the pane; a drag changes the pane's width, the root's width, and the observer fires; recordDrawerStack.tsx keys the level by depth:objectName, so a sibling step keeps the element and the observer. Holds.
  • The embedded record and the phone. embedded is no longer passed to the hook (the prop survives for the breadcrumb, the refresh and the page-name reads, correctly). The pins: 1440 and 1200 wide, 1100 two-column, 720 and 390 stack, embedded 1440 wide and 720 stack, ?tab= addresses no strip, ?related=timeline draws the full stream (recordPageTiers-26678, rewritten not deleted). Holds.
  • Enlarged face in the stack. enlargedRelated draws over the stack too; [...columns.related, ...columns.details] still finds body tables. Holds.
    One cosmetic note, not flagged: the aside is named 「右栏」/"Right column" in the stack as well, where it sits below the details; the Contoso e2e and accessibleNames-12625 pin the name, so a later rename is a deliberate change, not drift.

RecordTimelinePanel.tsx, renderers/record-activity.tsx, RecordActivityTimeline.tsx, recordTimelineStream.ts, RichTextCommentInput.tsx (Target 5). The panel's header has a related panel's shape (title, count chip, the row as one stretched button opening the tab, › on hover; no chevron, no ⤢); the body is the page's own record:activity node under RecordTimelinePanelContext, so the peek reads the same feed, mentions and audit rows the tab does — nothing is read twice when the tab opens (feedConsumerShown / historyConsumerShown latch for the view's life). The one-line composer opens the full composer focused (autoFocus on mount only, with the eslint note) and folds after a sent note (setComposerOpen(false) in handleAddComment's success path only — a rejected send keeps the draft, as the surrounding comment rules). peekLine renders what · when with the actor in the title. 「查看全部 N 条 →」 only when total > rootItems.length. Tried to break it:

  • Count across records. The host's feedLoading = feedFetchKey !== null && settledFeedKey !== feedFetchKey flips true on a record change, so peekCount goes null (chip hidden) and returns with the new record's total; no stale count survives a sibling step. Holds.
  • Draft across records. commentText and composerOpen live in the RecordActivityTimeline instance, which the drawer keeps across a sibling step; a half-written note on record A stays open on record B. The same was true of the full composer on main (the tabbed drawer kept the same instance) — pre-existing in kind, not introduced here; noted, not flagged.
  • Replies. The peek's root filter uses the whole stream (all.items), the tab's nesting uses the loaded page — so a reply whose parent is on a later page is a row in the tab and no line in the peek; and 「查看全部 N 条」 can appear when the only entries not drawn are replies already nested in the tab. Both are the design's own reading of "a reply is not a line"; noted, not flagged.
  • Pinned by RecordTimelinePanel-27524 (seven cases against the real renderer, including the CONTROL that the same block outside the panel is the full stream).

RecordDetailView.tsx, panel-toggles.tsx, workspacePreference.ts (Target 4). ⊟ is one icon-only toggle (h-8 w-8, aria-label = 「右栏」, tooltip with ⌘⇧,), pushed only when pageColumns && pageHasRightColumn && (timeline.length > 0 || related.length > 0); drawnPanelSwitch gates the chord so it never flips a switch the reader cannot see; ⌘⇧. and PANEL_CHORD_KEYS['.'] are gone; RECORD_TIMELINE_COLUMN_PREFERENCE is deleted and RECORD_RELATED_COLUMN_PREFERENCE keeps its stored name. Pinned by panelToggles-27052 (nine cases, including "not drawn in the one column" and "drawn for the embedded record at 1440 px"). Tried to break it — must-fix (④-1): the ⌘⇧, chord leaks across faces into another record's stored preference. The listener is document.addEventListener('keydown', …), installed by every mounted RecordDetailView whose tier has a right column. On main the embedded view was always 'tabs', so at most one view listened: the page's. This PR makes the embedded view take the width rule, and a record page is the host of the drawer its related rows and lookups open over it (RecordPageDrawerHost renders {pageTree} as its children with the drawer — a Radix Sheet — above; both views stay mounted). The drawer opens at min(92vw, 720px) (overlayWidthFor('md', …), the stack), but it is a wider pane once the reader drags its handle (storageKey, remembered per object, up to DRAWER_MAX_VW = 60 vw — 864 px at 1440) or when the object's own overlay size is larger; at ≥ 760 px the drawer's view has a right column, draws its own ⊟ and installs its own listener. A modal Sheet hides the page from pointer and accessibility, not from document key events. So one ⌘⇧, in the drawer runs both handlers: the drawer's flips its object's switch (visible), and the page's flips record.columns.related for the PAGE's object (invisible, and stored). The reader closes the drawer and the page's right column is gone — for that object, until they find ⊟ again; for the same object on both faces, the two writes race on one key. Target 4 puts ⊟ on each face for that face's column; a chord that also rewrites another record's stored choice is a leak across records of exactly the kind this review is asked to find. Fix, small: let only the topmost record view own the chord — the page host knows a drawer is open (RecordPageDrawerHost's root !== null, or the view's root pageRootEl having an open [role="dialog"] [data-record-page-root] above it in the document) and yields while it is; and pin "⌘⇧, in a 1000 px drawer over a 1440 px page flips the drawer's switch alone". useAdvertiseShortcut is unaffected (one row per id however many holders).

RecordPageColumns.tsx — EnlargedFace inside a pane — should-fix (④-2). The Esc handler keeps the old guard if (document.querySelector('[role="dialog"], [role="alertdialog"], [role="menu"]')) return;, written when the enlarged face existed only on the page. The split pane and the overlay drawer are a Radix Sheet (SheetContent, role="dialog"), so inside a pane the guard always finds the pane itself and the face never closes on Esc; the Sheet's onEscapeKeyDown → handleEscape then pops a level or closes the pane, and the reader who pressed Esc to leave the 动态 tab loses the record instead. The pane's enlarged faces (the 动态 tab, a related list's whole face) are reachable only since this PR. ‹ still returns, so this is not a dead end. Fix: exclude the dialog that contains the face (sectionRef.current?.closest('[role="dialog"]')) from the guard, and preventDefault when the face takes the key so the Sheet's layer does not dismiss on it; pin "Esc on the 动态 tab in the pane returns to the pane's page". (Target 9's "Esc or 概览 returns" is ③'s wording; the face itself is this PR's.)

containers.tsx, synth/buildDefaultPageSchema.ts, useDetailTranslation.ts, the ten packs. The strip's token split is sound: one(token) tries the exact-locale dict, then KNOWN_LABEL_KEYS through tt(key, token) and treats an echo of the token as "no translation", so Activity stream → detail.timeline → "Activity" in en and 「动态」 in zh, and Timeline → console.objectView.viewTypeTimeline → "Timeline" (en: the echo is the right answer, and translateLabel falls back to the text itself) / 「时间线」 (zh). buildDefaultTabs places the stream tab second for every object with no recordTimelineDefaultReason read (the import is gone), and the page:tabs items keep value: 'timeline', so recordPageColumnOfTab and ?tab=timeline are unchanged. DETAIL_DEFAULT_TRANSLATIONS drops detail.timelineFolded, adds detail.timelineComposer, keeps detail.timeline as "Activity"; every new call site passes a defaultValue. The zh strings match the card's words (「动态」, 「右栏」, 「查看全部 N 条」). Holds.

DetailSection.tsx, defaultRecordPage.ts. The cap is read from context and passed as the second argument; framelessSectionColumns(n, 2) returns 2 for six and more fields and leaves 1 and 2 alone; the default keeps the #27055 rule. recordPageV3Face-27055 calls it with the count alone and pins the cap (the second commit of the PR fixed a test that mapped it over Array.map, which would have handed the index as the cap — caught by the dev's own type-check). Holds.

index.tsx. Reads as ①. Holds.

Tests updated, not deleted (Acceptance 4). git diff --diff-filter=D is empty. #26678 (recordPageTiers, synth/recordPageColumns.test), #27052 (panelToggles, both switches-27052 files, workspacePreference.*), #27055 (recordPageV3Face, RelatedCardStack, recordTimelineStream, buildDefaultPageSchema.*), #27473 (the 760 px floor, pinned in synth/recordPageColumns.test as RECORD_PAGE_TWO_COLUMN_MIN_WIDTH = 760 and recordPageTierFor(760)), #26679 (the drawer-stack tests are untouched and still describe the drawer; recordDrawerStack.tsx is unchanged) — each collapse / rail / three-column / tabbed case becomes its two-column / stack / 动态-panel twin; nothing a ruling still owns was dropped. The app-shell pins that opened the tabbed tier now read the laid-out page (approvalLock-*, relatedBadgeArity-8882, relatedListFilter-4664, recordOpenWaves-13010, accessibleNames-12625 and the rest named in the body) — read, consistent with the tree. docs/qa/platform-checklist/areas/records-forms.json: three steps and one verify re-point the Changes chip at the 动态 tab (?related=timeline) and say the audit read goes out at open at every width — true of the tree. ui/e2e/live/record-history-display.spec.ts: test-only, as ③-3. The plugin-detail README describes the new layout (prose beside the code; read, not reviewed).

Findings: must-fix 1 (④-1); should-fix 1 (④-2).

Implemented-by: claude/project-thread-f9tvxt
Reviewed-by: session_01Utp6CY2n1JmZZKrHgtp9jC

VERDICT: FAIL


Generated by Claude Code

…returns to the record; ⌘⇧, is the topmost record view's

Inside the record pane the 动态 tab and a related list's whole face never
closed on Esc: the face's guard found the pane's own dialog, and the pane's
Esc (the drawer's dialog layer, the split pane's document listener) closed or
stepped the pane instead. The face now listens on the window in the capture
phase and takes the key unless a text field, an open menu or alert, or a
dialog the face is not inside owns it.

A drawer wide enough for its own right column draws its own ⊟, and the page
under it keeps its tree mounted, so one ⌘⇧, ran both views' listeners and
the page's ran first: it flipped the hidden page's stored switch, not the
drawer's. Only the topmost record root, the last in document order, answers
the chord now.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Preview review (proxy for epic:#26676#1) · a38376b · PASS

A context-free review agent ran this per the #26676 ruling B (6520722066). It worked on this head's live console (Vite dev server from source) on examples/app-crm, zh, read 2026-10-30T13:39Z. Scope: Target 4, 5 and 6, plus a regression pass on Target 1, 2 and 10 (PR ①), at 1440, 1200, 1000, 720 (a window, and the pane opened from a list in a 1440 window) and 390 (a phone). It ran in parallel with the contract review 6523984004. That review's two findings are pane keys, ④-1 and ④-2. Note 1 below is ④-2 seen live. The fix head gets a re-check of those two keys, posted separately.

Target 4, ⊟ 「右栏」.

  • One icon toggle, aria-label 右栏 and aria-pressed, with the tooltip 「右栏 ⌘⇧,」. It is the last button of the header's action row.
  • Off, the right column goes and the details take the row (684 → 1088 px). The choice holds across a reload (record.columns.related = {"value":"off","v":1}) and is per object.
  • Ctrl+Shift+, flips it both ways. Ctrl+Shift+. does nothing, and a stale record.columns.timeline key is ignored. There is no segmented 时间线 | 关联 control.
  • It is absent in every stack face (the 720 window, 390, the 720 pane), and the stack draws the panels even with off stored.

Target 5, 动态 as a panel and a tab.

  • The panel is the first child of <aside aria-label="右栏"> in every tier. Its header is 「动态 N」 with the related panel's anatomy: › on hover, the title 「查看全部 N 条」, and the whole row as the button.
  • The one-line composer 「写一条备注… @ 提到同事」 opens the full composer, focused, and @ opens the mention list. Ctrl+Enter sends and folds the composer back; the count went 1 → 2 → 3 → 4, newest first, each line icon · text · time.
  • The panel draws 5 lines when wide (1440, 1200) and 3 otherwise (1000, 720, 390, the pane). A reply is not a line. 「查看全部 27 条 →」 shows only when the stream holds more than it draws.
  • 「查看全部」, the header row and a direct URL all open ?related=timeline. There:
    • the chips are 全部 · 活动 · 讨论 · 变更 (aria-pressed), at 20 per page; 加载更多 adds a page (20 → 27, then the button goes);
    • 讨论 is the comments only, and 变更 the three field edits;
    • a reply threads under its parent once the parent's page is loaded.
  • Esc returns to the record on the page and at 390, and so does browser back. There is no ⊞ promotion control. In en: Activity / Right column / View all 27 → / Write a note….

Target 6, one rule by the container's width (the view root is measured, 48 px wider than the tier element):

Window Root Tier Right column Field grid 动态 lines
1440 1184 wide 380 3 5
1200 (sidebar as icon rail) 1152 wide 380 3 5
1000 / 1024 952 / 976 two-column 320 2 3
720 688 stack — — 3
720 pane in 1440 671 stack — — 3
390 366 stack — — 3

In stack the order is the details, then 动态, then the panels in order. There is no ⊟, no tab strip and no rail.

Regression, Target 1, 2 and 10.

  • The header is title then chip, with nothing reserved on the left. Not hovered, the controls and › are at opacity 0; hovered, › then ⠿ + ⋯. There is no chevron and no ⤢, and ⋯ holds 隐藏 only.
  • The chip press and a free-space press on the header open ?related=<id>. Esc and back return.
  • With ⠿ focused, ⌥↓ / ⌥↑ move the panel and keep focus on it. A drag draws the drop line and reorders.
  • 隐藏 shows the foot 「已隐藏 1 张 · 显示 · 恢复默认顺序」, and restore works.
  • Stored record.related.arrangement = {order, hidden, basedOn, v: 2}, with no collapsed.
  • On the opportunity, 「还有 2 个空关联 · 线索列表 · 商机列表 · 展开」; 展开 draws both, each with its own +.

Notes, none blocking.

  1. Esc on the 动态 tab inside the pane closes the pane instead of returning to the record (the pane's own 返回 works). This is the contract review's ④-2.
  2. The tab's header reads 「动态 (20)」 (the loaded count) while the panel says 27, until 加载更多.
  3. At a 1200 window the console folds its sidebar to the icon rail, so the page is wide. The board draws two columns there; the Target's container rule is what applies.
  4. 活动 is empty on app-crm by design: its created / updated activity rows map to field changes, so they show under 变更.
  5. The expanded composer's placeholder differs from the one-line text (cosmetic).

Not verifiable live:

Screenshots, in project files (not a repository branch): /mnt/project-files/ui-seat2/card-27524/preview-review-pr2/shots/, 50 files.

  • Target 4: t4-*.
  • Target 5: t5-* and account-timeline-tab-390.
  • Target 6: account-record-populated-{1440,1200,1000,720,390}, opportunity-record-* and account-split-pane-*.
  • Regression: reg-*.

Generated by Claude Code

…he record pane stays open around it

The face let an Esc pressed in a text field go on to the field, unmarked, so
inside the record pane the drawer's dialog layer took it next and closed the
whole pane: Esc in the 动态 tab's composer dropped the reader on the list with
`?related=timeline` still in the URL. A field in the face still gets the key
(it closes its mention list, cancels an inline edit), and the face marks it
handled, so the drawer does not dismiss on it and the face stays open. A field
outside the face, and an open floating layer (a select, a popover), keep the
key unmarked as before.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Preview review (proxy for epic:#26676#1) · 90828f1 · PASS

Delta re-check of the two pane keys from the contract review 6523984004, ④-1 and ④-2. The same context-free review agent as 6524207275 ran it on this head's live console (Vite dev server from source), on examples/app-crm, zh, at 1440 and 720 windows, read 2026-10-30T14:23Z. The rest of 6524207275 stands: since a38376b7be only RecordPageColumns.tsx (the face's Esc) and RecordDetailView.tsx (the chord) changed, plus their pins.

An intermediate head, d98543cb28 (never pushed), passed key 2. Key 1 went REWORK on one sub-case there: Esc in the 动态 composer inside the pane closed the whole pane, and ?related=timeline stayed on the list URL. 90828f16cb fixes that. This re-check is on 90828f16cb.

Key 1 (④-2), Esc on a face in the pane: PASS. The pane is opened from the list (Acme Corp), at both widths.

  • 动态 header, then click the composer, type a character, Esc: the pane and the 动态 face both stay. The URL keeps ?recordId=…&related=timeline, and focus stays in the textarea.
  • Focus on the face's 返回, then Esc: the face closes and the pane stays on the record (?recordId=…). The next Esc closes the pane, and the URL is /crm_account with no stale parameter.
  • With the composer's @ mention list open, Esc closes the list only. The face, the pane and the URL hold.
  • On the related list's face (?related=crm_activity.account), the first Esc closes the face and the second closes the pane.
  • With a ⋯ menu open in the pane, Esc closes the menu only and focus returns to ⋯. The next Esc closes the pane.
  • On the full page at 1440:
    • Esc in the 动态 composer keeps the face.
    • Esc on 返回 closes it.
    • Esc closes the related face.
    • With a ⋯ menu open, Esc closes the menu first and leaves the URL alone.

Key 2 (④-1), ⌘⇧, is the topmost record view's: PASS.

  • The Acme Corp page is at 1440 (wide, ⊟ pressed). A related row opens a 720 px drawer, which is stack with no ⊟.
  • Dragged to 864 px, the drawer is two-column with its own ⊟.
  • Ctrl+Shift+, flips the drawer's switch alone: record.columns.related@object:crm_activity turns off. The page keeps ⊟ pressed and its right column, and no @object:crm_account key is written.
  • A second press restores the drawer's switch.
  • With the drawer closed, the chord flips the page and back.
  • In a 720 px drawer (no ⊟), the chord changes nothing in the drawer or the page, and stores nothing.

Nothing was skipped. The maximized pane is still not reachable in this console (no ⤢ yet, #27545), as in 6524207275.

Screenshots are in project files, not a repository branch: /mnt/project-files/ui-seat2/card-27524/preview-review-pr2/shots/r3-*, 13 files.

  • Key 1: r3-pane-esc-in-composer-{1440,720}, r3-pane-after-face-esc-{1440,720}, r3-pane-mention-{open,after-esc}-{1440,720} and r3-page-esc-in-composer-1440.
  • Key 2: r3-drawer-wide-1440, r3-drawer-wide-chord-1440, r3-page-after-drawer-closed-1440 and r3-drawer-narrow-chord-1440.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Contract-review-claim: session_01Utp6CY2n1JmZZKrHgtp9jC · head 90828f1 · 2026-10-30T14:24Z

Delta review of the findings commits (d98543cb28, 90828f16cb) after record 6523984004 (FAIL on a38376b7be), by the same at-tier reviewer.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 90828f16cb5d4e3a4a8cc5c8f2fe30c7fd8dba72
Local-runs: none
Scope: ui/packages/app-shell/src/views/RecordDetailView.tsx, ui/packages/app-shell/src/views/RecordDetailView.panelToggles-27052.test.tsx, ui/packages/plugin-detail/src/RecordPageColumns.tsx, ui/packages/plugin-detail/src/tests/RecordPageColumns.enlargedEscapeInPane-27524.test.tsx + ui/packages/components/src/custom/navigation-overlay.tsx, ui/packages/app-shell/src/views/recordDrawerStack.tsx, ui/packages/plugin-detail/src/RichTextCommentInput.tsx, ui/packages/plugin-detail/src/InlineEditSaveBar.tsx, ui/packages/components/src/renderers/complex/data-table.tsx

Delta review after record 6523984004 (FAIL on a38376b7be): the diff a38376b7be..90828f16cb, two commits (d98543cb28 the two findings, 90828f16cb a field's own Esc inside the face), four files, +262 / −13. The rest of the PR's tree is unchanged from the head that record judged and is not re-judged here; its ① / ② / ③ carry over except where this delta touches them.

Check-runs on 90828f16cb at read time: success — Check Changeset, Governed Surface Queue Guard, PR-body guards (claim · duplicate · Part-of · single-writer path), Check PR Size, Check Documentation Links, Spec property liveness, Build UI Deps, Live E2E UI, Dogfood Regression Gate, Plan Test Core shards, Auto Label, filter. NOT yet concluded (in progress): Test UI shards 1–16, Test Core shards 1–5, Build & E2E UI, Build Core, Build Docs, Lint UI, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · PM dispatch-gates, the three scoped lints, Type Check UI, Type Check · workspace, Type Check · consumer gates, Type Check · source gates, Type Check · debt ledger. Skipped by path filter or opt-in: UI Published Artifacts, Dogfood Verify CLI, the dogfood matrix shards, Temporal Conformance, the schedule gate, the two opt-in harnesses. No run on this head has failed; no run on the previous head failed either. The record reports the tree, not a verdict the gates have not yet given.

① Derived judgments

Each finding of record 6523984004, checked in the tree (not the body):

  • ④-1 (must-fix) — fixed. RecordDetailView.tsx: isTopmostRecordView(root) answers whether root is the LAST [data-record-page-root] in its document; the chord handler returns before preventDefault unless it is, and the effect re-subscribes on pageRootEl. The drawer's sheet is a Radix portal appended to document.body after the app's root, so with a drawer open the drawer's record root is last and the page's handler yields; the page's own ⊟ and the drawer's are each still drawn where their tier has a right column. The leak named in the record — one chord writing record.columns.related for the page's object under the drawer — is closed. Pinned: a 1440 px page under a 1000 px role="dialog" root; the chord flips the drawer's (product) key to off, the page stays details right and the lead's key stays unwritten; after the drawer unmounts the same chord flips the page. Right.
  • ④-2 (should-fix) — fixed. RecordPageColumns.tsx: EnlargedFace keeps a faceRef, listens on window in the capture phase, and yields to a layer over it (escapeOwnedByLayer: any alertdialog, menu or Radix popper content, or a dialog that does not contain the face). The dialog that holds the face — the pane's sheet — no longer blocks it; the face preventDefaults and stopPropagations, so the pane's own Esc (the sheet's dialog layer on the document in capture, the split pane's document listener in bubble, both AFTER a window-capture listener) never sees the key; with no face open the pane's Esc works as before. Pinned against the real NavigationOverlay: the drawer (face closes, setIsOpen not called; the control Esc dismisses), a level below the root (onBack not called, then called), the split pane beside a list (close not called, then called), an open menu keeps the face. Right.
  • 90828f16cb. A field inside the face (isTypingTarget: input, textarea, select, contenteditable, role="textbox") keeps its Esc: the face does not close, calls preventDefault only (no stopPropagation) when the field is inside the face, and returns. The key reaches the field's own handler (React's root listener and the element's listeners run after window capture; preventDefault does not stop them), so the composer's mention list closes (RichTextCommentInput reads e.key === 'Escape' without a defaultPrevented check) and a table cell editor cancels (handleEditKeyDown, the same). The pane's two Esc paths then read defaultPrevented: useSplitEscape returns on it (and escapeOwnedByTarget would have too), and the sheet's layer does not dismiss on a prevented event at the stack's root. Pinned: an input and a textarea inside the face in the drawer and the split pane; the field's listener fires once, the face and the pane stay. Right — with one reach into navigation-overlay.tsx that is not this PR's, see ③.

No accept-set or public-surface change: the delta touches no export, prop type, stored shape or i18n pack. isTopmostRecordView, escapeOwnedByLayer and plugin-detail's isTypingTarget are module-private.

② Semver level

Unchanged from record 6523984004: patch ×4, Clause-②: yes (narrowing), Metadata-protocol: none, title with !, both BREAKING banners and both ADR-0087 markers untouched (.changeset/** is not in the delta). Check Changeset is success on this head. Holds.

③ Boundary flags

No new dev flag arrived with the delta. Carried from record 6523984004 and still standing, not decided here: the human-merge path for a diff over 3,000 lines (an authorized APPROVED review or the maintainer's merge; this record alone lands nothing).

The dev's out-of-scope finding is real, and pre-existing. navigation-overlay.tsx passes onEscapeKeyDown={maximized || onBack ? (e) => { e.preventDefault(); handleEscape(); } : undefined} to the sheet's dialog layer. By Radix's dismissable-layer contract the onEscapeKeyDown prop is called for every Escape the layer sees (the layer then dismisses only when the event is not defaultPrevented); that prop is this handler, and it reads nothing before acting. So in a maximized pane, or at any level below the stack's root, an Esc that a field already marked handled still restores the pane or pops a level: on main today, an Esc typed in the composer of a level-2 drawer pops the level under the reader's hands; after this PR, the same Esc typed in the 动态 composer of a level-2 or maximized pane's face closes the mention list AND pops / restores the pane, which 90828f16cb prevents only at the root (the new pins cover the root drawer and the root split pane; no pin has onBack with a field). The fix is one line in that arrow — return when e.defaultPrevented — on #27545's surface, which also owns the pane's maximize and its Esc semantics (Target 7's ✕ rule). Escalated to the seat for a card or for #27545's PR; not a finding on this PR, whose own listener does its part (preventDefault inside the face). The Radix package is not installed in this container, so the contract is read from its documented behaviour, not from the shipped source.

PR user.login is objectstack-fleet[bot] — no relay note owed.

④ Design judgment

RecordDetailView.tsx — the chord's owner. Tried to break it:

  • Two record roots and no drawer. The rule is DOM order, not nesting: whichever record root is last in the document owns the chord, and the other still draws ⊟ (click works) but ignores the key. In the console the only way two roots are mounted is the page under its drawer (RecordPageDrawerHost), where DOM order is the right answer; a split pane sits beside a list, not a record, and the drawer stack renders one level (current ? renderRecord(current) : null), so no second face appears. Two side-by-side embedded records would have one silent chord — deterministic, and not a case the console draws today. Noted, not flagged.
  • The drawer closing while the listener changes hands. The page decides per keydown (querySelectorAll at event time), so the hand-over needs no effect or ordering: the first chord after the drawer's root leaves the DOM is the page's (pinned). One sub-second window: a Radix sheet keeps its content mounted through its exit animation, so a chord pressed during the close animation still finds the drawer's root last and flips the closing drawer's stored switch rather than the page's. A race of a few hundred milliseconds on a preference the reader can see on the next open; noted, not flagged.
  • Dependencies. pageRootEl is in the effect's dependency list, so a remount of the root re-subscribes with the new element; drawnPanelSwitch keeps its role (a chord never flips a switch the reader cannot see); isTypingTarget and isComposing are unchanged. useAdvertiseShortcut is unaffected. Holds.

RecordPageColumns.tsx — the face's Esc. Tried to break it:

  • A capture-phase Esc swallowing a key meant for an open menu, popover, select, tooltip or alert. escapeOwnedByLayer yields on [role="menu"], [role="alertdialog"], [data-radix-popper-content-wrapper] (every Radix popover, select, dropdown, combobox and tooltip content) and on any [role="dialog"] the face is not inside (a confirm, a second drawer over the page, the inspector), so those layers take their Esc first exactly as before. Two consequences, neither a defect: a tooltip showing (the reader hovering ⊟ or ⠿) makes the first Esc the tooltip's and the second the face's; and a dialog in its closing animation (data-state="closed", still mounted) blocks the face's Esc for that animation. InlineEditSaveBar qualifies its dialog test with [data-state="open"]; the face could do the same for tightness. Should-fix? No — the earlier guard had the same shape, and the yield errs toward the layer. Noted.
  • An IME composition Esc. event.isComposing returns before anything, so the composition keeps its key and the face stays; the pane's own layers then see an unprevented Esc, which is the pre-existing behaviour at every depth. Holds for the face.
  • The split pane beside a list. Non-modal sheet (modal={!hasList}) plus useSplitEscape on the document in the bubble phase with defaultPrevented and escapeOwnedByTarget guards: a face Esc is stopped at window capture and never reaches it; a field's Esc is prevented and it returns. Pinned, both. Holds.
  • Other Esc listeners now starved. The non-field branch stops propagation at window capture, so no document or React handler sees that Esc: the save bar's cancel (InlineEditSaveBar, window bubble), the table's cell navigation (handleCellKeyDown), the sidebar rail. With a face open the details — and their inline edit — are not drawn, and a focused table cell that is not being edited loses only its Esc-to-navigation while the face closes under it; the cell editor, a typing target, keeps its cancel. Nothing a ruling owns is lost. Holds.
  • Return-to-pane. Esc on the 动态 tab in the pane now returns to the pane's page, keeping the record; ‹ unchanged. The face-first, pane-second ordering is what Target 9's "Esc or 概览 returns" (③'s wording) will stand on. Holds.

Tests. The chord pin renders a second real RecordDetailView into a role="dialog" container appended after the page, measures it at 1000 px through the same getBoundingClientRect stub, and asserts the stored keys of BOTH objects — the fact the must-fix turned on. The Esc pin drives the real NavigationOverlay (the Radix sheet's layer and the split listener) with the page's blocks stubbed, in drawer, below-root and split modes, with a menu, and with a field in the face in both pane modes. Holds; the uncovered case (a field inside the face below the root, or maximized) is the overlay's own, named in ③.

Findings: must-fix 0; should-fix 0.

Implemented-by: claude/project-thread-f9tvxt
Reviewed-by: session_01Utp6CY2n1JmZZKrHgtp9jC

VERDICT: PASS


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

席位核对

ACCEPT · head 90828f16cb5d4e3a4a8cc5c8f2fe30c7fd8dba72 · 2026-10-30T14:54Z

  1. Shape: draft → main, Part of #27524 (PR ② of four; ① = feat(plugin-detail)!: the record page's related panels lose collapse; the header opens the whole list and empty panels fold into one line #27920 merged), title carries !, attribution block on top, root changeset with the BREAKING banner and the ADR-0087 markers. 71 files, +2052 / −1519. No assignee.
  2. Card rule met (seat read the diff and both reviews):
  3. Gates (check-runs on this head): 56 success, 9 skipped, 0 red. Named runs: Lint & Repo Gates 124118401122, TypeScript Type Check 124119072932, Type Check UI 124116295680, Test UI 124123076085. Dev: 84/84 derived gates exit 0. Dogfood's one red, api-action-door-forward, is main's (main red (Dogfood Regression Gate 3/3) since 3e0fd7a8 (#27964): the api-action door dogfood pin still expects sys_metadata_activation in the disabled-action sentence that #27842 rewrote #28082); CI's Dogfood Regression Gate is success.
  4. Contract review: owed at CONTRACT_REVIEW_TIER (the narrowing face).
  5. Acceptance 3 (preview verification): ruled B (6520722066). The card thread's context-free proxy preview review is PASS 6524207275 on a38376b7be (Target 4–6 plus the regression pass on ①'s panels). The re-check of both keys on this head is PASS 6525118794. The surface is examples/app-crm.
  6. Landing: HUMAN MERGE OR AUTHORIZED APPROVAL. check-governed-merges --pr 28116: not path-governed, but 3571 changed lines > 3000. The seat does not flip it ready, enqueue it, or arm auto-merge. Review is requested from os-zhuang and hotlong now, and the PR carries needs-user-decision. After an APPROVED review, the owning seat (or the next ui seat 2 holder, named on the seat post) lands it through the queue; the maintainer's own merge is the other landing.

Generated by Claude Code

@os-zhuang
os-zhuang marked this pull request as ready for review October 30, 2026 15:18
@os-zhuang
os-zhuang enabled auto-merge October 30, 2026 15:18
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 41161426695 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test UI (2/16) — 失败步骤: Run the ui tests (shard 2/16)

    × a cold open sends one page request, no count probe and no separate part read, in one wave 471ms
    × a warm open of another record sends one page request too 440ms
    × asks the record a drawer step shows next ahead, so stepping to it sends nothing 392ms
    × in the v4 pane, both neighbours are asked ahead as pages, and an in-place step to either opens from that answer 300ms
    FAIL   dom  packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx > objectstack#27627 — one request opens a record page > a cold open sends one page request, no count probe an
      ↳ 失败原因: AssertionError: expected [ …(4) ] to deeply equal [ Array(1) ]
    FAIL   dom  packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx > objectstack#27627 — one request opens a record page > a warm open of another record sends one page request 
      ↳ 失败原因: AssertionError: expected [ …(4) ] to deeply equal [ Array(1) ]
    FAIL   dom  packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx > objectstack#27627 — one request opens a record page > asks the record a drawer step shows next ahead, so st
      ↳ 失败原因: AssertionError: expected [ …(3) ] to deeply equal []
    FAIL   dom  packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx > objectstack#27627 — one request opens a record page > in the v4 pane, both neighbours are asked ahead as pa
      ↳ 失败原因: AssertionError: expected [ …(2) ] to deeply equal []
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 2 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 18 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

Brings in #27713 (one request opens a record page) and #28193 (the 19th
flow value-ledger entry), both landed after this branch's base.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
@os-zhuang
os-zhuang added this pull request to the merge queue Oct 31, 2026
@os-zhuang
os-zhuang removed this pull request from the merge queue due to a manual request Oct 31, 2026
… own reads

#27713 landed a pin that a record open sends the page request alone. It
measured jsdom's unmeasured width, which drew the tabbed tier, where the
stream waited for its tab. This branch lays every width out with the 动态
panel first in the right column, so the stream is on screen at open and reads
its sources beside the page request, in the same wave: the record's comment
thread, its activity and, once per view, the mention list. On main the same
reads already went out at open wherever a column tier drew the stream.

The pin keeps every page-part assertion as it was and pins the stream's reads
on their own: which they are, that they share the page's wave, and that an
in-place step reads only the next record's two.

Claude-Session: https://claude.ai/code/session_01Utp6CaA9ZdTcj97mzGtHYt
Co-authored-by: Claude <noreply@anthropic.com>
@github-actions

Copy link
Copy Markdown
Contributor

⛔ merge queue 构建失败 — 先分诊,再决定要不要重排

队列构建 41163010887 红了。队列跑的是全量套件(PR 侧 CI 只跑 affected 子集),
所以失败的测试可能在本 PR 没碰过的包里 —— 那不是重排能修的。每次盲目重排都会让排在后面的所有 PR 重建一轮。

分类:failure —— 按下面的日志分诊。

失败的 job(日志抽取,best effort):

  • Test UI (2/16) — 失败步骤: Run the ui tests (shard 2/16)

    × a cold open sends one page request, no count probe and no separate part read, in one wave 779ms
    × a warm open of another record sends one page request too 875ms
    × asks the record a drawer step shows next ahead, so stepping to it sends nothing 528ms
    × in the v4 pane, both neighbours are asked ahead as pages, and an in-place step to either opens from that answer 485ms
    FAIL   dom  packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx > objectstack#27627 — one request opens a record page > a cold open sends one page request, no count probe an
      ↳ 失败原因: AssertionError: expected [ …(4) ] to deeply equal [ Array(1) ]
    FAIL   dom  packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx > objectstack#27627 — one request opens a record page > a warm open of another record sends one page request 
      ↳ 失败原因: AssertionError: expected [ …(4) ] to deeply equal [ Array(1) ]
    FAIL   dom  packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx > objectstack#27627 — one request opens a record page > asks the record a drawer step shows next ahead, so st
      ↳ 失败原因: AssertionError: expected [ …(3) ] to deeply equal []
    FAIL   dom  packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx > objectstack#27627 — one request opens a record page > in the v4 pane, both neighbours are asked ahead as pa
      ↳ 失败原因: AssertionError: expected [ …(2) ] to deeply equal []
    

↳ 失败原因 是判读的关键:超时(Test timed out in … / Hook timed out in …)多半是负载/时序,不是本 PR 的回归;
断言(AssertionError: …)才指向真实的行为改变。两者的 FAIL 行长得一模一样,只有这一行能区分。

⚠️ 断言这一侧有一类例外,判据是断言在测什么,不是它是不是 AssertionError。 断言的对象是产品行为(一个值、一个形状、一次拒收)⇒ 照上面读:真实的行为改变,去查,⛔ 不要重排掉;
断言的对象是这次实验自身的有效性前提(跑完的耗时、负载下的先后、任何只在时间预算内才成立的条件)⇒ 它跟超时是同一类,同样对负载敏感,重排一次是合法的判别手段。
识别是机械的:断言的消息或它比较的值本身点名了一段时长、一个时间戳、一个耗时计数。实测过的一对 —— AssertionError: SecurityPlugin.init() ran: expected false to be true 测的是产品行为(真回归);
AssertionError: this run took over a second, so second-precision stamps could have differed too: expected 1006 to be less than 1000 测的是实验前提:它守护的那条不变式当时是绿的,同一个 head 原样重排一次即成功。
穿着 AssertionError 外衣的时间测量,仍然是时间测量。(⛔ 这只改「怎么读一次红」,不改「哪些测试可以重排」——后者由别处管。)

跨 PR 相同签名(24h,按失败测试文件聚合):

  • packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx — 24h 窗口内只有本 PR 撞到过,暂不汇总(再有一个不同 PR 撞到就会自动开汇总 issue)。
  • ⚠️ 24h 评论账本没读完(超过 5 页仍未读到窗口尽头),所以上面的「不同 PR 数」是下界,不是全量。

历史信号:

  • ⚠️ 本 PR 过去 24h 已在队列失败 3 次(不含本次)。 内容未变而反复失败 ⇒ 高度怀疑 flaky 测试或与同组 PR 的语义冲突,重排不解决。
  • 过去 24h 队列共有 18 个失败构建(不含本次)。

分诊清单:

  1. 失败测试在本 PR 改动的包里 → 真回归,修 PR。
  2. 失败测试与本 PR 无关 → 看上面的「跨 PR 相同签名」;已有汇总 issue ⇒ flaky/环境问题实锤,去那张 issue 上谈,修好前重排只会再烧一轮全队列。
  3. 两者都不是 → 可能与同组 PR 语义冲突;等前面的 PR 落地或失败出队后再重排一次即可,不要连续重排。

Generated by Claude Code · merge-queue-triage workflow (#4859)

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

The second merge-queue red (run 41161426695, Test UI (2/16)) was this PR's, from a clash with #27713. It is fixed on 55c0b3c5a7. Read at 2026-10-31T03:30Z.

What failed. Four cases of ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx failed: the cold open, the warm open, the drawer step and the v4 pane step. #27713 landed that pin on main after this branch's base. It says a record open sends the page request alone. It measured jsdom's unmeasured width, which drew the tabbed tier there, so the stream waited for its tab.

This PR lays every width out with the 动态 panel first in the right column (Target 5, 6). So the stream is on screen at open and reads its own sources: the record's comment thread (sys_comment), its activity (sys_activity) and, once per view, the mention list (sys_user). The queue saw those reads as extra data requests. On main the same reads already went out at open wherever a column tier drew the stream (pageInColumnTier), at 1440 px for one.

The fix.

No product code changed.

Runs on 55c0b3c5a7, under the verify lock.

  • pnpm build: 115 of 115.
  • The pin: red on the merge, Tests 6 passed (6) after the fix.
  • The record-view, drawer, approval, page-tabs and panel-toggle tests plus packages/plugin-detail and packages/i18n: Test Files 561 passed | 1 skipped (562), Tests 5352 passed | 31 skipped (5383).
  • type-check of plugin-detail, app-shell, components and i18n: each exit 0.
  • eslint on the pin: 0 errors (the same 4 no-explicit-any warnings as main).

The other 15 Test UI shards and every other job of the queue run were green on the same base.

Next. os-zhuang's approval (on 90828f16cb) still stands on the PR; the push did not dismiss it. The PR waits for CI on 55c0b3c5a7. It then needs a re-queue.

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract-review-claim: session_01L9qibw58ehQ2gRHP2h2Puz · head 55c0b3c5a7e72a41ca2d4b2fefcd860431e0d384 · 2026-10-31T04:45Z

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-tesla · session_01L9qibw58ehQ2gRHP2h2Puz

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: 55c0b3c5a7e72a41ca2d4b2fefcd860431e0d384
Local-runs: none
Scope: the delta 90828f16cb..55c0b3c5a7 only: the merge commit 92b6992696 (main 69ce01d7ed into the branch; its two combined-diff hunks in ui/packages/app-shell/src/views/RecordDetailView.tsx and ui/packages/plugin-detail/src/RichTextCommentInput.tsx) and 55c0b3c5a7 (ui/packages/app-shell/src/views/RecordDetailView.recordPagePayload-27627.test.tsx, +28 / -2) + the interdiff of the PR-only diff at the old merge-base f0857d29c1 against the PR-only diff at the new merge-base 69ce01d7ed

Delta review after record 6525247890 (PASS on 90828f16cb) and the seat's ACCEPT 6525758616 on that head. The head moved by a merge of main and one test-only commit; the PR's own hand-written lines are otherwise unchanged from the head that record judged, so its ① / ② / ③ / ④ carry over and only the delta is judged here.

Inputs: card #27524 (body and comments, read for the Targets 4 to 6 the earlier records judged), PR #28116 (body, all fourteen comments: the two earlier records 6523984004 / 6525247890, the seat's ACCEPT 6525758616, the two preview reviews, the four merge-queue red reports and the seat's two readings of them 6526830338 / 6538437099), the interdiff of the PR-only diffs (index and hunk-header lines stripped: it names one file, the #27627 pin), git show --cc 92b6992696 (two hunks, both adjacency merges that keep both sides: the import line that this PR rewrites beside the hooks main added, and main's mention-chip rendering beside this PR's autoFocus prop), the 61 check-runs on this head (read 2026-10-31T04:47Z: all 61 runs completed, none failed, none cancelled, none in progress; success: Auto Label, Build & E2E UI, Build Core, Build Docs, Build UI Deps, Check Changeset, Check Documentation Links, Check PR Size, Dogfood Regression Gate, Governed Surface Queue Guard, Lint & Repo Gates, Lint UI, Lint · CI, release & engine gates, Lint · ESLint, Lint · PM dispatch-gates, Lint · docs & code guards, Lint · repo & tooling gates, Lint · scoped · query-options erasure, Lint · scoped · slot lookup & corpus, Lint · scoped · tooling self-tests, Live E2E UI, PR-body guards (claim · duplicate · Part-of · single-writer path), Plan Test Core shards, Spec property liveness, Test Core, Test Core (all 8), Test UI, Test UI (all 16), Type Check UI, Type Check · consumer gates, Type Check · debt ledger, Type Check · source gates, Type Check · workspace, TypeScript Type Check, filter; skipped: 18.x upgrade harness (opt-in), Dogfood Regression Gate (matrix shard/3), Dogfood Verify CLI, Packed-tarball smoke (opt-in), Schedule gate · skip when a green push run covers the commit, Temporal Conformance (live PG + MySQL), UI Published Artifacts), and the tree at this head for the pin. The head is mergeable and clean against today's main (merge-tree clean); the ten locale files main moved since the merge-base merge without conflict.

① Derived judgments

② Semver level

  • Unchanged from 6525247890 - the delta adds no changeset and touches no published surface; the ! title, the BREAKING banner and the ADR-0087 markers of the two changesets stand.

③ Boundary flags

④ Design judgment

  • Nothing blocking in the delta. Tried: a stream read that names no record (the mention list, matched by object alone, so a second view does not double-count it); a record whose thread is empty (the read still goes out, and the pin counts the request, not rows); the drawer step (the stream of the stepped-to record is read, the page is the prefetched answer, pinned). The pin now says what the page owns and what the stream owns, which is the right split for two features that share one wave.

Verdict reasoning. The head moved by a merge that kept both sides at the two adjacent hunks and by one test-only commit that answers the queue red the right way: the #27627 contract is kept whole and the stream's own reads are pinned apart from it. Every judgment of the prior record stands on the unchanged product lines. The one thing to carry forward is the request count at open, which belongs to the epic seat.

Implemented-by: claude/project-thread-f9tvxt
Reviewed-by: session_01L9qibw58ehQ2gRHP2h2Puz

VERDICT: PASS

@os-zhuang
os-zhuang added this pull request to the merge queue Oct 31, 2026
Merged via the queue into main with commit 3d2da4f Oct 31, 2026
61 checks passed
@os-zhuang
os-zhuang deleted the claude/project-thread-f9tvxt branch October 31, 2026 06:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants