Skip to content

fix(plugin-detail): an interpolated titleFormat H1 hides no field row (objectui#8351, ruled option B) - #9437

Merged
os-tesla merged 1 commit into
mainfrom
claude/issue-8351-titleformat-dedupe-ladder
Sep 13, 2026
Merged

fix(plugin-detail): an interpolated titleFormat H1 hides no field row (objectui#8351, ruled option B)#9437
os-tesla merged 1 commit into
mainfrom
claude/issue-8351-titleformat-dedupe-ladder

Conversation

@os-tesla

Copy link
Copy Markdown
Collaborator

Fixes #8351

Implements the ruled option B (director seat, decision batch #82, 2026-09-08, maintainer 「同意」): in plugin-detail, an interpolated titleFormat H1 names no single field, so the dedupe ladder hides no row.


ZONE 2 measurements — both assumptions checked before any edit

1. Did plugin-detail move under objectui#9299? For this card's surface, no. objectui#9299 landed as 7098eed (this branch's base). Its plugin-detail diff extracted RecordDetailDrawer's payload into RecordDetailPanel and mounted it through NavigationOverlay — the record overlay shell. The dedupe ladder was not in that diff: git log -- packages/plugin-detail/src/renderers/record-details.tsx stops at 345e24a, two cards earlier. And the extraction did not relocate the H1 — RecordDetailPanel.tsx has zero occurrences of h1, title, DisplayName, page:header or record:details. The ladder is where the card says it is, in packages/plugin-detail/src/renderers/record-details.tsx, and the H1 is still drawn a package away by @object-ui/components' PageHeaderRenderer.

2. What "fully interpolates" is measured against — established from the code, and it is NOT a bare presence test.

The chain already owns both instruments, so no new predicate was written:

  • formatTitleTemplate (packages/core/src/utils/record-title.ts) renders the template. It is what getRecordDisplayName step 3 calls and what this package's own DetailView.resolveDisplayTitle step 2 calls, so all three agree about what a titleFormat produces on a given record.
  • recordDisplayValueAt — the emptiness authority objectui#8350 made this ladder import rather than re-spell — then answers the only question a dedupe has: is that rendered string some candidate's value?

Three outcomes, and the two that are not the headline case are what keep the rule honest:

rendered title H1 is… ladder
a composite no candidate's value equals not any one field's value hides nothing — the ruled case
empty (no placeholder resolved) the declared pointer; the header already walked past this rung existing value-keyed walk, unchanged
collapsed onto one field's value exactly that field's value still hides that row

⚠️ A presence-only reading was measured and rejected. Suppressing whenever a titleFormat merely resolves prints Contract No: HT-2026-003 directly beneath an H1 reading HT-2026-003, on every record where a blank placeholder was dropped with its orphan separator — which is precisely the duplication Phase P.0 of this ladder exists to remove. It would also make the lit control this PR was asked for unconstructible: the suggested route's "a titleFormat that does not fully interpolate, where the ladder must still hide" only exists under a reading where partial interpolation still hides.

⚠️ The match is a SCAN of the candidates, not a peek at the first one with a value. With titleFormat: '{name}' over nameField: 'contract_no', the first resolving candidate is contract_no while the H1 is name's value — stopping early hides the wrong row and leaves the real duplicate, the objectui#8175 shape one rung along. Pinned.


Red / green legs

The pin is lit, measured twice, in both directions:

  • Before the implementation existed (new test file only, on 7098eed): Test Files 1 failed, Tests 2 failed | 5 passed (7). The two reds are HALF 1 — KEEPS the declared nameField row and hides the row the TEMPLATE names….
  • After (425cd85): Test Files 1 passed (1), Tests 7 passed (7).
  • Ablation from the committed state, with on-disk proof and a trap-guarded restore: the rung's read site was neutralised (const interpolatedTitle = ''), anchor occurrences 1 → 0, injected marker count 1, on-disk hash differs from the HEAD blob — then Tests 2 failed | 5 passed (7), the same two cases. Restore verified two ways, not by an exit code: git diff HEAD empty and git hash-object equal to the HEAD blob. No dist/ is on the path — the pin imports ../record-details relatively, from source in the same package — so no rebuild leg was owed.

The remaining five cases include three deliberate controls that pass on both sides (an object with no titleFormat dedupes exactly as before; a template resolving to nothing leaves the ladder alone; a single-field {name} template still dedupes) — without them, a change that simply disabled the H1 dedupe outright would pass everything.


Gates run locally

All at 425cd85; the working tree is byte-identical to it (git diff HEAD empty).

  • pnpm exec vitest run packages/plugin-detail/176 files, 1632 tests, all passed (under the shared os-verify-lock.sh).
  • pnpm --filter @object-ui/plugin-detail run type-checkexit 0 (tsc --noEmit && tsc -p tsconfig.test.json, so the new pin is type-checked too). ⚠️ This needed pnpm --workspace-concurrency=2 --filter '@object-ui/plugin-detail^...' build first: without the dependency dist/*.d.ts it reports TS2307 Cannot find module for every workspace import — a missing prerequisite, not a red gate.
  • pnpm exec eslint packages/plugin-detail --format json233 files, 0 errors, 1013 warnings (all pre-existing no-explicit-any / react-refresh, the same shape the neighbouring pins carry). Declared narrowing against the repo-wide Lint job: the population is eslint's own file selection for that directory, not a glob of mine; the count is read from --format json; and eslint.config.js enables no type-aware linting (no parserOptions.project, no projectService), so no rule crosses a file boundary and this diff cannot move the verdict on any file it does not itself contain. The repo-wide run is CI's.
  • node scripts/check-changeset-presence.mjs — exit 0, 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s).
  • node scripts/check-changeset-no-major.mjs — exit 0.
  • check:control-bytes · check:test-path-roots · check:changeset-claims · check:unreferenced-sources · check:new-line-citations · check:comment-mask-corpus · check:vi-mock-specifiers — all exit 0. Plus a direct control-byte scan of the three changed files (grep -naP over the C0 range): no hits.
  • node scripts/check-governed-queue-guard.mjs --testNOT GOVERNED for all three paths.

NOT MEASURED: check:sdui-registration-pins❌ No console build to weigh at apps/console/dist/assets. A missing prerequisite (it weighs an apps/console build this worktree has never produced), not a verdict on this diff. Left to CI.

Not run locally, by design: the repo-wide Lint / Type Check / the four Test shards / Build & E2E / Build Docs. Those are the farm's; CI convergence is the PM's read, not a local one.


Acceptance notes

Out of scope, observed while measuring, noted and not filed:

  • @object-ui/components' PageHeaderRenderer and @object-ui/core's formatTitleTemplate are two different interpolators for the same titleFormat. The header's local interpolate matches {[a-zA-Z0-9_.]+} and then refuses the result if a { survived; formatTitleTemplate matches {[^{}]+}, normalises handlebars {{x}} first, and strips orphan separators. So a token such as a name containing a space is a value to one and a literal to the other, and the two halves can disagree about whether the template rung resolved at all. Successor: objectui#9436, whose whole subject is that code region and which must weigh both interpolators whichever way it is ruled — this is not a card of its own, it is context that card needs.
  • DetailView.resolveDisplayTitle is a third ranking of the same rungs, inside this package (primaryFieldtitleFormat → unified resolver → schema.title). It reads schema.primaryField off the view schema, which is legitimate and explicitly protected by objectui#7586, so this is an observation about how many orderings exist, not a defect. Successor: objectui#9436 — the same ruling decides whether three orderings collapse to one.

Neither is filed separately: both are the same ordering question that card asks, and a card whose answer is "see objectui#9436" is noise in the queue.


Option C — filed, not folded in

The ruling placed an obligation on whoever took this card. objectui#9436 is now open, carrying needs-user-decision / domain:ui / priority:p2. It states plainly that the change moves what the H1 shows on existing records, and it names the currently-green pin it would retire: packages/components/src/__tests__/page-header-title.test.tsx, case titleFormat still outranks nameField.

⛔ Nothing in this PR touches @object-ui/components, that pin, or the ordering. This ladder deliberately does not depend on which rung wins the header: it compares the rendered title against the candidates' values, which answers the dedupe under either order — so objectui#9436 can be ruled either way without reopening this.

⛔ Also untouched, excluded by the ruling by name: page:header's own schema.title. It is a key on the header schema, which record:details never receives, so nothing in this package can answer it.

Dedup before filing: /search/issues is 403 in this container, so REST list endpoints plus a local grep over all 429 open issues, all 25 needs-user-decision issues and PRs (state=all), this card's cross-reference timeline, and 1902 closed issues — 38 cards matched the six keywords, all read, none asking the precedence question. Two control words fired on objectui#8351 in the same pass (titleFormat still outranks nameField → 1, PageHeaderRenderer ranks → 1), so the zero is a reading rather than a dead channel.


Files changed

  • packages/plugin-detail/src/renderers/record-details.tsx — the rung, plus the ladder docblock, which had recorded both these divergences as "deliberately NOT repaired here"; one of the two now is, and the comment says which and why the other cannot be.
  • packages/plugin-detail/src/renderers/__tests__/record-details.titleFormatNoDedupe-8351.test.tsx — new pin, 7 cases.
  • .changeset/8351-titleformat-hides-no-row.mdpatch on @object-ui/plugin-detail; user-visible, so a real bump rather than an empty declaration.

🤖 Generated with Claude Code

https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa


Generated by Claude Code

objectui#8351, maintainer ruling 2026-09-08 (decision batch #82), option B.

`record:details`' dedupe ladder answers "which single field is the H1
showing". A `titleFormat` rung answers with a rendered TEMPLATE, which on a
multi-field format is no field's value at all — so the ladder walked past it
onto `resolveNameField` and hid THAT row, a row the H1 never showed. Nothing
errored; a field was simply absent from the grid.

"Fully interpolates" is measured with the instruments already in the chain,
not a new predicate: `formatTitleTemplate` (what `getRecordDisplayName` step 3
and this package's own `DetailView.resolveDisplayTitle` step 2 both call)
renders the title, and `recordDisplayValueAt` — the emptiness authority the
rest of the ladder already imports — answers whether that string IS some
candidate's value. Three outcomes:

- composite, no candidate's value equals it -> hide NOTHING (the ruled case);
- renders nothing on this record -> the header has already walked past this
  rung, so the existing value-keyed walk runs unchanged;
- collapses onto ONE field's value (a blank placeholder dropped with its
  orphan separator, or a single-field format) -> that row IS the duplicate
  and still goes.

The match is a SCAN of the candidates rather than a peek at the first one
with a value: with `titleFormat: '{name}'` over `nameField: 'contract_no'`
the first resolving candidate is `contract_no` while the H1 is `name`'s
value, so stopping early hides the wrong row and leaves the real duplicate.

Deliberately untouched, by the same ruling: `page:header`'s own
`schema.title` (invisible to this package), and the ORDER in which
`PageHeaderRenderer` ranks `titleFormat` against the ADR-0079 pointer — that
moves what the H1 shows on existing records and retires a green pin, so it
carries its own `needs-user-decision` card. This ladder does not depend on
which of the two wins the header.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_011QreXiyMEqKLN4U5daMPVa
@github-actions

Copy link
Copy Markdown
Contributor

⚠️ Console Performance Budget — gauge not trustworthy

The eager closure was measured, but one of the ceilings it is measured against no longer means what it names, so this run carries no pass/fail verdict for the performance budget.

This is not a budget violation. Nothing grew: the half marked below is a verdict about the gauge, and a ceiling that has stopped measuring anything can neither clear a bundle nor condemn one.

Step Outcome
Build packages success
Check console performance budget failure

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ✅ pass
Ceiling sensitivity (headroom) ⚠️ broken gauge
Ceiling freshness (checkout vs. base branch) ✅ pass

⚠️ A broken gauge half is a verdict about the ceiling, not about the bundle: that line has drifted out of range of the regression it exists to catch, or the report behind it cannot be trusted. It does not say anything grew. The Check console performance budget step log carries the ceiling and the number it was compared against.

Reason: The entry chunk measured 144.5 KB, but the eager-closure half of this gate returned no trustworthy VERDICT: the report could not be read, a ceiling has drifted out of range of the regression it must catch, or (objectui#6245) a ceiling was replaced on the base branch after this checkout was made. The step log says which. This is not a passing budget — and it is not a size regression either.

See the workflow run for details.


📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 16.69KB 6.21KB
app-shell (runtime-config.js) 20.68KB 7.36KB
app-shell (types.js) 0.01KB 0.04KB
app-shell (urlParams.js) 10.06KB 3.86KB
auth (ActiveOrganizationStorage.js) 25.05KB 9.16KB
auth (AuthContext.js) 0.31KB 0.24KB
auth (AuthGuard.js) 2.07KB 1.00KB
auth (AuthProvider.js) 40.18KB 10.59KB
auth (AuthShell.js) 3.49KB 1.40KB
auth (ForgotPasswordForm.js) 12.21KB 3.45KB
auth (LoginForm.js) 18.15KB 5.39KB
auth (PreviewBanner.js) 0.90KB 0.50KB
auth (RegisterForm.js) 6.65KB 2.22KB
auth (SocialSignInButtons.js) 9.61KB 3.89KB
auth (UserMenu.js) 3.41KB 1.23KB
auth (auth-gate-events.js) 1.29KB 0.66KB
auth (authStyles.js) 5.04KB 1.72KB
auth (createAuthClient.js) 40.21KB 10.80KB
auth (createAuthenticatedFetch.js) 8.46KB 3.43KB
auth (index.js) 3.19KB 1.44KB
auth (invitation-status.js) 1.22KB 0.70KB
auth (org-roles.js) 6.66KB 2.78KB
auth (phone-identifier.js) 1.11KB 0.66KB
auth (types.js) 0.59KB 0.35KB
auth (useAuth.js) 5.30KB 1.02KB
auth (useWorkspaceAdminStatus.js) 11.08KB 4.58KB
collaboration (CommentThread.js) 26.08KB 7.56KB
collaboration (LiveCursors.js) 3.17KB 1.27KB
collaboration (PresenceAvatars.js) 6.49KB 2.64KB
collaboration (PresenceProvider.js) 2.79KB 1.13KB
collaboration (index.js) 1.68KB 0.73KB
collaboration (useCollaborationTranslation.js) 6.05KB 2.52KB
collaboration (useCommentSearch.js) 1.98KB 0.88KB
collaboration (useConflictResolution.js) 7.75KB 1.86KB
collaboration (useMentionNotifications.js) 1.81KB 0.68KB
collaboration (usePresence.js) 6.33KB 1.84KB
collaboration (useRealtimeSubscription.js) 7.91KB 2.01KB
components (index.js) 502.94KB 115.47KB
core (index.js) 8.52KB 3.41KB
create-plugin (index.js) 27.94KB 9.51KB
data-objectstack (index.js) 213.54KB 59.33KB
fields (index.js) 247.89KB 62.50KB
i18n (LocalizationContext.js) 1.76KB 0.96KB
i18n (builtinAggregateLabels.js) 0.86KB 0.49KB
i18n (currency.js) 1.22KB 0.64KB
i18n (fallbackInterpolation.js) 6.25KB 2.77KB
i18n (i18n.js) 8.87KB 3.64KB
i18n (index.js) 5.22KB 2.26KB
i18n (pickLocalized.js) 9.86KB 3.95KB
i18n (provider.js) 32.15KB 10.49KB
i18n (useDisplayLocale.js) 2.85KB 1.45KB
i18n (useObjectLabel.js) 34.34KB 9.17KB
i18n (useSafeTranslation.js) 5.60KB 2.33KB
layout (index.js) 38.83KB 10.95KB
mobile (MobileProvider.js) 0.92KB 0.49KB
mobile (ResponsiveContainer.js) 0.94KB 0.38KB
mobile (breakpoints.js) 1.51KB 0.70KB
mobile (createOfflineDataSource.js) 5.61KB 1.75KB
mobile (index.js) 1.99KB 0.87KB
mobile (offlineQueue.js) 3.91KB 1.35KB
mobile (pwa.js) 0.97KB 0.49KB
mobile (serviceWorker.js) 1.48KB 0.62KB
mobile (serviceWorkerSource.js) 3.41KB 1.48KB
mobile (useBreakpoint.js) 1.54KB 0.65KB
mobile (useGesture.js) 6.96KB 1.98KB
mobile (useOfflineSync.js) 1.99KB 0.72KB
mobile (usePullToRefresh.js) 2.53KB 0.85KB
mobile (useResponsive.js) 0.72KB 0.42KB
mobile (useSpecGesture.js) 4.39KB 1.66KB
mobile (useTouchTarget.js) 1.01KB 0.54KB
permissions (MePermissionsProvider.js) 13.52KB 4.88KB
permissions (PermissionContext.js) 0.31KB 0.25KB
permissions (PermissionGuard.js) 0.89KB 0.45KB
permissions (PermissionProvider.js) 6.24KB 2.16KB
permissions (discardProofCache.js) 1.04KB 0.55KB
permissions (evaluator.js) 8.39KB 3.10KB
permissions (index.js) 0.93KB 0.41KB
permissions (store.js) 0.91KB 0.42KB
permissions (useFieldPermissions.js) 1.28KB 0.53KB
permissions (usePermissions.js) 4.83KB 2.27KB
plugin-ai (index.js) 14.81KB 3.63KB
plugin-calendar (index.js) 49.92KB 14.22KB
plugin-charts (index.js) 71.33KB 19.90KB
plugin-chatbot (index.js) 195.34KB 46.51KB
plugin-dashboard (index.js) 131.44KB 34.65KB
plugin-designer (index.js) 215.94KB 44.33KB
plugin-detail (index.js) 252.27KB 65.55KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 136.71KB 34.16KB
plugin-gantt (index.js) 167.62KB 41.26KB
plugin-grid (index.js) 212.55KB 57.83KB
plugin-kanban (index.js) 46.63KB 14.53KB
plugin-list (index.js) 112.53KB 27.63KB
plugin-map (index.js) 21.48KB 6.99KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.41KB 11.93KB
plugin-timeline (index.js) 30.07KB 8.74KB
plugin-tree (index.js) 10.58KB 3.72KB
plugin-view (index.js) 84.36KB 20.78KB
providers (DataSourceProvider.js) 0.75KB 0.39KB
providers (MetadataProvider.js) 1.37KB 0.59KB
providers (ThemeProvider.js) 1.90KB 0.85KB
providers (UploadProvider.js) 11.66KB 3.50KB
providers (index.js) 0.45KB 0.23KB
providers (types.js) 0.01KB 0.04KB
react-runtime (index.js) 5.62KB 2.34KB
react (LazyPluginLoader.js) 4.47KB 1.63KB
react (SchemaRenderer.js) 96.00KB 31.71KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 4.25KB 2.04KB
react (spec-input.js) 0.20KB 0.18KB
sdui-parser (codegen.js) 6.58KB 2.74KB
sdui-parser (dashboard-widget-options.js) 3.08KB 1.30KB
sdui-parser (index.js) 5.66KB 2.50KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (kanban-quick-add.js) 3.89KB 1.87KB
sdui-parser (parse.js) 25.28KB 7.80KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 14.82KB 4.99KB
types (ai.js) 0.20KB 0.17KB
types (api-types.js) 0.20KB 0.18KB
types (app.js) 2.87KB 1.00KB
types (base.js) 0.20KB 0.18KB
types (blocks.js) 0.20KB 0.18KB
types (complex.js) 2.93KB 1.49KB
types (crud.js) 0.20KB 0.18KB
types (dashboard-filter-alias.js) 6.23KB 2.74KB
types (data-display.js) 3.75KB 1.85KB
types (data-protocol.js) 0.20KB 0.19KB
types (data.js) 0.20KB 0.18KB
types (designer.js) 1.85KB 0.85KB
types (disclosure.js) 0.20KB 0.18KB
types (error-code.js) 1.54KB 0.88KB
types (expression.js) 0.20KB 0.18KB
types (feedback.js) 0.20KB 0.18KB
types (field-types.js) 0.20KB 0.18KB
types (form.js) 0.20KB 0.18KB
types (http-inflight.js) 8.87KB 3.73KB
types (http-retry.js) 4.32KB 2.02KB
types (icon-key-migration.js) 4.26KB 1.63KB
types (index.js) 4.74KB 2.25KB
types (layout.js) 0.20KB 0.18KB
types (managed-by.js) 0.19KB 0.18KB
types (mobile.js) 4.73KB 2.28KB
types (navigation.js) 0.20KB 0.18KB
types (objectql.js) 0.20KB 0.18KB
types (overlay.js) 0.20KB 0.18KB
types (permissions.js) 0.20KB 0.18KB
types (plugin-scope.js) 0.20KB 0.18KB
types (record-components.js) 0.20KB 0.19KB
types (record-semantics.js) 1.28KB 0.67KB
types (registry.js) 0.20KB 0.18KB
types (reports.js) 0.20KB 0.18KB
types (select-option.js) 0.20KB 0.19KB
types (spec-report.js) 5.05KB 1.93KB
types (spec-ui-namespace.js) 0.20KB 0.19KB
types (strict-authoring-face.js) 14.04KB 5.36KB
types (system-fields.js) 3.33KB 1.54KB
types (theme.js) 6.28KB 2.87KB
types (ui-action.js) 8.11KB 3.32KB
types (views.js) 0.20KB 0.18KB
types (widget.js) 0.20KB 0.18KB

Size Limits

  • ✅ Core packages should be < 50KB gzipped
  • ✅ Component packages should be < 100KB gzipped
  • ⚠️ Plugin packages should be < 150KB gzipped

Copy link
Copy Markdown
Collaborator Author

Bundle Analysis red here is the standing ui-components ceiling debt — ⛔ not this PR's

domain:ui PM seat, session_011QreXiyMEqKLN4U5daMPVa (os-tesla). Recorded per PR, as queueing with a red non-required check requires.

Every budget in the job passes; the single ❌ is the checker's gauge verdict (exit 2) that the ui-components per-chunk row's headroom has fallen under its 0.10x floor. The gate's own output says the row is "a standing debt that predates this change", that the verdict is "NOT an accusation that your diff spent the bytes", and that "there is therefore nothing here for this pull request to 'fix'" — with both greening edits (raise the ceiling, raise the allowance) ⛔ forbidden.

Taken where the gate directs: objectui#9251 (the open decision on the chunk) with objectui#9204 as the implementation row — both already exist and are held by the seat working that chunk ⇒ ⛔ no duplicate filed. This PR ⛔ edits neither ceiling nor allowance.

performance-budget.yml declares only push and pull_request — ⛔ no merge_group — and its own comment records that it "cannot hold the merge queue". Outside the 9-check required set.


Generated by Claude Code

@os-tesla
os-tesla marked this pull request as ready for review September 13, 2026 22:24
@os-tesla
os-tesla added this pull request to the merge queue Sep 13, 2026
Merged via the queue into main with commit e2feb13 Sep 13, 2026
37 of 38 checks passed
@os-tesla
os-tesla deleted the claude/issue-8351-titleformat-dedupe-ladder branch September 13, 2026 22:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants