Skip to content

fix(plugin-detail): DetailSection and HeaderHighlight draw the shared EmptyValue - #8528

Merged
os-justin merged 2 commits into
mainfrom
claude/issue-8506-detail-placeholder-shared-emptyvalue
Sep 8, 2026
Merged

fix(plugin-detail): DetailSection and HeaderHighlight draw the shared EmptyValue#8528
os-justin merged 2 commits into
mainfrom
claude/issue-8506-detail-placeholder-shared-emptyvalue

Conversation

@os-justin

Copy link
Copy Markdown
Collaborator

Fixes #8506

Placeholder note: this body spells element names in words (a "span element") rather than in angle brackets, because GitHub's body sanitizer eats tag-shaped fragments even inside backticks and fenced blocks.

What this does

packages/plugin-detail/src/DetailSection.tsx:342 (the details body grid) and packages/plugin-detail/src/HeaderHighlight.tsx:254 (the ADR-0085 highlights strip) each hand-rolled a span element holding a bare em-dash and resolved their own aria-label from detail.noValue. Both now render @object-ui/components' shared EmptyValue, which resolves exactly that key with the same "No value" English fallback through the provider-safe useObjectTranslation.

The card's central premise is falsified — verified here, not inherited

The card was filed as a decision: "adopting the shared EmptyValue would trade a translated accessible name for an untranslated one", and asked whether @object-ui/components had to learn a caller-supplied translated name first. It does not. Verified on origin/main rather than taken on trust:

  • EmptyValue reads t("detail.noValue") and maps a raw-key answer to "No value" itself. Same key, same fallback, different hook.
  • Measured in two non-en packs, because detail.noValue's en value is byte-identical to the literal it replaces and English is green either way (the objectui#7173 trap, in this very package): zh is , de is Kein Wert. Both sites announce those, and the provider-less path announces No value, never a raw key.
  • The em-dash is byte-identical too: glyph's default and both call sites' literal are U+2014, measured by codepoint.

So nothing needed to land in @object-ui/components first, and premise_still_valid is false for the card's stated reason to hesitate.

Per-site judgement, decided rather than averaged

Both sites take the shared typography. Each retired its own text-muted-foreground/60 text-sm and now paints the shared text-muted-foreground/50. That was not a neutral difference: a type-aware cell renderer already draws the same shared component at /50 in the very next row or chip, for a value hasCellValue waves through but the renderer cannot draw. An unparseable datetime is the reachable case (measured on PR #8503, whose card had attributed that branch to DateCellRenderer when it is DateTimeCellRenderer's). So one section could show two dashes in two different greys, one row apart. THE AGREEMENT pins that pair in one render.

Archaeology, since "deliberate" was worth checking rather than asserting: 7ca7203ec (2026-05-22) moved DetailSection from /50 text-xs italic to /60 text-sm and added both the aria-label and the title, three weeks after EmptyValue shipped at /50 (4f33c60d9, 2026-05-01); 471c5d38d (2026-07-16) copied that spelling into HeaderHighlight. Nothing in either commit shows awareness of the shared component, so the /60 reads as continued hand-rolling rather than as a decision against /50.

DetailSection keeps its title, and keeps it ALIVE — this is the one carve-out. The title arrived in the same commit as the aria-label: it is the sighted-mouse counterpart of the accessible name. It also happens to be the only instrument two landed pins use to tell this placeholder apart from a cell renderer's (DetailSection.emptinessAuthority-8376, record-details.emptySectionDefault, both by queryAllByTitle('No value')). It rides through EmptyValue's ...props untouched — but the shared component sets pointer-events-none, and a title on an element that is not a hit target never renders a tooltip: the hover falls through to the row's own title={t('detail.editInlineHint')}. Keeping the attribute without keeping the pointer events would leave a dead attribute that two landed pins go on reading as if it were alive. So that one site merges pointer-events-auto, which cn collapses the shared default away for.

HeaderHighlight takes the shared non-interactive default unchanged. It carries no title and never did, so there is nothing to keep hoverable. It keeps only block, which is layout rather than typography: the filled branch beside it is a block span in the same min-w-0 flex-1 box, and an inline dash sits on a different baseline.

The landed pin whose instrument had to be re-derived

detailPage.emptinessAuthority-8394.test.tsx read the strip's affordance as [aria-label="No value"]:not([data-slot="empty-value"]) — that is, it navigated by exactly the thing this change moves. Once the strip adopted the shared component the selector matched nothing and the case failed over a perfectly correct surface. It now anchors on the field label, which no placeholder change can move, and asserts which chip draws the affordance rather than how many. Its docblock records why, so the next reader does not re-introduce the coupling.

Ablations — run, not predicted

Three legs. Each mutation was proved on disk in both directions (injected marker present, removed text absent) before the instrument ran, each script carried trap ... EXIT INT TERM with absolute paths, and each restore was verified by state (git diff HEAD empty and git hash-object equal to git rev-parse HEAD:PATH for both files), never by exit code.

leg what it does result
THE CARICATURE EmptyValue returned unconditionally from both sites, filled values included 18 of 29 cases red across four files; 8 of 8 in the new pin
THE PLAUSIBLE WRONG FIX the swap made, but each site's own className dropped 3 red, all three on their headline
THE HARNESS KILL the field label removed from both surfaces 19 of 29 red; 8 of 8 in the new pin, loudly

Read the modes, not the count. The first caricature run reddened 7 of 8 and not one of them through its headline assertion — every failure came out of a CONTROL ("CONTROL: the filled text row rendered"), because a surface drawing nothing but placeholders satisfies "the empty row has an accessible name", "the title survives" and "the two branches agree" perfectly well. The eighth, the provider-less fallback case, survived outright. That reading changed the file rather than the write-up: the two NON-REGRESSION cases now state drawingAffordance([...]) first, ahead of their own controls, and the provider-less case gained a value control plus a label saying what it does not decide. Re-measured after that change: 8 of 8, with the two drawingAffordance cases failing on their headline and naming the offenders — expected ['industry','notes','amount'] to deeply equal ['notes']. The other six still fail through a control, which is what a control is for; they are labelled scope declarations on that axis, as are all three cases in the i18n file (their aria-label === '无' headline is true of a page that has given up on values).

The wrong-fix leg is the one that earns the per-site argument above: it is what a mechanical swap actually produces, it is invisible to every assertion that only reads the accessible name, and exactly the three cases that encode the two decisions refuse it — the site restores pointer events for its title, the ONLY class the two branches disagree on is the non-visual pointer-events pair, the chip keeps its block display.

The harness-kill leg exists because a harness that navigates by what the mutation erases dies first and then measures nothing while reading as a strong refusal. Every lookup here is anchored on the field label; removing it reddens all 8 with TestingLibraryElementError: Unable to find an element with the text: industry, i.e. loudly and unmistakably, never as a quiet pass.

Testing

  • pnpm exec vitest run packages/plugin-detail/143 files, 1281 tests, all passing (full package, at the source state of this PR).
  • Final targeted run over every test in the repo that reads this placeholder — enumerated by grep with a lit control, not by memory — 7 files, 41 tests passing: the two new pins, detailPage.emptinessAuthority-8394, DetailSection.emptinessAuthority-8376, record-details.emptySectionDefault, record-details.hideEmptyRetired-7129, RelatedList.emptyPlaceholderAffordance-8475.
  • pnpm --filter @object-ui/plugin-detail --filter @object-ui/components run type-check — both Done; each package's script is tsc --noEmit && tsc -p tsconfig.test.json, so the new test files are inside the measurement rather than excluded from it.
  • ESLint over the seven changed files: exit 0, warnings only (the repo's usual no-explicit-any warnings). Plus an explicit irregular-whitespace sweep with a planted-ZWSP control, since check:control-bytes passing does not cover that class.
  • check:control-bytes, changeset:check, check:i18n-keys, check-changeset-presence.mjs — all green.
  • node scripts/check-governed-queue-guard.mjs --test over the seven paths: NOT GOVERNED.

Declared narrowing. turbo ls --affected names 31 packages, because the diff touches @object-ui/components. That package's change is a docblock only — proved mechanically, not asserted: stripping comments from both the HEAD blob and the working file leaves byte-identical text (2335 chars each), with a control confirming the strip does not hide a real code change. EmptyValue's exported signature, classes and behaviour are untouched, and no test outside plugin-detail reads either changed surface (grepped with lit controls). So its full suite and the other 29 packages were left to CI; a sibling agent shares this container and the shared verify lock was held for roughly six minutes in total.

Changeset

.changeset/8506-detail-placeholders-shared-empty-value.md, patch for both packages. skip-changeset is a phantom label in this repository, so a real changeset was written; major is forbidden here and is not used.

Session for this implementation, in prose so it survives a body edit: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S.

🤖 Generated with Claude Code

https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S


Generated by Claude Code

… EmptyValue

Both files spelled their own `<span>—</span>` for a missing value and resolved
their own accessible name from `detail.noValue`. `@object-ui/components`'
`EmptyValue` resolves EXACTLY that key, with the same "No value" English
fallback, through the provider-safe `useObjectTranslation` — so the accessible
name is byte-identical before and after in every locale, and the placeholder
gains `data-slot="empty-value"`, `no-underline` and the shared `select-none`.

Two per-site decisions, argued rather than averaged:

- Both retire their own `text-muted-foreground/60 text-sm` and take the shared
  `/50`. Not a neutral difference: a type-aware cell renderer already draws the
  same shared component at /50 in the very next row or chip (an unparseable
  `datetime` is the reachable case), so one section could show two dashes in two
  greys.
- `DetailSection` keeps its `title` and keeps it ALIVE. The title arrived in the
  same commit as the aria-label and is the sighted-mouse counterpart of it; two
  landed pins read it as the only instrument telling this placeholder apart from
  a cell renderer's. It rides through `...props`, but the shared component is
  `pointer-events-none` and a title on a non-hoverable element never renders a
  tooltip, so this one site restores `pointer-events-auto`. `HeaderHighlight`
  has no title and takes the shared non-interactive default unchanged; it keeps
  only `block`, which is layout, not typography.

`detailPage.emptinessAuthority-8394`'s strip instrument is re-derived: it read
`[aria-label="No value"]:not([data-slot="empty-value"])`, i.e. it navigated by
exactly the thing this change moves, so it failed over a correct surface. It now
anchors on the field LABEL and asserts WHICH chip draws the affordance.

`EmptyValue`'s docblock stops calling its glyph default an "en-dash"; it is and
always was U+2014. Comment only — measured code-identical after comment strip.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
…hrough `title` a dead attribute

Records the trap the objectui#8506 adoption found, at the shared component
rather than only at the one call site that hit it: `title` rides through
`...props` and stays in the DOM, so `getAttribute('title')` is green over a
tooltip that can never render. Comment only — measured code-identical after
comment strip (2335 chars either side, with a control confirming the strip
does not hide a real change).

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

github-actions Bot commented Sep 8, 2026

Copy link
Copy Markdown
Contributor

❌ Console Performance Budget

Metric Value Budget
Eager closure (gzip, 50 chunks) 3474.1 KB 3512.7 KB
Main entry chunk (gzip) 143.9 KB 350 KB
Entry file index-DYxP-tCC.js
Status FAIL

The eager closure is every chunk the entry reaches through static imports — what the browser fetches and parses before the app renders. The entry chunk on its own is a small fraction of it.

Which half objected:

Eager-closure half Verdict
Aggregate closure ceiling ✅ pass
Per-chunk ceilings ❌ over its ceiling
Ceiling sensitivity (headroom) ✅ pass
Ceiling freshness (checkout vs. base branch) ✅ pass

📦 Bundle Size Report

Package Size Gzipped
app-shell (consoleActionDispatch.js) 0.20KB 0.19KB
app-shell (index.js) 15.67KB 5.75KB
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) 498.87KB 114.10KB
core (index.js) 7.48KB 2.96KB
create-plugin (index.js) 10.12KB 3.28KB
data-objectstack (index.js) 191.36KB 53.16KB
fields (index.js) 243.24KB 61.42KB
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) 6.57KB 2.76KB
i18n (index.js) 3.65KB 1.47KB
i18n (pickLocalized.js) 7.62KB 3.26KB
i18n (provider.js) 26.89KB 9.04KB
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.84KB 10.94KB
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) 11.71KB 4.29KB
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) 5.12KB 1.74KB
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) 15.16KB 3.68KB
plugin-calendar (index.js) 49.00KB 13.91KB
plugin-charts (index.js) 71.39KB 19.92KB
plugin-chatbot (index.js) 194.53KB 46.34KB
plugin-dashboard (index.js) 131.43KB 34.44KB
plugin-designer (index.js) 213.21KB 43.63KB
plugin-detail (index.js) 248.46KB 63.90KB
plugin-editor (index.js) 2.23KB 1.05KB
plugin-form (index.js) 131.01KB 32.32KB
plugin-gantt (index.js) 167.16KB 40.99KB
plugin-grid (index.js) 208.30KB 56.63KB
plugin-kanban (index.js) 55.40KB 15.71KB
plugin-list (index.js) 112.74KB 27.70KB
plugin-map (index.js) 20.49KB 6.83KB
plugin-markdown (index.js) 13.88KB 4.80KB
plugin-report (index.js) 43.42KB 11.92KB
plugin-timeline (index.js) 30.10KB 8.74KB
plugin-tree (index.js) 9.33KB 3.25KB
plugin-view (index.js) 84.54KB 20.84KB
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) 81.07KB 26.86KB
react (data-invalidation.js) 5.05KB 2.08KB
react (index.js) 4.63KB 2.18KB
react (schema-input.js) 2.32KB 1.24KB
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.55KB 2.45KB
sdui-parser (input-type.js) 2.84KB 1.40KB
sdui-parser (parse.js) 20.57KB 5.88KB
sdui-parser (provenance.js) 3.66KB 1.82KB
sdui-parser (types.js) 0.28KB 0.23KB
sdui-parser (validate.js) 13.64KB 4.59KB
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 (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

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

2 participants