Skip to content

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
objectstack-fleet[bot] merged 8 commits into
mainfrom
claude/project-thread-f9tvxt
Oct 30, 2026
Merged

objectstack-fleet[bot] merged 8 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: none — no file under skills/** or content/docs/** teaches the related card's collapse, its ⋯ items or the stored arrangement (a git grep for each names nothing); what an author writes is unchanged.

维护者速读

  • 改了什么:记录页右栏的关联列表变成同形面板。表头是「标题 + 数量」,点表头或数量打开整张列表;悬停时右端出现 ⠿ + ⋯,⋯ 只剩「隐藏」;没有折叠、没有 ⤢。0 行的面板收成一行「还有 N 个空关联 · 名字 · … · 展开」。存储的排列去掉 collapsed(版本升到 2,旧记录读作「没选过」)。行内首列跳过指回本记录的外键。
  • 为什么改: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 1、2、10、11(画布 60 定稿),是四个串行 PR 中的第一个;3、12 在 main 上已成立,本 PR 只加钉子。
  • 风险与回滚:只动 @object-ui/plugin-detail、十个语言包和 app-shell 的偏好编码;已拖过顺序的读者会回到默认顺序一次(无兼容读取,按裁定)。回滚即 revert 本 PR。
  • 先看:ui/packages/plugin-detail/src/RelatedList.tsx(紧凑面表头与首列);RelatedListEmptyFold.tsx(面板行的折叠规则);synth/relatedArrangement.ts(去掉 collapsed)。
  • 两分钟验证:pnpm --dir ui exec vitest run packages/plugin-detail/src/__tests__/RelatedPanel.header-27524.test.tsx packages/plugin-detail/src/renderers/__tests__/record-related-list.emptyPanels-27524.test.tsx,预期 2 个文件、14 个测试全绿。
  • 需要你做的:无,席位落地。

What changes

The record page's related column becomes a stack of same-shaped panels (record page v4, canvas 60). This is PR ① of four serial PRs for #27524, approved by the seat as a split; each leaves main coherent on its own.

Target item Where
1 panel header this PR (interim: the header opens the existing ?related= face; PR ③ makes it the panel's tab)
2 empty panels fold into one line this PR
3 column foot already on main (RelatedCardStack-27055 pins it); unchanged here
10 stored arrangement without collapsed this PR
11 compact row's first column this PR
12 highlights = declared minus the name, max 6 already on main; this PR adds the pin highlights-27524.test.ts
4, 5, 6 ⊟ switch, 动态 as the first panel, tiers by container width PR ②
8, 9 the tab row, enlarged = active tab PR ③
7 the top bar PR ④ (with #27545's ⤢ / ⤡); the last PR, the one that closes the card

1. The panel header (RelatedList compact face, RelatedCardMenu.tsx). Title, then the count chip, inside one <button data-related-panel-open> whose title reads 「查看全部 N 条」 (detail.relatedPanelViewAll); › shows at the title's end on hover or focus. The header row itself is the target too: the button's ::after stretches over the positioned header, so a click on its free space opens the list while the row stays one control. The controls sit above it (relative z-10). On hover or focus-within the right end shows ⠿ (RelatedPanelHandle: the drag source on desktop; focused, ⌥↑ / ⌥↓ move the panel and keep focus on it), + and ⋯, which holds 隐藏 only. No collapse, no ⤢, nothing reserved on the left. On a touch screen (hover: none) the controls stay drawn. The full face of an arranged list (the Related tab of an authored page) draws the same ⠿ and ⋯ and no longer collapses from its header.

The header has no click handler of its own, so a click inside the ⋯ menu's portaled content cannot open the list, even though it bubbles through React. The test ⋯ holds 隐藏 and nothing else pins this. The first version, a clickable header div guarded by event.currentTarget.contains(event.target), failed clickableNonControlCensus-13461 in CI on b8c4395be2. 1d1d2d6e6f replaces it with the stretched button. The census is red on b8c4395be2 and green after the fix, locally.

2. Empty panels (RelatedListEmptyFold.tsx). In the panel column (RelatedListCompactContext) a stack of two or more folds its 0-row panels into one line after the others: 「还有 N 个空关联 · 名字 · … · 展开」 (detail.relatedEmptyFold, plural per pack; 展开 reuses detail.relatedCardExpand). Unlike the Related tab's line (#25895, unchanged), the panel line folds a panel with an add picker too, folds a column whose panels are all empty, and carries no New: 展开 draws the panels again in their places, each with its own +. Non-empty panels keep their order. 展开 holds for the record it was pressed on (Stack.expandedFor, object:id from the record context). A view that steps to another record in place, such as the pane's ↓ ↑, keeps its column element, and the next record folds again.

10. The stored arrangement (synth/relatedArrangement.ts, useRelatedArrangement.ts, app-shell workspacePreference.ts). record.related.arrangement is { v, order, hidden, basedOn, seen }. collapsed, the collapse edit and the defaultCollapsed argument are removed; the codec's version is RELATED_ARRANGEMENT_RECORD_VERSION (2), so a version-1 record reads as no choice (no compatibility read, per the ruling). CollapsedRelatedCard and the stack's collapsed branch go.

11. The compact row's first column (RelatedList). When the child's name field is the FK pointing back at this record, the row is named by the first column that is not that FK, not redacted, not a platform column and readable. The authored columns are read first, then the declared fields. The FK is skipped even when it is the only authored column. So a product's line items name the opportunity.

i18n. detail.relatedCardMoveUp, detail.relatedCardMoveDown, detail.relatedCardCollapse and detail.relatedCardCount leave all ten packs; detail.relatedPanelViewAll, detail.relatedPanelReorder, detail.relatedPanelReorderHint and detail.relatedEmptyFold arrive in all ten, with each pack's plural set.

Declared. Clause-②: yes (narrowing): RelatedArrangement, ResolvedRelatedArrangement and RelatedArrangementEdit are exported from the plugin-detail entry and lose members. @object-ui/i18n's exported TranslationKeys (typeof en) also loses the four removed keys. A t() call naming one still compiles, because its key parameter is a string. The changeset's BREAKING banner names both packages. Its ADR-0087 marker, not-required (runtime-interface-only …), names all four symbols, and the gate verifies it. The changeset also carries the one-line fix. RELATED_ARRANGEMENT_RECORD_VERSION is a new export.

Source re-measured (main 273137d227 → this branch's base). S1, S3 and S4 hold. S2 drifted, description only: the page's crumb row carries ‹ back and 「收为抽屉」, with no ↑ ↓. The card names synth/recordTimelineStream.ts; the file is src/recordTimelineStream.ts, and the promotion logic it means lives in synth/recordPageColumns.ts (PR ② retires it there). Neither drift changes what a Target item asks.

File surface. One file outside the card's list: ui/packages/app-shell/src/utils/workspacePreference.ts, the codec of record.related.arrangement (item 10 cannot land without its version). RelatedList.tsx, RelatedCardMenu.tsx, relatedListCompact.ts, renderers/record-related-list.tsx and index.tsx are the files the header and fold are drawn in, beside the listed ones.

Tests

New:

  • __tests__/RelatedPanel.header-27524.test.tsx (10): the real RelatedList compact face with the stack's chrome. Header anatomy (title, chip, no toggle, no ⤢, 「View all 2」); row, title and chip each open the list; ⠿ + ⋯ at the right end, hover/focus classes, their clicks do not open the list; ⋯ holds Hide only and Hide does not open the list; ⌥↑ / ⌥↓ on the handle, a bare arrow does nothing; the handle is the drag source, the header row is not; the first column on the product page (Acme renewal, Globex pilot), also when the FK is the only authored column, and on the opportunity page (Widget); the arranged full face (the Related tab) draws ⠿ and ⋯, no chevron, and a header click leaves its rows drawn.
  • renderers/__tests__/record-related-list.emptyPanels-27524.test.tsx (4): the line's text and order; 展开 restores the panels in place; stepping to a sibling record in the same view (same column element) folds again; an all-empty column folds.
  • synth/__tests__/highlights-27524.test.ts (3): lead and contact sets minus full_name, the cut at six.
  • app-shell/src/utils/workspacePreference.relatedArrangement-27524.test.ts (2): the v2 shape round-trips; a v1 record carrying collapsed reads as no choice.

Updated, not deleted: RelatedCardStack-27055 (the collapse case becomes "a panel has no collapse"; moves via the handle), relatedArrangement-27055 (collapse cases become order + hidden), RelatedList.compactColumn-26678 (the new header anatomy).

Runs and ablation
  • Pre-merge head 96652743f2, under the verify lock: pnpm --dir ui exec vitest run packages/plugin-detail packages/app-shell/src/utils packages/i18n → Test Files 479 passed | 2 skipped (481), Tests 5209 passed | 39 skipped (5248).

  • Merged head fbf43d1dfa (main 04740b7c9f merged once, no conflict; pnpm install --frozen-lockfile && pnpm build → 115/115 tasks): the same command → Tests 1 failed | 5241 passed | 39 skipped (5281). The one red was ⋯ holds 隐藏 and nothing else reading a RelatedList.tsx transformed before the commit below landed mid-run (it asserts that commit's class). On the final head b8c4395be2: RelatedPanel.header-27524, record-related-list.emptyPanels-27524, RelatedList.compactColumn-26678, RelatedCardStack-27055 → Test Files 4 passed (4), Tests 30 passed (30).

  • type-check (tsc --noEmit + tsc -p tsconfig.test.json) for @object-ui/plugin-detail, @object-ui/i18n, @object-ui/app-shell on the merged head: 0 errors each, lock VERDICT command-exit 0.

  • A bug the screenshots found, fixed in b8c4395be2: with ⋯ open the controls faded out, because the pointer and the focus are both inside the portaled menu. The controls now stay drawn while a control holds data-state=open; the menu test pins it.

  • Ablation (scripts/ablation-replace.mjs, wrap mode, on e8a0e9f9fd): in RelatedListEmptyFold.tsx, if (!panels && !ordered.some((m) => m.empty === false)) return NO_FOLD; → if (!ordered.some((m) => m.empty === false)) return NO_FOLD; /* ABLATED_27524 */ (anchor 1 → 0, blob cfb2543146ea → e4a7af3d6923, marker count 1). Red: a column whose panels are all empty folds them too (Tests 1 failed | 2 passed (3)); the two cases with a non-empty panel stay green, as they should. Restored: blob equal to HEAD's, git diff HEAD empty. The suite imports the renderer from source, so no dist is involved.

  • Contract review 6514756946 (FAIL on b8c4395be2), fixed in 8b20d10d4c. Each pin was ablated (ablation-replace.mjs, wrap mode) and restored:

    • ④-1: 展开 scoped to the record. Ablation stack?.expandedFor === recordKey → stack?.expandedFor != null. Result: the sibling case is red, Tests 1 failed | 3 passed (4).
    • ④-2: the first column falls through to the declared fields. Ablation [...authored, ...declared] → (authored.length > 0 ? authored : declared). Result: the authored-only case is red, Tests 1 failed | 9 passed (10).
    • ④-3: the Related tab list no longer collapses. Ablation: arrangedFull put back into isCollapsible. Result: the full-face case is red, Tests 1 failed | 9 passed (10).
    • ②-1: the changeset banner names @object-ui/i18n, and the marker adds ui/packages/i18n/src/locales/en.ts#TranslationKeys. check-adr-0087-registration --base origin/main: exit 0, four symbols verified.
  • On 8b20d10d4c: pnpm --dir ui exec vitest run packages/plugin-detail packages/app-shell/src/views/RecordDetailView packages/components/src/__tests__/clickableNonControlCensus-13461.test.ts under the lock → Test Files 425 passed | 1 skipped (426), Tests 3635 passed | 18 skipped (3653); @object-ui/plugin-detail type-check VERDICT command-exit 0; eslint on the four touched files 0 errors.

  • Proxy preview review 6521198256 (REWORK on 8b20d10d4c, Target 1: a press on the header row's free space opened nothing), fixed in e6436ddc12. The open button carries transform: none, so the console's pressed-button scale(0.96) no longer makes it the containing block of its stretched ::after. On e6436ddc12, under the lock: RelatedPanel.header-27524, RelatedList.compactColumn-26678, RelatedCardStack-27055, clickableNonControlCensus-13461 → Test Files 4 passed (4), Tests 33 passed (33); @object-ui/plugin-detail type-check VERDICT command-exit 0; eslint on the two touched files 0 errors. Ablation (the style={{ transform: 'none' }} line replaced by /* ABLATED_27920 */ with sed, marker count 1): the pin is red (expected '' to be 'none', Tests 1 failed | 9 passed (10)), and a live press on the free space (Playwright, 120 ms hold, an app-crm account at 1440) opens nothing. Restored with git checkout: git diff HEAD empty, and the same press opens ?related=crm_activity.account.

Acceptance notes

  • Screenshots: on examples/app-crm, not HotCRM (deviation, as the order says); six shots in /mnt/project-files/ui-seat2/card-27524/shots/ (project files, not a repository branch), zh, 1440 × 900, on the final head through the console's dev server: 01-account-1440-panels (three panels with rows, then 「还有 1 个空关联 · 线索列表 · 展开」), 02-panel-header-hover (›, ⠿ + ⋯), 03-panel-menu-hide-only, 04-empty-panels-expanded, 05-header-click-opens-list (the interim ?related= face), 06-opportunity-1440 (all three panels empty → one line). The tier, the 时间线 | 关联 control and the folded 动态 card in them are main's and change in PR ② and ④. app-crm has no product object, so Target 11 is shown by its test only.
  • The panel fold line needs a stack of at least two panels; a single empty panel stays drawn.
  • The epic seat verifies in the console preview against the boards before ACCEPT.

Verification

Gates (34 derived, all exit 0) and the dogfood run

node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack, each run with its exit code captured before any pipe; --ran → 34 derived famil(ies) accounted for. The run started on the merged head fbf43d1dfa; b8c4395be2 (one class and one comment in RelatedList.tsx, one test assertion) landed while it ran, so the gates that read sources after it read the final tree, and the two that could see the change (check-comment-mask-corpus, check-adr-0087-registration --base origin/main) were run again on b8c4395be2: exit 0 both.

node scripts/check-adr-0087-registration.mjs --base origin/main :: exit 0
node scripts/check-adr-0087-registration.mjs --self-test :: exit 0
node scripts/check-changeset-no-major.mjs --base origin/main :: exit 0
node scripts/check-closing-keyword-parity.mjs :: exit 0
node scripts/check-closing-keyword-parity.mjs --self-test :: exit 0
node scripts/check-comment-mask-corpus.mjs :: exit 0
node scripts/check-empty-changeset.mjs --base origin/main :: exit 0
node scripts/check-empty-changeset.mjs --self-test :: exit 0
node scripts/check-sdui-manifest.mjs :: exit 0
node scripts/check-sdui-manifest.mjs --self-test :: exit 0
node scripts/pm/release-rehearsal-clone.mjs --self-test :: exit 0
node scripts/release-pending-publish.mjs --self-test :: exit 0
pnpm --dir ui exec vitest run scripts/ :: exit 0
pnpm --filter @objectstack/spec run check:duration-unit-keys :: exit 0
pnpm check:changeset-gate-self-tests :: exit 0
pnpm check:closed-ceremony-text :: exit 0
pnpm check:cross-package-test-inputs :: exit 0
pnpm check:dispatcher-error-vocabulary :: exit 0
pnpm check:driver-memory-census :: exit 0
pnpm check:engine-double-contract :: exit 0
pnpm check:gitlink-declared :: exit 0
pnpm check:nul-bytes :: exit 0
pnpm check:pm-changeset-deadline-census :: exit 0
pnpm check:published-files :: exit 0
pnpm check:query-options-erasure :: exit 0
pnpm check:refd-timer-probe :: exit 0
pnpm check:skill-identifier-liveness :: exit 0
pnpm check:test-source-alias :: exit 0
pnpm check:tier-file-adoption :: exit 0
pnpm check:type-check-coverage :: exit 0
pnpm check:type-check-debt :: exit 0 (re-run under the verify lock; the first run queued behind the dogfood hold and exited 3, NOT MEASURED)
pnpm check:undeclared-dep-imports :: exit 0
pnpm check:watch-hint-literal :: exit 0
pnpm check:where-matcher :: exit 0
pnpm --dir ui run check:i18n-drift :: exit 0

Full dogfood once on the final head, because the title carries ! (pnpm --filter @objectstack/dogfood test under the verify lock, after pnpm build): b8c4395be2 · VERDICT command-exit 1 · Test Files 3 failed | 353 passed | 3 skipped (359) · Tests 4 failed | 2867 passed | 20 skipped (2891). All four are main's, red at this branch's merge base 04740b7c9f and repaired on main after it; this diff touches nothing they read (only ui/** and the changeset):

Body refreshed 2026-10-30T11:19Z: the runs line for e6436ddc12 (contract review 6521350798, should-fix).


Generated by Claude Code

…-only menu, empty-panel line

WIP: panel header anatomy, the arrangement without collapsed, the empty
panel line and the compact row's first column. Tests follow.

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

Contract-review-claim: session_01Utp6CY2n1JmZZKrHgtp9jC · head b8c4395 · 2026-10-30T03:59Z

The face is Clause-②: yes (narrowing): the plugin-detail entry exports RelatedArrangement / ResolvedRelatedArrangement / RelatedArrangementEdit lose members. That puts the review at CONTRACT_REVIEW_TIER, scoped to the whole diff outside the prose faces. The seat runs it with an at-tier reviewer agent that did not draft the change.


Generated by Claude Code

… the row, not a clickable div

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

Contract review

Served-tier: CONTRACT_REVIEW_TIER
Head-sha: b8c4395be25d3908d9d5e29202c3942933c3b0b7
Local-runs: none
Scope: ui/packages/plugin-detail/src/ArrangedRelatedTab.tsx, ui/packages/plugin-detail/src/RelatedCardMenu.tsx, ui/packages/plugin-detail/src/RelatedCardStack.tsx, ui/packages/plugin-detail/src/RelatedList.tsx, ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx, ui/packages/plugin-detail/src/index.tsx, ui/packages/plugin-detail/src/relatedListCompact.ts, ui/packages/plugin-detail/src/renderers/record-related-list.tsx, ui/packages/plugin-detail/src/synth/relatedArrangement.ts, ui/packages/plugin-detail/src/useRelatedArrangement.ts, ui/packages/plugin-detail/src/tests/RelatedCardStack-27055.test.tsx, ui/packages/plugin-detail/src/tests/RelatedList.compactColumn-26678.test.tsx, ui/packages/plugin-detail/src/tests/RelatedPanel.header-27524.test.tsx, ui/packages/plugin-detail/src/renderers/tests/record-related-list.emptyPanels-27524.test.tsx, ui/packages/plugin-detail/src/synth/tests/highlights-27524.test.ts, ui/packages/plugin-detail/src/synth/tests/relatedArrangement-27055.test.ts, ui/packages/app-shell/src/utils/workspacePreference.ts, ui/packages/app-shell/src/utils/workspacePreference.relatedArrangement-27524.test.ts, ui/packages/i18n/src/locales/{ar,de,en,es,fr,ja,ko,pt,ru,zh}.ts + ui/packages/plugin-detail/src/RecordPageColumns.tsx, ui/packages/plugin-detail/src/synth/buildDefaultPageSchema.ts, ui/packages/plugin-detail/package.json, ui/packages/app-shell/src/views/RecordDetailView.tsx, ui/packages/app-shell/src/views/recordDrawerStack.tsx, ui/packages/app-shell/src/utils/workspacePreference.test.ts, ui/packages/core/src/utils/record-title.ts, ui/packages/core/src/utils/parent-scope.ts, ui/packages/i18n/src/i18n.ts, ui/packages/i18n/src/locales/index.ts, ui/packages/i18n/src/locales/registry.ts, ui/packages/react/src/index.ts, scripts/check-adr-0087-registration.mjs

Read: card #27524 (body, all six comments incl. the claim 6513005479, the split plan 6513066595 and the dev report 6514580814), PR #27920 body and file list (29 files), the net diff against main at the merge base 04740b7c9f, and the check-runs on the head.

Check-runs on b8c4395be2 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 UI, Lint · ESLint, Lint · repo & tooling gates, Lint · docs & code guards, Lint · PM dispatch-gates, Lint · CI, release & engine gates, the three scoped lints, Build UI Deps, Build & E2E UI, Live E2E UI, Test Core, Dogfood Regression Gate, Check Changeset, Governed Surface Queue Guard, PR-body guards, Check PR Size, Auto Label, filter, Test UI shards 4, 6, 9, 10, 16. NOT yet concluded: Type Check UI and Test UI shards 1, 2, 3, 5, 7, 8, 11, 12, 13, 14, 15 (in progress). Skipped by path filter: the Core shards, Build Core, Build Docs, UI Published Artifacts, Dogfood Verify CLI, Temporal Conformance, the opt-in harnesses. No check-run has failed.

① Derived judgments

Public surface of @object-ui/plugin-detail (entry src/index.tsx, published through exports["."], no private flag — a published package):

  • RelatedArrangement loses collapsed — right (Target 10, the ruled shape { v, order, hidden, basedOn, seen }).
  • ResolvedRelatedArrangement loses collapsed — right; nothing in the tree reads it after the stack's collapsed branch and CollapsedRelatedCard go.
  • RelatedArrangementEdit loses the collapse arm — right; useRelatedArrangement.chromeOf no longer emits it, ArrangedRelatedTab no longer calls it.
  • resolveRelatedArrangement / editRelatedArrangement lose the trailing defaultCollapsed parameter — right; the one caller is the package's own hook. A fourth positional argument from an external caller is now a compile error, as the changeset says.
  • EMPTY_RELATED_ARRANGEMENT loses collapsed — right, it is the host's fallback for a no-choice read and must match the interface.
  • New export RELATED_ARRANGEMENT_RECORD_VERSION = 2 — right: the codec's version lives beside the record's shape it versions, and app-shell's RECORD_RELATED_ARRANGEMENT_PREFERENCE reads it instead of a second literal; the version and the shape move in one place.
  • Not public, so no surface judgment owed: RelatedCardChrome.onCollapse (removed), RelatedArrangementScope.isCollapsed / expand (removed), CollapsedRelatedCard (deleted), RelatedCardMenu's collapsed / onExpand props (removed), RelatedPanelHandle (new) — none is exported from the entry (checked by grep on the head's index.tsx).

Stored preference record.related.arrangement (app-shell workspacePreference.ts): the codec's version moves 1 → 2; versionedPreferenceCodec.decode returns undefined on record.v !== version, so a version-1 record (the one that carried collapsed) reads as no choice and the host passes EMPTY_RELATED_ARRANGEMENT (RecordDetailView, useWorkspacePreference(..., EMPTY_RELATED_ARRANGEMENT)). Right per Target 10's "no compatibility read" — a reader's dragged order, hidden set and seen stamps are discarded once, as the PR body states in its risk line. relatedArrangementFromRecord no longer requires collapsed and relatedArrangementToRecord no longer writes it — the strict reader stays strict on order, hidden, basedOn, seen. The stale v1 record stays in storage until the next write overwrites the same key — harmless. The pre-existing versionedPreferenceCodec test in workspacePreference.test.ts uses collapsed only in its own private fixture codec (version 3) — unaffected, and correct to leave.

@object-ui/i18n (published): the keys detail.relatedCardMoveUp, relatedCardMoveDown, relatedCardCollapse, relatedCardCount (with every plural form) leave all ten packs; relatedPanelViewAll, relatedPanelReorder, relatedPanelReorderHint, relatedEmptyFold arrive in all ten with each pack's plural set (ar six forms, ru four, es/fr/pt three, en/de/ja/ko/zh two — checked on the head). relatedCardEnlarge stays (the body-table ⤢ in the full face still uses it), relatedCardExpand is reused by the fold line. Right on the packs. One consequence the diff does not declare: TranslationKeys = typeof en is an exported type of @object-ui/i18n (re-exported by @object-ui/react), and it narrows with the keys — see ② (should-fix). The translate function itself is typed (key: string, ...), so no caller's t('detail.relatedCardCount') fails to compile; it falls back to the key string at runtime.

Accept-set changes inside the tree, each judged:

  • decideEmptyFold(ordered, panels): in the panel column the "at least one non-empty" guard is skipped, so an all-empty column folds — right (Target 2; the HotCRM contact with four empties is the case the ruling names). The stack-of-one guard stays, so a single empty panel is drawn as its header alone — acceptable under the line's own wording (「还有 N 个」 presumes others), noted, not flagged.
  • foldable in record-related-list.tsx widens for panels to include an add picker and child toolbar actions — right: 展开 restores the panel with its own + and ⋯, the line carries no New.
  • The compact header: the row, the title button and the chip all open the list; ⠿ + ⋯ at the right end, opacity-0 until hover / focus-within / an open menu / a hover-less pointer — right (Target 1). The portaled-menu guard event.currentTarget.contains(event.target) is correct: Radix portals the content outside the row in the DOM while React bubbles through the tree.
  • The compact row's first column (compactNameField): only when the child's resolved name field IS the back-FK, the row is named by the first authored (else declared) column that is not that FK, not redacted, not a platform column, declared, and readable — right (Target 11), see ④ for one edge.

② Semver level

  • Title carries !; PR body and changeset both carry Clause-②: yes (narrowing) and Metadata-protocol: none. Correct: a TypeScript interface narrowing on a published package is a narrowing; no metadata key is added, removed or re-meant (Target 10 says "No metadata key is added"; packages/spec/** untouched).
  • Changeset .changeset/27524-record-page-related-panels.md: patch for @object-ui/plugin-detail, @object-ui/i18n, @object-ui/app-shell. 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". The changeset has the BREAKING banner (naming the two interfaces, the edit union, the dropped parameter, the new constant) and exactly one ADR-0087 marker. check-changeset-no-major and check-adr-0087-registration run under Check Changeset and Lint & Repo Gates — both success on the head. No CHANGELOG or content/docs edit (release-owned, correctly untouched). Skills-docs: none, and a grep of skills/** for the collapse / ⋯ items / stored arrangement names nothing — holds.
  • The ADR-0087 claim not-required (runtime-interface-only ui/packages/plugin-detail/src/synth/relatedArrangement.ts#RelatedArrangement, …#ResolvedRelatedArrangement, …#RelatedArrangementEdit) checked against scripts/check-adr-0087-registration.mjs's four predicates, by reading: (1) each path#Symbol resolves to an exported interface / type at HEAD — yes; (2) the declaration site is not a *.zod.ts, not under packages/spec/src/contracts/, not an object definition — yes; (3) none is a z.input / z.infer projection — yes; (4) no metadata surface references the names in code — git grep over *.zod.ts and packages/spec/** on the head returns nothing. The inherited no-prescription refusal: the body's "the fix is to drop it" is a one-line prose fix with no FROM → TO arrow, table or 迁移 framing — the fix(service-package): classify a publish driver fault as 5xx and stop returning driver text as caller data (#8131) #8277 shape the gate accepts, and the same spelling the console record page v3: frameless detail sections, one merged timeline (discussion + activity + field changes), compact related cards, header 1 primary + 2 secondary + ⋯ — the synthesized default page, drawn from the object's roles; the dead BuildPageOptions hints go #27055 changeset used. unpublished would have been refused (the package is not private), so the chosen category is the right one, not the convenient one. The gate judges the claim as made, not its completeness (its header says so), which leaves one gap for the human reader:
  • Should-fix (②-1): the BREAKING paragraph says "(@object-ui/plugin-detail, TypeScript only)" while the diff also narrows @object-ui/i18n's exported TranslationKeys (typeof en, as const; re-exported by @object-ui/react) by four keys and their plural forms. The i18n bullet names the keys, so the fact is in the body; the banner should name the second package too (one clause: "and @object-ui/i18n's TranslationKeys loses the four detail.relatedCard* keys named below"). No level change: patch stays right.

③ Boundary flags

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

  1. Clause-② measured yes (narrowing) against a claim that said no — answered: claim 6513005479 is already amended to yes (narrowing) and names the seat's contract review; the PR body matches.
  2. Part of #27524 vs the split plan's Refs — answered: both non-closing; PR-body guards (… Part-of …) success on the head; PR ④ carries the Fixes.
  3. File surface: workspacePreference.ts outside the card's list, plus RelatedList.tsx, RelatedCardMenu.tsx, relatedListCompact.ts, renderers/record-related-list.tsx, index.tsx — answered: the codec's version cannot land anywhere else, and the others are where the header and fold are drawn; the claim comment already lists workspacePreference.ts.
  4. Screenshots on examples/app-crm in project files, not HotCRM on an evidence branch linked from the PR body — escalated to the seat and the epic seat: Acceptance 2 of the card asks for HotCRM shots on a never-merged evidence branch; the dev reports the dispatch order chose app-crm. Not a tree matter and not re-judged here; the seat's ACCEPT and the epic seat's preview verification (Acceptance 3) own it. Target 11 is shown by test only (app-crm has no product object) — consistent with the tree.
  5. PR attribution link is the card thread's session — noted, cosmetic.
  6. Source drift (S2 has no ↑ ↓ in the page crumb row; recordTimelineStream.ts lives at src/, the promotion at synth/recordPageColumns.ts) — answered: description-only, no Target item changes meaning; PR ② retires the promotion where it actually lives.
  7. No assignee / reviewer on the PR per the maintainer's routing rule — answered.

Standing question on the card not yet answered by the epic seat: comment 6513066595 asks whether Acceptance 3 (preview verification against the boards) applies to each of the four PRs or to the end state after ④. The seat said it holds each PR for that answer. Escalated: this record does not decide it; a PASS here would still wait on that answer before landing.

Out-of-scope observation (dev): main moved RelatedList.tsx after the merge base (#27856); git merge-tree clean; the merge-ref check-runs cover it (Build & E2E UI, Lint UI success; Type Check UI and most Test UI shards still in progress at read time).

④ Design judgment

Scope files, each against the code it cites and Target items 1, 2, 10, 11, 12 (3–9 are later PRs; the interim face "header click opens the existing ?related= face" is the split plan's and holds in the tree: cardChrome.onEnlarge is the host's ?related= handler, unchanged).

synth/relatedArrangement.ts, useRelatedArrangement.ts, workspacePreference.ts (Target 10, codec bump, hidden/order merge). resolveRelatedArrangement after the cut: rules 1–6 intact; hidden filtered to existing ids; rearranged = hidden or order differs; fresh / unseen unchanged. editRelatedArrangement rebuilds base without collapsed; hide keeps order empty for a never-dragged reader (the updated rule-1 test pins "a hide alone does not freeze the order"); show-hidden and restore-default-arrangement drop nothing they should keep. Rule 4 now drops a vanished id from order and hidden on the next write (pinned). The new relatedArrangement-27055 case pins the record's key set and the version constant. The v1-record read: decode refuses on v, fromRecord is never reached — pinned in workspacePreference.relatedArrangement-27524.test.ts with a real v1 record carrying collapsed. Holds. Tried to break: a v2 record written, then the host downgraded to a v1 reader — v: 2 !== 1 → no choice, symmetric and safe. A record with v: 2 but a collapsed member (hand-edited) — fromRecord ignores unknown keys, reads fine. No finding.

RelatedCardMenu.tsx — RelatedPanelHandle (keyboard reorder). ⌥↑ / ⌥↓ only with altKey and no meta / ctrl / shift; preventDefault + stopPropagation; onMoveUp is undefined at index 0 and onMoveDown at the end (the hook's chromeOf), so an edge press is a no-op; the handle's click stops propagation so Enter / Space on it does not open the list; focus is re-sought on the moved panel's handle after a frame, which is needed because the keyed reorder moves the section's DOM node (insertBefore blurs it). dragHandleProps is spread last and carries only draggable / onDragStart / onDragEnd, so it cannot overwrite onKeyDown / onClick. setDragImage on the whole panel is guarded for jsdom. The menu: 隐藏 alone, trigger stops propagation. Holds; pinned by RelatedPanel.header-27524 (five cases) and RelatedCardStack-27055 (handle moves written through the host).

RelatedList.tsx (Target 1 header, Target 11 first column). The compact face: title button + chip + › on hover inside one data-related-panel-open button titled detail.relatedPanelViewAll (zh 「查看全部 N 条」); the row's own onClick opens on free-space clicks and ignores portaled descendants; controls span with has-[[data-state=open]]:opacity-100 (Tailwind 4, has-[…] already used in components/src/ui/sidebar.tsx) and [@media(hover:none)]:opacity-100; no ⤢, no chevron, nothing on the left. Holds against Target 1 as far as PR ① claims it. Target 11: compactNameField fires only when resolveNameField(objectSchema) === referenceField; the projection (compactSelect) swaps the name field for the chosen column, so the wire read carries it; the row reads recordDisplayValueAt(row, compactNameField), which unwraps a lookup value's name. Pinned on both the product page and the opportunity page. One edge, should-fix (④-2): when the list HAS authored columns but every one of them is the back-FK, redacted, a platform column or unreadable, find returns undefined and the row falls back to the FK (the page's own name) instead of continuing to the child's declared fields — the docblock says "the list's authored columns when it has any, else the child's fields", so it is self-consistent, but Target 11 says the FK is skipped, full stop; fall through to declared when authored yields nothing.
The full (non-compact) arranged face: isCollapsible = collapsible || bodyTable || arrangedFull is unchanged, and handleHeaderClick now toggles the component's LOCAL collapsed state for arrangedFull too, with the chevron still drawn under {isCollapsible && (…)}. So in the tabbed tier's 关联 tab (ArrangedRelatedTab, still on main until PR ②) a header click still collapses the list — locally, unstored — and a chevron sits before the title. The PR body says "The full face of an arranged list … no longer collapses from its header"; the tree says otherwise. Should-fix (④-3), not must-fix: that face is not the panel column Target 1 rules on, and it retires in PR ②; but the body's claim and the code must agree — drop arrangedFull from isCollapsible (one token) so the Related tab's list header carries no collapse either, or correct the body.

RelatedListEmptyFold.tsx, renderers/record-related-list.tsx, RelatedCardStack.tsx, RecordPageColumns.tsx (Target 2, the empty fold). The panel line: drawn by the stack's last slot, names each folded panel, one 展开 button, no New; all-empty column folds; foldable widened for panels; non-empty panels keep their order. Pinned by record-related-list.emptyPanels-27524 (three cases), and the dev's ablation on the all-empty guard went red on exactly that case. The stack is per container element (WeakMap keyed by the slots' parentElement, which is the aside[data-record-column="related"] of RecordPageColumns). Tried to break it: must-fix (④-1) — the 展开 state leaks across records. Stack.expanded is set true by 展开 and is never reset: not when the members change, not when the record changes. RecordDetailView keys nothing by record id (recordId = recordIdOverride ?? params.recordId on the same instance) and recordDrawerStack.tsx keys its level by ${depth}:${objectName} on purpose ("a step to a sibling swaps the record in place"), so when a reader presses 展开 on contact A in the split pane and steps ↓ to contact B, C, … the same aside element, the same Stack and its expanded = true serve every sibling: their empty panels never fold, and there is no control that folds them again (展开 has no inverse). The same holds on the page wherever the view instance survives a record change. Target 2 rules that empty panels fold into one line; after one 展开 it holds for the rest of the drawer session only by accident of remount. The PR body's own model, "the stack then folds nothing until it is drawn again", is implemented as "until the container element is recreated", which is not a view. Fix, small: scope expanded to the record — reset it in RelatedListStackSlot when the record context's id changes (or key it by recordId in the Stack), or key RelatedCardStack's column by the record id — and pin "stepping to a sibling record folds again". With that, the fold design holds.

ArrangedRelatedTab.tsx. Scope loses isCollapsed / expand; chromeOf, lastId, hiddenCount, showHidden, defaultChanged, restoreDefault stay; the renderer's collapsed branch and its CollapsedRelatedCard node synthesis are gone, the language read with them. Holds (subject to ④-3 above on the list it draws).

relatedListCompact.ts, index.tsx. RelatedCardChrome without onCollapse; comments retargeted; the entry adds RELATED_ARRANGEMENT_RECORD_VERSION beside the other codec halves. Holds.

synth/__tests__/highlights-27524.test.ts (Target 12, pin only). deriveHighlightFields on the head: a declared highlightFields list is filtered of the title field (resolveTitleField) and sliced to RECORD_HIGHLIGHTS_MAX = 6 — the ruled rule, already on main; the three cases (lead, contact with no status field and nothing invented, cut at six after the title is removed) pin it. buildDefaultHighlights applies the same cut to a page-level options.highlightFields override without removing the title — that is an authored page option, outside Target 12's "declared" set; noted, not flagged.

Tests updated, not deleted (RelatedCardStack-27055, relatedArrangement-27055, RelatedList.compactColumn-26678): each collapse case becomes its no-collapse or hide twin; nothing a ruling still owns was dropped. Holds.

i18n packs. Shapes per ④ ①; the zh strings match the card's quoted text (「查看全部 N 条」, 「还有 N 个空关联」); check:i18n-drift reported green by the dev and Lint UI success on the head. Holds.

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

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

VERDICT: FAIL


Generated by Claude Code

…s through to the declared fields, the Related tab list does not collapse

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

Contract-review-claim: session_01Utp6CY2n1JmZZKrHgtp9jC · head 8b20d10 · 2026-10-30T04:28Z

Delta review of the findings commits (1d1d2d6, 8b20d10) after record 6514756946 (FAIL on b8c4395be2), 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: 8b20d10d4c86013b15bd862dcae5c5a40d8b850e
Local-runs: none
Scope: .changeset/27524-record-page-related-panels.md, ui/packages/plugin-detail/src/RelatedList.tsx, ui/packages/plugin-detail/src/RelatedListEmptyFold.tsx, ui/packages/plugin-detail/src/tests/RelatedPanel.header-27524.test.tsx, ui/packages/plugin-detail/src/renderers/tests/record-related-list.emptyPanels-27524.test.tsx + ui/packages/components/src/tests/clickableNonControlCensus-13461.test.ts, ui/packages/react/src/context/RecordContext.tsx, ui/packages/app-shell/src/views/recordDrawerStack.tsx, ui/packages/i18n/src/locales/en.ts, ui/packages/plugin-detail/src/RelatedCardMenu.tsx, scripts/check-adr-0087-registration.mjs

Delta review after record 6514756946 (FAIL on b8c4395be2): the diff b8c4395be2..8b20d10d4c, two commits (1d1d2d6e the census fix, 8b20d10d the four findings), five files, +100 / −36. 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 8b20d10d4c at read time: success — Check Changeset, Governed Surface Queue Guard, PR-body guards (claim · duplicate · Part-of · single-writer path), Check PR Size, Build UI Deps, Live E2E UI, Test Core, Dogfood Regression Gate, Auto Label, filter. NOT yet concluded (in progress): Test UI shards 1–16 (all sixteen), Build & E2E UI, Lint UI, Type Check 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 · workspace, Type Check · consumer gates, Type Check · source gates, Type Check · debt ledger. Skipped by path filter: the Core shards, Build Core, Build Docs, UI Published Artifacts, Dogfood Verify CLI, Temporal Conformance, the opt-in harnesses. No run on this head has failed. The one red on the previous head (Test UI (1/16), clickableNonControlCensus-13461 on the clickable header div) is the site 1d1d2d6e removes; the shard that re-runs it has not concluded yet, so the record reports the tree, not a verdict the gate has not yet given.

① Derived judgments

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

  • ④-1 (must-fix) — fixed. RelatedListEmptyFold.tsx: Stack.expanded: boolean becomes expandedFor: string | null; RelatedListStackSlot reads useRecordContext() and builds recordKey = objectName:recordId; 展开 sets expandedFor = recordKey; the verdict is NO_FOLD only while stack.expandedFor === recordKey. The leak named in the record — the pane's ↓ ↑ swapping the record in place on the same column element (recordDrawerStack.tsx keys its level by depth:objectName) — is closed by scoping to the record rather than the element. RecordContext's recordId is normalised to a string by the provider, so the key is stable across number / string ids. Pinned: the new case re-renders the same tree with recordId c2, asserts the column element is the SAME node (toBe(aside)) and that the line returns with the two empties. Right.
  • ④-2 (should-fix) — fixed. RelatedList.tsx compactNameField: the either-or read (authored when any, else declared) becomes [...authored, ...declared].find(…), so the back-FK is skipped even when it is the only surviving authored column; the docblock is updated to say so. Pinned: renderLines('product', ['product']) names the rows by the opportunity. Right.
  • ④-3 (should-fix) — fixed. isCollapsible = collapsible || bodyTable (arrangedFull dropped); arrangedFull keeps its two remaining uses (the handle and the ⋯ menu in the full face). The Related tab's list draws no chevron and a header click leaves its rows drawn. Pinned by the new describe (no compact context, chrome present: ⠿ and ⋯ drawn, no lucide-chevron-*, click on the title keeps Call Ada). The PR body's sentence now matches the tree. Right.
  • ②-1 (should-fix) — fixed. The changeset's BREAKING banner names @object-ui/plugin-detail and @object-ui/i18n, states that TranslationKeys loses the four detail.relatedCard* keys and that a t() call with one still compiles (its key is a string) and reads the key back; the ADR-0087 marker adds ui/packages/i18n/src/locales/en.ts#TranslationKeys as a fourth symbol. Right — see ②.

The census fix (1d1d2d6e), judged against Target 1 and the census's own rule:

  • The header div loses its onClick and cursor-pointer and gains relative; the title + chip button gains cursor-pointer after:absolute after:inset-0 after:content-[''], so its ::after box covers the positioned header and a click on the row's free space (or on the non-interactive 「新」 badge between) lands on the button; its onClick simply calls open(), with no stopPropagation left to need. The controls span gains relative z-10, so ⠿ + ⋯ paint and hit above the stretched pseudo-element (opacity 0 does not remove them from hit-testing, so a desktop reader who has hovered, or a hover: none device where they are always drawn, reaches them). The title tooltip 「查看全部 N 条」 now covers the whole row, as Target 1 asks. Right.
  • No nested interactive: the button contains two spans and an aria-hidden icon; the handle, + and ⋯ are siblings of the button, not descendants. Keyboard focus order is DOM order: open-list button → ⠿ handle → + → ⋯; the handle keeps its own onClick stop and ⌥↑ / ⌥↓; the chevron and the hover reveal still key off group/panel-head on the header, which the pseudo-element's hover reaches. Right.
  • The census rule (clickableNonControlCensus-13461: a shipped div / span with onClick and none of role / tabIndex / onKeyDown / onKeyUp / onKeyPress, keyed by file · tag · handler): the only such site this PR had — RelatedList.tsx · div · open — is gone; no NOT_CONTROLS / BACKLOG entry was added (the census file is untouched in the delta), which is the honest repair rather than a declared exemption. The portaled-menu click can no longer reach a header handler, because there is none; the menu test still pins onEnlarge uncalled. Right.
  • The after:… utility shape has precedent in the tree (DetailSection.tsx, AppReportIndexView.tsx, ApprovalsInboxPage.tsx). The header test now asserts BUTTON, after:absolute, after:inset-0, header relative, controls relative + z-10, and that the button and the chip (inside it) open the list twice, not three times.

No accept-set or public-surface change beyond those four: the delta touches no export, no stored shape, no i18n pack.

② Semver level

Unchanged from record 6514756946 on everything but the marker: patch ×3, Clause-②: yes (narrowing), Metadata-protocol: none, title with !, BREAKING banner now naming both narrowed packages. The fourth marker symbol checked against scripts/check-adr-0087-registration.mjs's runtime-interface-only predicates by reading: (1) ui/packages/i18n/src/locales/en.ts exports type TranslationKeys = typeof en — resolves; (2) not a *.zod.ts, not under packages/spec/src/contracts/, not an object definition; (3) typeof of an as const object, not a z.input / z.infer projection; (4) a git grep -w TranslationKeys over *.zod.ts and packages/** on the head returns nothing, and no homonym declares the name there. The prose stays the one-line fix with no FROM → TO prescription. Check Changeset is success on this head. Holds.

③ Boundary flags

No new dev flag arrived with the delta. Carried from record 6514756946 and still standing, not decided here: Acceptance 2's HotCRM evidence branch (shots are on app-crm in project files — seat / epic seat), and the epic seat's unanswered cadence question on comment 6513066595. The PR body's Runs and ablation section records an ablation per fix with a named red case each; those are the dev's runs, read, not re-run.

④ Design judgment

RelatedListEmptyFold.tsx. Tried to break the record scope: stepping A → B → A leaves A expanded (expandedFor still equals A's key) — that is "for the record", as the header now documents, and is the intended memory, not a leak; a reader with no record context gets the key : only where the compact context is absent too (RelatedListCompactContext is set by RecordPageColumns under a record), so the Related tab's #25895 line is untouched. useRecordContext is an unconditional hook at the top of the slot; the import from @object-ui/react is already a dependency of this package. Holds.

RelatedList.tsx. The stretched button: the ::after takes the button's cursor-pointer, the focus ring stays on the button's own box (title + chip), and nothing else in the header is positioned, so the stacking is exactly pseudo-element below, controls above. One thing the pseudo-element does change on purpose: the fresh 「新」 badge is now inside the hit area — it is a non-control, so that is the row being the target, per Target 1. isCollapsible without arrangedFull: a body table of the default page (bodyTable, #27055 Target 8) keeps its collapse and ⤢, which this card does not rule on. compactNameField's concatenation reads authored first, then declared, so the authored order still wins when it has a survivor. Holds.

Tests. The four new / changed cases each pin the fix they name against the real components (the header through RelatedList, the fold through the registered record:related_list renderer with a RecordContextProvider re-render); the sibling-step case asserts element identity, which is the fact the must-fix turned on. Holds.

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

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

VERDICT: PASS


Generated by Claude Code

…ens the list

The console scales a pressed button (`button:active { transform: scale(0.96) }`).
A transformed button is the containing block of its own absolute ::after, so
mid-press the stretched hit area shrank back to the title and chip, the release
landed on the header row, and the click went nowhere. The open button now
carries `transform: none`, so the row stays the target for the whole press.
Found by the proxy preview review on 8b20d10.

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) · 8b20d10 · REWORK

Performed per the #26676 ruling B (6520722066) by a context-free review agent with no development context. Its inputs were #27524's Target and Acceptance, the v4 boards (git show a7825499aa:docs/design/record-page-v2-2026-10-27/v4/…) and this PR's "What changes". Scope: Target 1, 2, 10 and 11. It ran on this head's live console (Vite dev server from source) on examples/app-crm, zh, 1440 × 900, read 2026-10-30T11:08Z.

Reason, Target 1 (blocking). A press on the header row's free space opens nothing. That is everything right of › and left of ⠿, about 40 % of the row at 1440, and the URL stays the same. The row is a pointer target with › on hover, so a reader expects the list. The clause broken is 「Clicking the header row (or the chip) switches to that panel's tab」 (interim here: the ?related= face).

  • Repro: an account with a non-empty panel, hover the panel, press and release at the row's free space.
  • Cause, traced: the console's button:not([disabled]):active { transform: scale(0.96) } (ui/apps/console/src/index.css, under (hover: hover)) makes the pressed button the containing block of its own stretched ::after. Mid-press, that hit area shrinks back to the title and chip, so the release lands on the header div and no click reaches the button. The jsdom pin asserts the classes only, so it stayed green.

Passed.

  • Target 1, the rest of it:
    • Title then chip, nothing on the left, no chevron, no ⤢.
    • › and ⠿ + ⋯ show on hover; the tooltip reads 「查看全部 N 条」. ⋯ holds 隐藏 only.
    • Clicking the title, clicking the chip and pressing Enter all open the list.
    • With the handle focused, ⌥↑ / ⌥↓ move the panel and keep focus on it; a bare ↓ does nothing.
    • Dragging ⠿ draws a drop line.
    • With (hover: none) the controls stay drawn.
  • Target 2: 「还有 N 个空关联 · 名字 · 展开」 with no New, and non-empty panels keep their order. 展开 redraws the panels in place. A column whose panels are all empty folds whole.
  • Target 10: record.related.arrangement is { order, hidden, basedOn, v: 2 } with no collapsed, and it persists across a reload. A seeded v1 record carrying collapsed reads as no choice. No metadata write happens on move or hide.
  • Target 11: verified by code and test only. No app-crm child names its rows by the FK back to the parent.

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

  • The blocking case: 09-header-free-space-click-no-navigation-1440.png.
  • Target 1: 01–07, 30–34, 40.
  • Target 2: 10–14.
  • Target 10: 20, 21, 32.

The fix follows as a new head on this PR, and the review is run again on it.


Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

Contract-review-claim: session_01Utp6CY2n1JmZZKrHgtp9jC · head e6436dd · 2026-10-30T11:10Z

Delta review of e6436ddc12 (the proxy preview review's REWORK fix 6521198256) after record 6515042489 (PASS on 8b20d10d4c), 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: e6436ddc129425adfe0582c7cbf62a80d8f608de
Local-runs: none
Scope: ui/packages/plugin-detail/src/RelatedList.tsx, ui/packages/plugin-detail/src/tests/RelatedPanel.header-27524.test.tsx + ui/apps/console/src/index.css, ui/packages/app-shell/src/styles.css, .changeset/27524-record-page-related-panels.md

Delta review after record 6515042489 (PASS on 8b20d10d4c): the diff 8b20d10d4c..e6436ddc12, one commit (e6436ddc the proxy preview review's REWORK fix, comment 6521198256), two files, +12 / −0. 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 e6436ddc12 at read time (52 runs): success — Auto Label, Build UI Deps, Check PR Size, Dogfood Regression Gate, Governed Surface Queue Guard, PR-body guards (claim · duplicate · Part-of · single-writer path), Test Core, filter. NOT yet concluded (in progress): Test UI shards 1–16 (all sixteen), Build & E2E UI, Live E2E UI, Check Changeset, Lint UI, Type Check UI, Lint · ESLint, Lint · docs & code guards, Lint · repo & tooling gates, Lint · CI, release & engine gates, Lint · PM dispatch-gates, the three scoped lints (query-options erasure, slot lookup & corpus, tooling self-tests), Type Check · workspace, Type Check · consumer gates, Type Check · source gates, Type Check · debt ledger. Skipped by path filter: the Core shards and their planner, Build Core, Build Docs, 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. Check Changeset was success on 8b20d10d4c and the changeset is byte-identical on this head, so its re-run is a formality; the record reports the tree, not a verdict the gate has not yet given.

① Derived judgments

The one finding of preview review 6521198256 (Target 1, blocking), checked in the tree (not the body):

  • Free-space press — fixed, and the mechanism is as traced. ui/apps/console/src/index.css lines 434–442: under @media (hover: hover), button:not([disabled]):active { transform: scale(0.96) }, with a transition: transform 0.18s …, filter 0.18s ease on every enabled button. A transformed element is the containing block of its absolutely positioned descendants, ::after included; the open button's after:absolute after:inset-0 was meant to resolve against the header div (relative), so mid-press the hit area collapsed to the button's own box and the mouseup landed on the header, which has no handler since 1d1d2d6e removed it. No click fires when mousedown and mouseup targets differ. The delta puts style={{ transform: 'none' }} on the button, so the pressed button is never a containing block and the ::after stays stretched for the whole press. Right.
  • The lever is the only one that works without !important. The console rule sits between two @layer base blocks and inside none of them — it is unlayered. ui/apps/console/package.json pins tailwindcss ^4.3.3 and index.css opens with @import 'tailwindcss', so every utility, active:transform-none included, lives in @layer utilities and loses to any unlayered declaration regardless of specificity. A class-based override would therefore not take; the choices were an !-important utility (transform-none!), of which the tree has no instance (grep for transform-none! / !transform-none under ui/ returns nothing), or an inline style, which also happens to be what jsdom can pin. The inline style is the right pick, not a deviation from an idiom. Right.
  • No other containing-block trigger remains. The button's class list carries no relative, no filter, backdrop-filter, perspective, contain or will-change; the filter half of the console transition has no :active value to transition to. The app-shell press rule (ui/packages/app-shell/src/styles.css line 60, :is(button, a).inline-flex.whitespace-nowrap.rounded-md.text-sm.font-medium.ring-offset-background:not(:disabled):active { transform: scale(0.97) }) is scoped to the Button cva base and does not match this flex … rounded-sm button, so transform: none suppresses exactly one rule. Right.
  • Press feedback. The inline style removes the iOS springback from this one button. That is the row's title, not a Button; the app-shell's own comment (styles.css lines 40–58) deliberately keeps the springback off row-like controls (cmdk rows, SidebarNav links), and the ⠿ + ⋯ controls in the same header keep theirs. The row is a list header that navigates; losing a 4 % scale on it is consistent with the rest of the console, not a regression anyone is accepted against. Not a finding.
  • Test. RelatedPanel.header-27524.test.tsx adds expect(open.style.transform).toBe('none') to the existing row-opens case, and its comment says why jsdom can pin only the override (no :active, no layout). The pin is honest: it guards the style against removal; it does not prove the browser behaviour, which only the preview review's re-run on this head can. The comment's button:active { transform: scale(0.96) } is a paraphrase of button:not([disabled]):active under (hover: hover), close enough to find the rule by.

No accept-set or public-surface change: the delta touches no export, no stored shape, no i18n pack, no changeset (git diff 8b20d10d4c e6436ddc12 -- .changeset is empty), no governed path.

② Semver level

Unchanged from record 6515042489: patch ×3, Clause-②: yes (narrowing), Metadata-protocol: none, title with !, BREAKING banner naming @object-ui/plugin-detail and @object-ui/i18n, ADR-0087 marker with four symbols. The delta adds an inline style attribute and one assertion; neither is a published surface. Holds.

③ Boundary flags

No new dev flag arrived with the delta. Carried from record 6515042489 and still standing, not decided here: Acceptance 2's HotCRM evidence branch (shots are on app-crm in project files — seat / epic seat), and the epic seat's unanswered cadence question on comment 6513066595. New on this head: the preview review 6521198256 says it is run again on the fix; that re-run is the only check that exercises :active and layout, and it is owed by the preview process, not by this record. The PR body's Runs and ablation section ends at 8b20d10d4c; no vitest, eslint or type-check line names e6436ddc12, in the body, the PR comments after the claim line, or the commit message (which gives the cause and the fix only). The PR author is objectstack-fleet[bot], so no login note.

④ Design judgment

RelatedList.tsx. Tried to break the override: a prefers-reduced-motion reader hits the same console rule (it is gated on hover, not motion), so the fix is needed there too and applies there too; a (hover: none) device never had the scale, and transform: none is a no-op for it. A keyboard press (Enter / Space) fires :active on the focused button in Chromium and the stretched ::after would have shrunk too, but the click targets the button itself, so that path was never broken and is not changed. The transform: none is the computed initial value, so nothing else in the header — the chevron's opacity transition, the controls' z-10 stacking above the pseudo-element, the drag source's translate while a panel is dragged (on the handle, not this button) — reads differently. Holds.

Tests. The new assertion sits in the case that pins the row as one control, where it belongs; it reads the DOM property, not the class string, so a later move of the override to a stylesheet would red it and force the author to re-decide the layer question above, which is the right pressure. Holds.

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

  • should-fix (PR body). Add a Runs and ablation line for e6436ddc12: the header test run and eslint / type-check on RelatedList.tsx, and the ablation (drop style={{ transform: 'none' }} → the row-opens case reds on the new expectation). Every other head of this PR carries one; the missing line is a record gap, not a tree defect, and does not block.

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

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

The same context-free review agent ran this round after REWORK 6521198256 (on 8b20d10d4c), 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-30T11:20Z. Scope: Target 1, 2, 10 and 11.

Reason.

  • The blocking case is fixed. The agent pressed the header row's free space between › and ⠿, holding 140 ms before release, at three points per panel (8 px right of ›, the midpoint, 8 px left of ⠿). It did this on two panels each of two accounts and an opportunity, at 1440 × 900 and 1200 × 860. That is 12 panel cases and 36 presses. Every press opened the list (?related=<id>). The element under the pointer while held stayed the open button in all 36; on 8b20d10d4c it flipped to the header div.
  • Target 1 regression pass.
    • At rest: title then chip, nothing on the left, no chevron, no ⤢, and the tooltip 「查看全部 N 条」.
    • Clicking the title, clicking the chip and pressing Enter all open the list.
    • On hover, › and then ⠿ + ⋯, in that order. ⋯ holds 隐藏 only. A held press on the menu's free space neither opens the list nor closes the menu. 隐藏 hides the panel and leaves the URL unchanged.
    • With ⠿ focused, ⌥↑ / ⌥↓ move the panel and keep focus on it. A bare ↓ does nothing, and a click on ⠿ does not open the list.
    • Dragging draws the drop line, and the drop reorders the panels.
    • + opens 「新建联系人」 linked to the account.
    • The foot's 「恢复默认顺序」 restores the order and shows the hidden panels again.
  • Target 2. 「还有 1 个空关联 · 线索列表 · 展开」 follows the non-empty panels, in order. A record whose panels are all empty folds the whole column into one line, and 展开 redraws the panels, each with its own +.
  • Target 10. A seeded v1 record carrying collapsed reads as no choice. After a move and a hide, the stored record's keys are exactly order, hidden, basedOn, v (v: 2), and it persists across a reload. No non-GET request is made during the edits.
  • Target 11. Unchanged by this head. It is verified by code (compactNameField) and the PR's pins only, because no app-crm child names its rows by the FK back.

No deviation in scope. Not judged, as they belong to later PRs: the ?related= face still has the older card header (Target 9, PR ③), and 「查看全部 N 条」 is a native title tooltip.

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

  • The free-space press, each shot taken after a mid-row press: r2-01-freespace-press-{acme,initech,opp-q3}-{1440,1200}.
  • Target 1: r2-02 to r2-08.
  • Target 2: r2-10, r2-11.
  • Target 10: r2-12.

Generated by Claude Code

@objectstack-fleet

Copy link
Copy Markdown
Contributor Author

domain:ui#2 · os-bill · session_01Utp6CY2n1JmZZKrHgtp9jC

席位核对

ACCEPT · head e6436ddc129425adfe0582c7cbf62a80d8f608de · 2026-10-30T11:25Z

  1. Shape: draft → main, Part of #27524 (PR ① of the four serial PRs, 6513066595; the card stays open), title carries !, attribution block on top, root changeset with the BREAKING banner and the ADR-0087 marker. 29 files, +1046 / −451. No assignee, no reviewer.
  2. Card rule met (seat read the diff and the shots): Target 1 (title + count chip as one stretched button that opens the list, interim ?related= face until ③; ⠿ + ⋯ on hover/focus at the right end, ⋯ = 隐藏 only, no collapse, no ⤢), Target 2 (「还有 N 个空关联 · 名字 · 展开」, 展开 scoped to the record via Stack.expandedFor), Target 10 (collapsed gone, codec v2, v1 reads as no choice), Target 11 (first column skips the FK back, authored then declared), pin for 12. Shots 01-account-1440-panels, 02-panel-header-hover, 03-panel-menu-hide-only, 06-opportunity-1440 match. The 时间线 | 关联 control and the folded 动态 card in them are main's, retired by ② and ④.
  3. Gates (check-runs on this head): 46 success, 13 skipped, 0 red. Named runs: Lint & Repo Gates 124033087706, TypeScript Type Check 124033418909, Type Check UI 124030780091, Test UI 124036279293. Dev: 34/34 derived gates exit 0; dogfood's four reds are main's (repaired by test(dogfood): flip the three hook-abort pins to the ruled HOOK_ABORTED wire #27878 / test(dogfood): boot PlatformObjectsPlugin in the Setup/Account entry-view roster, so sys_exchange_rate resolves #27873).
  4. Contract review: owed at CONTRACT_REVIEW_TIER (narrowing face): FAIL 6514756946 on b8c4395be2, fixed, PASS 6515042489 on 8b20d10d4c, then a delta review of the preview fix e6436ddc12 PASS 6521350798 on this head (check-owed-reviews: DONE).
  5. Acceptance 3 (preview verification): ruled B (6520722066) on the maintainer's 「你可以自主验收合并,有问题会开新卡」 (2026-10-30T10:39Z). The card thread's context-free proxy preview review on this head: first round REWORK 6521198256 on 8b20d10d4c (a press on the header's free space opened nothing: the console's unlayered button:active { transform: scale(0.96) } made the pressed button the containing block of its stretched ::after), fixed by e6436ddc12 (transform: none on the open button, pinned, ablated); re-run PASS 6521417551 on this head (36 presses across 12 panel cases at 1440 and 1200, all open the list). The seat also read the dev's console-preview shots on examples/app-crm, which is the accepted surface; HotCRM is not required. The epic seat re-checks the result on main and files cards for any deviation.
  6. Landing: not governed (check-governed-merges --pr 27920: 0 of 29 paths), 1497 changed lines ≤ 3000. The seat lands it: ready + auto-merge now. PR ② opens from the new main after it merges.

Generated by Claude Code

@objectstack-fleet
objectstack-fleet Bot marked this pull request as ready for review October 30, 2026 11:26
@objectstack-fleet
objectstack-fleet Bot enabled auto-merge October 30, 2026 11:26
@objectstack-fleet
objectstack-fleet Bot added this pull request to the merge queue Oct 30, 2026
Merged via the queue into main with commit ad030d8 Oct 30, 2026
60 checks passed
@objectstack-fleet
objectstack-fleet Bot deleted the claude/project-thread-f9tvxt branch October 30, 2026 11:48
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation size/xl tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant