fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default
Sep 1, 2026
Merged

fix(plugin-detail): let DetailSection's heuristic own the empty-section default#7123
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7064

Stops RecordDetailsRenderer forcing hideEmpty: s.hideEmpty ?? true onto every
section it synthesizes, so DetailSection's own stated heuristic owns the
unauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).

The one-line diff is hideEmpty: s.hideEmpty ?? true, becoming
hideEmpty: s.hideEmpty, — the authored value now passes through untouched.

Force sites swept

hideEmpty occurs in 5 source files across the repo. Exactly one site
defaulted a record:details section's hideEmpty, and it is the one this PR
changes:

siteverdict
packages/plugin-detail/src/renderers/record-details.tsx:210fixed — the ruled site
packages/plugin-detail/src/DetailSection.tsx (heuristic + hideEmptyEffective + toggle)left alone — this is the heuristic the ruling hands the case to
packages/plugin-detail/src/renderers/record-reference-rail.tsx:232 (schema.hideEmpty !== false)left alone, reported — different surface (record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.
packages/plugin-detail/src/index.tsx:731the rail's registered input (defaultValue: true) — belongs to the row above
packages/types/src/views.ts:230the type declaration; unchanged

No other renderer or mapper independently drops empty sections. DetailView
renders schema.sections.map(...) unconditionally — every section-hiding
decision is DetailSection's single early return.

Heuristic verified by rendering, not inferred from the comment

Rendered RecordDetailsRenderer over a sparse record inside a real
RecordContextProvider and dumped the DOM. container.textContent for an
all-empty 4-field section:

Deal Termsstage—amount—close_date—next_step—

The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(aria-label="No value" title="No value"). The heuristic is fully implemented;
the forced default was the only thing suppressing it.

Precedence chain

hideEmpty is per section only — there is no page-level or view-level
hideEmpty with competing precedence. The effective chain inside
DetailSection is:

  1. the user's per-session toggle (showEmptyOverride) wins over everything;
  2. otherwise empties hide when section.hideEmpty is truthy or
    shouldAutoHideEmpty fires;
  3. shouldAutoHideEmpty requires !section.hideEmpty, not editing, at least 4
    fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
    filled row
    — that last clause is what reserves the all-empty case;
  4. the whole section returns null only when every field is empty and all of
    them got filtered out.

⚠️Measured, and worth a decision:hideEmpty: false is not an
override. Step 3 reads !section.hideEmpty, so an authored false is
indistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR
?? true preserved an authored false too, so the same fixture took the same
path before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.

Behaviour delta (named in the changeset)

  • an all-empty section renders heading, labels and placeholders — it used to
    render nothing;
  • a small partly-empty section (below the 4-field / 25% thresholds) now
    shows its empty rows;
  • a large mostly-empty section with a filled row still auto-hides, toggle
    and all — the label-graveyard guard is intact;
  • empty rows are now visible while inline-editing, so an unwritten field can be
    filled in place (shouldAutoHideEmpty already excluded isEditing; the
    forced default overrode that too).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds a
real sample record, so a record:details block over a sparse sample now
previews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shell was touched. No application metadata anywhere needs
editing — that is the point of the ruling.

Tests

New pin file packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx
(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle, hideEmpty: true hiding an all-empty
section (with a sibling section as the render control), hideEmpty: true hiding
rows in a partly-filled section, hideEmpty: false showing them, and the
measured false-is-not-an-override case.

No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline packages/plugin-detail/ was 119 files /
1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned ?? true.

All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.

runverdict
pnpm exec vitest run packages/plugin-detail/at 1895c0965Test Files 120 passed (120) · Tests 1107 passed (1107) · lock VERDICT command-exit 0
baseline, same command at 71d83a6b1Test Files 119 passed (119) · Tests 1100 passed (1100)
15 consumer test files referencing record:details in apps/console, packages/app-shell, packages/core, packages/typesTest Files 15 passed (15) · Tests 329 passed (329)
pnpm --filter "@object-ui/plugin-detail" type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
pnpm --filter "@object-ui/plugin-detail" lint (eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existing
node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).
pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
pnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OK
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0
pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.
pnpm check:sdui-registration-pinsfirst run exit 2, NOT MEASURED (a run with nothing to read has measured nothing. Build the console first); after building the console closure and the console: ✅ All 16 registration(s) a sideEffects array promises are present in the built console

Typecheck coverage is proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists the new pin file (1 hit) alongside the existing
renderers/__tests__/record-details.test.tsx control (1 hit) out of 1712 files,
so the "typecheck is clean" claim really covers the new tests.

Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement: eslint.config.js declares no project /
projectService / parserOptions, so type-aware linting is off and a change in
these two files cannot move any verdict on an untouched file; --format json
over the two changed files reports 2 files, 0 errors, and 32 warnings all of
the pre-existing no-explicit-any / react-refresh families the package
already carries 894 of. The repo-wide run stays CI's.

Ablation

Predicted before running: restoring ?? true turns exactly 2 of the 7 pins
red — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because ?? true is a no-op on an authored value and produces an
identical DOM on the auto-hide path.

Observed:Tests 2 failed | 5 passed (7), and the two failures are exactly
the two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:

TestingLibraryElementError: Unable to find an element with the text: Deal Terms.

…and the body printed by that failure is a div.space-y-6 wrapping a single
self-closed div.space-y-3 sm:space-y-4 — the section list element with no
section inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)

No rebuild leg is owed: vitest.config.mts aliases every @object-ui/*
specifier to that package's src, and the pin file imports ../record-details
relatively, so the mutation runs from source with no dist in the path.

Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (showBorder: s.showBorder) 1, blob
bee9ec88115d566c98ba314ca665ddd0f426f5c1 to
4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exit
code: git checkout HEAD -- against an absolute path (with an EXIT INT TERM
trap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1 and git diff HEAD empty. The final
suite run above is on the restored tree.

Falsified assumptions, reported not fixed

  1. hideEmpty is not authorable on a record:details section at
    @objectstack/spec 17.2.0 — it is refused.
    The issue and the dispatch both
    describe authored hideEmpty as a declared opt-out. Measured against the
    installed spec: RecordDetailsProps.safeParse on a section carrying
    hideEmpty: true returns success: false with
    unrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on this
    record:details section"
    . Control in the same probe: a section carrying
    columns: 2 parses and the value survives. So for any spec-validated page the
    key never reaches this renderer at all, and the old ?? true was an
    unconditional platform policy with no author escape hatch. This PR is
    unaffected — the renderer still honours an authored value for the schemas that
    carry one (@object-ui/types declares DetailViewSection.hideEmpty), and the
    flip is what actually gives spec-validated pages the right behaviour with zero
    authoring. Flagged because the ruling's second clause reads differently once
    you know this.
  2. Stale comment, left alone.packages/plugin-detail/src/index.tsx:426-431
    says the spec's section object "STRIPS" undeclared keys on parse. Since the
    #4001 batch A work it refuses them loudly instead, per the measurement
    above. Not in this card's class and in a file this PR does not otherwise
    touch, so it is reported rather than fixed.
  3. No existing test encoded the old default (detail above).

hotcrm acceptance is unverified by me

The acceptance scenario names hotcrm opportunity_detail_page and
case_detail_page on hand-created records. hotcrm is not in this session's
repo scope
and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRenderer through the real DetailView and DetailSection with
sparse fixtures. The hotcrm scenario needs a run by someone with that repo.

Open question for the maintainer

Should hideEmpty: false become a hard override of the auto-hide heuristic?
Today it means "not true" (see Precedence chain). Left exactly as found and
pinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes false on a large sparse section today gets
no change in behaviour, which is its own quiet surprise.


Generated by Claude Code

…on default
`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic -- "If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide -- the labels themselves are useful
as a structural skeleton" -- and the forced default overrode exactly the case
that sentence reserves. On a hand-created sparse record whole sections
disappeared and the body collapsed to a couple of rows.
The renderer now passes the authored value through untouched. An unauthored
section reaches DetailSection as `undefined` and the heuristic decides; an
authored `hideEmpty` keeps its exact former meaning.
Also drops a non-English comment from the slot (AGENTS.md commandment #-1).
Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton,
the below-threshold empty row, the intact label-graveyard guard, and both
authored directions.
Maintainer ruling 2026-08-31; objectui#7064.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3151.4 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-xcLGHyrB.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges; I will not flip ready on in_progress.

⭐⭐ The finding that outranks the fix: the ruling's second clause describes an escape hatch that does not exist

The card and my dispatch both called authored hideEmpty "the declared opt-out". Measured against installed @objectstack/spec 17.2.0: RecordDetailsProps.safeParse on a section carrying hideEmpty returns success: false, unrecognized_keys: ['hideEmpty']refused, not accepted — against a control in the same probe (columns: 2) that parses and whose value survives.

⇒ For any spec-validated page the key never reaches this renderer at all. So the old ?? true was not "a default over an authorable key" — it was an unconditional platform policy with no author escape hatch whatsoever. Which makes this flip do more than the ruling claimed: it is the only thing that gives spec-validated pages the ruled behaviour, and no app can opt out even if it wanted to.

ZONE 1 rule 2 is still satisfied in code — the renderer honours an authored value verbatim for schemas that carry one — so the PR is correct as built. But the ruling's reasoning rested on a premise measurement contradicts, and that goes to the maintainer rather than being quietly absorbed.

I verified the objectui half of the divergence myself, since it is a three-party disagreement about one key:

partyhideEmpty on a record:details section
@objectstack/spec 17.2.0⛔ refuses (unrecognized_keys)
@object-ui/typesviews.ts:230✅ declares — hideEmpty?: boolean
packages/types/src/zod/views.zod.tsabsent — 0 hits, control headerColor = 2 in the same file
RecordDetailsRenderer✅ honours

The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.

⭐ You falsified my assumption about the test suite, and proved it rather than asserting it

I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.

The heuristic was verified by rendering, which the card's own framing invited you to skip

The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.

Ablation: an exact, quantified, mixed prediction

Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.

The precedence surprise: measured, pinned, and correctly not fixed

hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).

⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.

⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine

You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.

I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:

queryresult
your query, verbatim0
gantt date field names fabricated11 hits, #7070 top
empty section skeleton sparse record detail1 hit — #7064 itself

⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).

The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.

I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.

Routing the two open questions — both are the maintainer's

  1. Should hideEmpty: false become a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling.
  2. Should the spec declare hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.

Both go to the decision box on #7064, which therefore stays open past this landing.

hotcrm

Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:31
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit eeb6c2fSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7064-empty-section-default branch September 1, 2026 03:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing hideEmpty ?? true over DetailSection's own stated heuristic

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Add copy buttons to all
 blocks\n(function() {\n function addCopyButtons() {\n document.querySelectorAll('pre code').forEach(function(codeBlock) {\n if (codeBlock.parentElement.hasAttribute('data-copy-added')) return;\n codeBlock.parentElement.setAttribute('data-copy-added', 'true');\n \n var btn = document.createElement('button');\n btn.textContent = 'Copy';\n btn.style.cssText = 'position:absolute;top:4px;right:4px;padding:2px 8px;font-size:11px;background:#4ecdc4;border:none;border-radius:4px;color:#1a1a2e;cursor:pointer;opacity:0.7;transition:opacity 0.2s;';\n btn.onmouseover = function() { this.style.opacity = '1'; };\n btn.onmouseout = function() { this.style.opacity = '0.7'; };\n btn.onclick = function() {\n navigator.clipboard.writeText(codeBlock.textContent).then(function() {\n btn.textContent = 'Copied!';\n setTimeout(function() { btn.textContent = 'Copy'; }, 1500);\n });\n };\n codeBlock.parentElement.style.position = 'relative';\n codeBlock.parentElement.appendChild(btn);\n });\n }\n \n addCopyButtons();\n \n // Re-run on dynamic content\n var observer = new MutationObserver(addCopyButtons);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Add Copy Buttons to Code Blocks");
}
} catch(__e) { console.warn('[Userscript:Add Copy Buttons to Code Blocks]', __e); }
})();
(function(){
try {
var __m = "github.com";
var __re = new RegExp('^' + "github\\.com" + '
Skip to content

fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default
Sep 1, 2026
Merged

fix(plugin-detail): let DetailSection's heuristic own the empty-section default#7123
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7064

Stops RecordDetailsRenderer forcing hideEmpty: s.hideEmpty ?? true onto every
section it synthesizes, so DetailSection's own stated heuristic owns the
unauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).

The one-line diff is hideEmpty: s.hideEmpty ?? true, becoming
hideEmpty: s.hideEmpty, — the authored value now passes through untouched.

Force sites swept

hideEmpty occurs in 5 source files across the repo. Exactly one site
defaulted a record:details section's hideEmpty, and it is the one this PR
changes:

siteverdict
packages/plugin-detail/src/renderers/record-details.tsx:210fixed — the ruled site
packages/plugin-detail/src/DetailSection.tsx (heuristic + hideEmptyEffective + toggle)left alone — this is the heuristic the ruling hands the case to
packages/plugin-detail/src/renderers/record-reference-rail.tsx:232 (schema.hideEmpty !== false)left alone, reported — different surface (record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.
packages/plugin-detail/src/index.tsx:731the rail's registered input (defaultValue: true) — belongs to the row above
packages/types/src/views.ts:230the type declaration; unchanged

No other renderer or mapper independently drops empty sections. DetailView
renders schema.sections.map(...) unconditionally — every section-hiding
decision is DetailSection's single early return.

Heuristic verified by rendering, not inferred from the comment

Rendered RecordDetailsRenderer over a sparse record inside a real
RecordContextProvider and dumped the DOM. container.textContent for an
all-empty 4-field section:

Deal Termsstage—amount—close_date—next_step—

The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(aria-label="No value" title="No value"). The heuristic is fully implemented;
the forced default was the only thing suppressing it.

Precedence chain

hideEmpty is per section only — there is no page-level or view-level
hideEmpty with competing precedence. The effective chain inside
DetailSection is:

  1. the user's per-session toggle (showEmptyOverride) wins over everything;
  2. otherwise empties hide when section.hideEmpty is truthy or
    shouldAutoHideEmpty fires;
  3. shouldAutoHideEmpty requires !section.hideEmpty, not editing, at least 4
    fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
    filled row
    — that last clause is what reserves the all-empty case;
  4. the whole section returns null only when every field is empty and all of
    them got filtered out.

⚠️Measured, and worth a decision:hideEmpty: false is not an
override. Step 3 reads !section.hideEmpty, so an authored false is
indistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR
?? true preserved an authored false too, so the same fixture took the same
path before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.

Behaviour delta (named in the changeset)

  • an all-empty section renders heading, labels and placeholders — it used to
    render nothing;
  • a small partly-empty section (below the 4-field / 25% thresholds) now
    shows its empty rows;
  • a large mostly-empty section with a filled row still auto-hides, toggle
    and all — the label-graveyard guard is intact;
  • empty rows are now visible while inline-editing, so an unwritten field can be
    filled in place (shouldAutoHideEmpty already excluded isEditing; the
    forced default overrode that too).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds a
real sample record, so a record:details block over a sparse sample now
previews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shell was touched. No application metadata anywhere needs
editing — that is the point of the ruling.

Tests

New pin file packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx
(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle, hideEmpty: true hiding an all-empty
section (with a sibling section as the render control), hideEmpty: true hiding
rows in a partly-filled section, hideEmpty: false showing them, and the
measured false-is-not-an-override case.

No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline packages/plugin-detail/ was 119 files /
1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned ?? true.

All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.

runverdict
pnpm exec vitest run packages/plugin-detail/at 1895c0965Test Files 120 passed (120) · Tests 1107 passed (1107) · lock VERDICT command-exit 0
baseline, same command at 71d83a6b1Test Files 119 passed (119) · Tests 1100 passed (1100)
15 consumer test files referencing record:details in apps/console, packages/app-shell, packages/core, packages/typesTest Files 15 passed (15) · Tests 329 passed (329)
pnpm --filter "@object-ui/plugin-detail" type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
pnpm --filter "@object-ui/plugin-detail" lint (eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existing
node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).
pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
pnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OK
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0
pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.
pnpm check:sdui-registration-pinsfirst run exit 2, NOT MEASURED (a run with nothing to read has measured nothing. Build the console first); after building the console closure and the console: ✅ All 16 registration(s) a sideEffects array promises are present in the built console

Typecheck coverage is proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists the new pin file (1 hit) alongside the existing
renderers/__tests__/record-details.test.tsx control (1 hit) out of 1712 files,
so the "typecheck is clean" claim really covers the new tests.

Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement: eslint.config.js declares no project /
projectService / parserOptions, so type-aware linting is off and a change in
these two files cannot move any verdict on an untouched file; --format json
over the two changed files reports 2 files, 0 errors, and 32 warnings all of
the pre-existing no-explicit-any / react-refresh families the package
already carries 894 of. The repo-wide run stays CI's.

Ablation

Predicted before running: restoring ?? true turns exactly 2 of the 7 pins
red — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because ?? true is a no-op on an authored value and produces an
identical DOM on the auto-hide path.

Observed:Tests 2 failed | 5 passed (7), and the two failures are exactly
the two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:

TestingLibraryElementError: Unable to find an element with the text: Deal Terms.

…and the body printed by that failure is a div.space-y-6 wrapping a single
self-closed div.space-y-3 sm:space-y-4 — the section list element with no
section inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)

No rebuild leg is owed: vitest.config.mts aliases every @object-ui/*
specifier to that package's src, and the pin file imports ../record-details
relatively, so the mutation runs from source with no dist in the path.

Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (showBorder: s.showBorder) 1, blob
bee9ec88115d566c98ba314ca665ddd0f426f5c1 to
4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exit
code: git checkout HEAD -- against an absolute path (with an EXIT INT TERM
trap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1 and git diff HEAD empty. The final
suite run above is on the restored tree.

Falsified assumptions, reported not fixed

  1. hideEmpty is not authorable on a record:details section at
    @objectstack/spec 17.2.0 — it is refused.
    The issue and the dispatch both
    describe authored hideEmpty as a declared opt-out. Measured against the
    installed spec: RecordDetailsProps.safeParse on a section carrying
    hideEmpty: true returns success: false with
    unrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on this
    record:details section"
    . Control in the same probe: a section carrying
    columns: 2 parses and the value survives. So for any spec-validated page the
    key never reaches this renderer at all, and the old ?? true was an
    unconditional platform policy with no author escape hatch. This PR is
    unaffected — the renderer still honours an authored value for the schemas that
    carry one (@object-ui/types declares DetailViewSection.hideEmpty), and the
    flip is what actually gives spec-validated pages the right behaviour with zero
    authoring. Flagged because the ruling's second clause reads differently once
    you know this.
  2. Stale comment, left alone.packages/plugin-detail/src/index.tsx:426-431
    says the spec's section object "STRIPS" undeclared keys on parse. Since the
    #4001 batch A work it refuses them loudly instead, per the measurement
    above. Not in this card's class and in a file this PR does not otherwise
    touch, so it is reported rather than fixed.
  3. No existing test encoded the old default (detail above).

hotcrm acceptance is unverified by me

The acceptance scenario names hotcrm opportunity_detail_page and
case_detail_page on hand-created records. hotcrm is not in this session's
repo scope
and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRenderer through the real DetailView and DetailSection with
sparse fixtures. The hotcrm scenario needs a run by someone with that repo.

Open question for the maintainer

Should hideEmpty: false become a hard override of the auto-hide heuristic?
Today it means "not true" (see Precedence chain). Left exactly as found and
pinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes false on a large sparse section today gets
no change in behaviour, which is its own quiet surprise.


Generated by Claude Code

…on default
`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic -- "If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide -- the labels themselves are useful
as a structural skeleton" -- and the forced default overrode exactly the case
that sentence reserves. On a hand-created sparse record whole sections
disappeared and the body collapsed to a couple of rows.
The renderer now passes the authored value through untouched. An unauthored
section reaches DetailSection as `undefined` and the heuristic decides; an
authored `hideEmpty` keeps its exact former meaning.
Also drops a non-English comment from the slot (AGENTS.md commandment #-1).
Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton,
the below-threshold empty row, the intact label-graveyard guard, and both
authored directions.
Maintainer ruling 2026-08-31; objectui#7064.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3151.4 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-xcLGHyrB.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges; I will not flip ready on in_progress.

⭐⭐ The finding that outranks the fix: the ruling's second clause describes an escape hatch that does not exist

The card and my dispatch both called authored hideEmpty "the declared opt-out". Measured against installed @objectstack/spec 17.2.0: RecordDetailsProps.safeParse on a section carrying hideEmpty returns success: false, unrecognized_keys: ['hideEmpty']refused, not accepted — against a control in the same probe (columns: 2) that parses and whose value survives.

⇒ For any spec-validated page the key never reaches this renderer at all. So the old ?? true was not "a default over an authorable key" — it was an unconditional platform policy with no author escape hatch whatsoever. Which makes this flip do more than the ruling claimed: it is the only thing that gives spec-validated pages the ruled behaviour, and no app can opt out even if it wanted to.

ZONE 1 rule 2 is still satisfied in code — the renderer honours an authored value verbatim for schemas that carry one — so the PR is correct as built. But the ruling's reasoning rested on a premise measurement contradicts, and that goes to the maintainer rather than being quietly absorbed.

I verified the objectui half of the divergence myself, since it is a three-party disagreement about one key:

partyhideEmpty on a record:details section
@objectstack/spec 17.2.0⛔ refuses (unrecognized_keys)
@object-ui/typesviews.ts:230✅ declares — hideEmpty?: boolean
packages/types/src/zod/views.zod.tsabsent — 0 hits, control headerColor = 2 in the same file
RecordDetailsRenderer✅ honours

The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.

⭐ You falsified my assumption about the test suite, and proved it rather than asserting it

I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.

The heuristic was verified by rendering, which the card's own framing invited you to skip

The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.

Ablation: an exact, quantified, mixed prediction

Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.

The precedence surprise: measured, pinned, and correctly not fixed

hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).

⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.

⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine

You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.

I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:

queryresult
your query, verbatim0
gantt date field names fabricated11 hits, #7070 top
empty section skeleton sparse record detail1 hit — #7064 itself

⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).

The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.

I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.

Routing the two open questions — both are the maintainer's

  1. Should hideEmpty: false become a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling.
  2. Should the spec declare hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.

Both go to the decision box on #7064, which therefore stays open past this landing.

hotcrm

Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:31
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit eeb6c2fSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7064-empty-section-default branch September 1, 2026 03:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing hideEmpty ?? true over DetailSection's own stated heuristic

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Force GitHub README to respect dark mode\n(function() {\n var style = document.createElement('style');\n style.textContent = '\n .markdown-body {\n color-scheme: dark light;\n }\n .markdown-body pre { background: #161b22 !important; }\n .markdown-body code { background: rgba(110, 118, 129, 0.4) !important; }\n .markdown-body table th, .markdown-body table td { border-color: #30363d !important; }\n .markdown-body img { background: #0d1117; }\n .markdown-body blockquote { border-left-color: #8b949e; }\n .markdown-body hr { border-color: #30363d; }\n ';\n document.head.appendChild(style);\n})();", "GitHub Dark Mode README Fix"); } } catch(__e) { console.warn('[Userscript:GitHub Dark Mode README Fix]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default
Sep 1, 2026
Merged

fix(plugin-detail): let DetailSection's heuristic own the empty-section default#7123
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7064

Stops RecordDetailsRenderer forcing hideEmpty: s.hideEmpty ?? true onto every
section it synthesizes, so DetailSection's own stated heuristic owns the
unauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).

The one-line diff is hideEmpty: s.hideEmpty ?? true, becoming
hideEmpty: s.hideEmpty, — the authored value now passes through untouched.

Force sites swept

hideEmpty occurs in 5 source files across the repo. Exactly one site
defaulted a record:details section's hideEmpty, and it is the one this PR
changes:

siteverdict
packages/plugin-detail/src/renderers/record-details.tsx:210fixed — the ruled site
packages/plugin-detail/src/DetailSection.tsx (heuristic + hideEmptyEffective + toggle)left alone — this is the heuristic the ruling hands the case to
packages/plugin-detail/src/renderers/record-reference-rail.tsx:232 (schema.hideEmpty !== false)left alone, reported — different surface (record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.
packages/plugin-detail/src/index.tsx:731the rail's registered input (defaultValue: true) — belongs to the row above
packages/types/src/views.ts:230the type declaration; unchanged

No other renderer or mapper independently drops empty sections. DetailView
renders schema.sections.map(...) unconditionally — every section-hiding
decision is DetailSection's single early return.

Heuristic verified by rendering, not inferred from the comment

Rendered RecordDetailsRenderer over a sparse record inside a real
RecordContextProvider and dumped the DOM. container.textContent for an
all-empty 4-field section:

Deal Termsstage—amount—close_date—next_step—

The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(aria-label="No value" title="No value"). The heuristic is fully implemented;
the forced default was the only thing suppressing it.

Precedence chain

hideEmpty is per section only — there is no page-level or view-level
hideEmpty with competing precedence. The effective chain inside
DetailSection is:

  1. the user's per-session toggle (showEmptyOverride) wins over everything;
  2. otherwise empties hide when section.hideEmpty is truthy or
    shouldAutoHideEmpty fires;
  3. shouldAutoHideEmpty requires !section.hideEmpty, not editing, at least 4
    fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
    filled row
    — that last clause is what reserves the all-empty case;
  4. the whole section returns null only when every field is empty and all of
    them got filtered out.

⚠️Measured, and worth a decision:hideEmpty: false is not an
override. Step 3 reads !section.hideEmpty, so an authored false is
indistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR
?? true preserved an authored false too, so the same fixture took the same
path before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.

Behaviour delta (named in the changeset)

  • an all-empty section renders heading, labels and placeholders — it used to
    render nothing;
  • a small partly-empty section (below the 4-field / 25% thresholds) now
    shows its empty rows;
  • a large mostly-empty section with a filled row still auto-hides, toggle
    and all — the label-graveyard guard is intact;
  • empty rows are now visible while inline-editing, so an unwritten field can be
    filled in place (shouldAutoHideEmpty already excluded isEditing; the
    forced default overrode that too).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds a
real sample record, so a record:details block over a sparse sample now
previews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shell was touched. No application metadata anywhere needs
editing — that is the point of the ruling.

Tests

New pin file packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx
(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle, hideEmpty: true hiding an all-empty
section (with a sibling section as the render control), hideEmpty: true hiding
rows in a partly-filled section, hideEmpty: false showing them, and the
measured false-is-not-an-override case.

No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline packages/plugin-detail/ was 119 files /
1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned ?? true.

All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.

runverdict
pnpm exec vitest run packages/plugin-detail/at 1895c0965Test Files 120 passed (120) · Tests 1107 passed (1107) · lock VERDICT command-exit 0
baseline, same command at 71d83a6b1Test Files 119 passed (119) · Tests 1100 passed (1100)
15 consumer test files referencing record:details in apps/console, packages/app-shell, packages/core, packages/typesTest Files 15 passed (15) · Tests 329 passed (329)
pnpm --filter "@object-ui/plugin-detail" type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
pnpm --filter "@object-ui/plugin-detail" lint (eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existing
node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).
pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
pnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OK
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0
pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.
pnpm check:sdui-registration-pinsfirst run exit 2, NOT MEASURED (a run with nothing to read has measured nothing. Build the console first); after building the console closure and the console: ✅ All 16 registration(s) a sideEffects array promises are present in the built console

Typecheck coverage is proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists the new pin file (1 hit) alongside the existing
renderers/__tests__/record-details.test.tsx control (1 hit) out of 1712 files,
so the "typecheck is clean" claim really covers the new tests.

Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement: eslint.config.js declares no project /
projectService / parserOptions, so type-aware linting is off and a change in
these two files cannot move any verdict on an untouched file; --format json
over the two changed files reports 2 files, 0 errors, and 32 warnings all of
the pre-existing no-explicit-any / react-refresh families the package
already carries 894 of. The repo-wide run stays CI's.

Ablation

Predicted before running: restoring ?? true turns exactly 2 of the 7 pins
red — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because ?? true is a no-op on an authored value and produces an
identical DOM on the auto-hide path.

Observed:Tests 2 failed | 5 passed (7), and the two failures are exactly
the two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:

TestingLibraryElementError: Unable to find an element with the text: Deal Terms.

…and the body printed by that failure is a div.space-y-6 wrapping a single
self-closed div.space-y-3 sm:space-y-4 — the section list element with no
section inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)

No rebuild leg is owed: vitest.config.mts aliases every @object-ui/*
specifier to that package's src, and the pin file imports ../record-details
relatively, so the mutation runs from source with no dist in the path.

Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (showBorder: s.showBorder) 1, blob
bee9ec88115d566c98ba314ca665ddd0f426f5c1 to
4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exit
code: git checkout HEAD -- against an absolute path (with an EXIT INT TERM
trap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1 and git diff HEAD empty. The final
suite run above is on the restored tree.

Falsified assumptions, reported not fixed

  1. hideEmpty is not authorable on a record:details section at
    @objectstack/spec 17.2.0 — it is refused.
    The issue and the dispatch both
    describe authored hideEmpty as a declared opt-out. Measured against the
    installed spec: RecordDetailsProps.safeParse on a section carrying
    hideEmpty: true returns success: false with
    unrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on this
    record:details section"
    . Control in the same probe: a section carrying
    columns: 2 parses and the value survives. So for any spec-validated page the
    key never reaches this renderer at all, and the old ?? true was an
    unconditional platform policy with no author escape hatch. This PR is
    unaffected — the renderer still honours an authored value for the schemas that
    carry one (@object-ui/types declares DetailViewSection.hideEmpty), and the
    flip is what actually gives spec-validated pages the right behaviour with zero
    authoring. Flagged because the ruling's second clause reads differently once
    you know this.
  2. Stale comment, left alone.packages/plugin-detail/src/index.tsx:426-431
    says the spec's section object "STRIPS" undeclared keys on parse. Since the
    #4001 batch A work it refuses them loudly instead, per the measurement
    above. Not in this card's class and in a file this PR does not otherwise
    touch, so it is reported rather than fixed.
  3. No existing test encoded the old default (detail above).

hotcrm acceptance is unverified by me

The acceptance scenario names hotcrm opportunity_detail_page and
case_detail_page on hand-created records. hotcrm is not in this session's
repo scope
and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRenderer through the real DetailView and DetailSection with
sparse fixtures. The hotcrm scenario needs a run by someone with that repo.

Open question for the maintainer

Should hideEmpty: false become a hard override of the auto-hide heuristic?
Today it means "not true" (see Precedence chain). Left exactly as found and
pinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes false on a large sparse section today gets
no change in behaviour, which is its own quiet surprise.


Generated by Claude Code

…on default
`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic -- "If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide -- the labels themselves are useful
as a structural skeleton" -- and the forced default overrode exactly the case
that sentence reserves. On a hand-created sparse record whole sections
disappeared and the body collapsed to a couple of rows.
The renderer now passes the authored value through untouched. An unauthored
section reaches DetailSection as `undefined` and the heuristic decides; an
authored `hideEmpty` keeps its exact former meaning.
Also drops a non-English comment from the slot (AGENTS.md commandment #-1).
Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton,
the below-threshold empty row, the intact label-graveyard guard, and both
authored directions.
Maintainer ruling 2026-08-31; objectui#7064.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3151.4 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-xcLGHyrB.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges; I will not flip ready on in_progress.

⭐⭐ The finding that outranks the fix: the ruling's second clause describes an escape hatch that does not exist

The card and my dispatch both called authored hideEmpty "the declared opt-out". Measured against installed @objectstack/spec 17.2.0: RecordDetailsProps.safeParse on a section carrying hideEmpty returns success: false, unrecognized_keys: ['hideEmpty']refused, not accepted — against a control in the same probe (columns: 2) that parses and whose value survives.

⇒ For any spec-validated page the key never reaches this renderer at all. So the old ?? true was not "a default over an authorable key" — it was an unconditional platform policy with no author escape hatch whatsoever. Which makes this flip do more than the ruling claimed: it is the only thing that gives spec-validated pages the ruled behaviour, and no app can opt out even if it wanted to.

ZONE 1 rule 2 is still satisfied in code — the renderer honours an authored value verbatim for schemas that carry one — so the PR is correct as built. But the ruling's reasoning rested on a premise measurement contradicts, and that goes to the maintainer rather than being quietly absorbed.

I verified the objectui half of the divergence myself, since it is a three-party disagreement about one key:

partyhideEmpty on a record:details section
@objectstack/spec 17.2.0⛔ refuses (unrecognized_keys)
@object-ui/typesviews.ts:230✅ declares — hideEmpty?: boolean
packages/types/src/zod/views.zod.tsabsent — 0 hits, control headerColor = 2 in the same file
RecordDetailsRenderer✅ honours

The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.

⭐ You falsified my assumption about the test suite, and proved it rather than asserting it

I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.

The heuristic was verified by rendering, which the card's own framing invited you to skip

The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.

Ablation: an exact, quantified, mixed prediction

Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.

The precedence surprise: measured, pinned, and correctly not fixed

hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).

⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.

⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine

You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.

I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:

queryresult
your query, verbatim0
gantt date field names fabricated11 hits, #7070 top
empty section skeleton sparse record detail1 hit — #7064 itself

⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).

The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.

I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.

Routing the two open questions — both are the maintainer's

  1. Should hideEmpty: false become a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling.
  2. Should the spec declare hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.

Both go to the decision box on #7064, which therefore stays open past this landing.

hotcrm

Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:31
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit eeb6c2fSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7064-empty-section-default branch September 1, 2026 03:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing hideEmpty ?? true over DetailSection's own stated heuristic

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Highlight search terms from Google/DuckDuckGo/Bing referrer\n(function() {\n var ref = document.referrer;\n var terms = [];\n \n if (ref.includes('google.com') || ref.includes('duckduckgo.com') || ref.includes('bing.com')) {\n var url = new URL(ref);\n var q = url.searchParams.get('q') || url.searchParams.get('p');\n if (q) {\n terms = q.split(/\\s+/).filter(function(t) { return t.length > 2; });\n }\n }\n \n if (terms.length === 0) return;\n \n var style = document.createElement('style');\n style.textContent = '.userscript-highlight { background: #fbbf24; color: #1a1a2e; padding: 1px 3px; border-radius: 2px; }';\n document.head.appendChild(style);\n \n function highlight(node) {\n if (node.nodeType === 3) { // text node\n var text = node.textContent;\n var found = false;\n terms.forEach(function(term) {\n var regex = new RegExp('(' + term.replace(/[.*+?^${}()|[\\]\\\\]/g, '\\\\') + ')', 'gi');\n if (regex.test(text)) {\n found = true;\n var frag = document.createDocumentFragment();\n var parts = text.split(regex);\n parts.forEach(function(part, i) {\n if (i % 2 === 0) {\n frag.appendChild(document.createTextNode(part));\n } else {\n var span = document.createElement('span');\n span.className = 'userscript-highlight';\n span.textContent = part;\n frag.appendChild(span);\n }\n });\n node.parentNode.replaceChild(frag, node);\n }\n });\n } else if (node.nodeType === 1 && node.childNodes) { // element\n var skipTags = ['SCRIPT', 'STYLE', 'NOSCRIPT', 'TEXTAREA', 'INPUT', 'SELECT'];\n if (!skipTags.includes(node.tagName)) {\n Array.from(node.childNodes).forEach(highlight);\n }\n }\n }\n \n highlight(document.body);\n \n // Re-highlight on dynamic content\n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1 || node.nodeType === 3) highlight(node);\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Highlight Search Terms"); } } catch(__e) { console.warn('[Userscript:Highlight Search Terms]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default
Sep 1, 2026
Merged

fix(plugin-detail): let DetailSection's heuristic own the empty-section default#7123
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7064

Stops RecordDetailsRenderer forcing hideEmpty: s.hideEmpty ?? true onto every
section it synthesizes, so DetailSection's own stated heuristic owns the
unauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).

The one-line diff is hideEmpty: s.hideEmpty ?? true, becoming
hideEmpty: s.hideEmpty, — the authored value now passes through untouched.

Force sites swept

hideEmpty occurs in 5 source files across the repo. Exactly one site
defaulted a record:details section's hideEmpty, and it is the one this PR
changes:

siteverdict
packages/plugin-detail/src/renderers/record-details.tsx:210fixed — the ruled site
packages/plugin-detail/src/DetailSection.tsx (heuristic + hideEmptyEffective + toggle)left alone — this is the heuristic the ruling hands the case to
packages/plugin-detail/src/renderers/record-reference-rail.tsx:232 (schema.hideEmpty !== false)left alone, reported — different surface (record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.
packages/plugin-detail/src/index.tsx:731the rail's registered input (defaultValue: true) — belongs to the row above
packages/types/src/views.ts:230the type declaration; unchanged

No other renderer or mapper independently drops empty sections. DetailView
renders schema.sections.map(...) unconditionally — every section-hiding
decision is DetailSection's single early return.

Heuristic verified by rendering, not inferred from the comment

Rendered RecordDetailsRenderer over a sparse record inside a real
RecordContextProvider and dumped the DOM. container.textContent for an
all-empty 4-field section:

Deal Termsstage—amount—close_date—next_step—

The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(aria-label="No value" title="No value"). The heuristic is fully implemented;
the forced default was the only thing suppressing it.

Precedence chain

hideEmpty is per section only — there is no page-level or view-level
hideEmpty with competing precedence. The effective chain inside
DetailSection is:

  1. the user's per-session toggle (showEmptyOverride) wins over everything;
  2. otherwise empties hide when section.hideEmpty is truthy or
    shouldAutoHideEmpty fires;
  3. shouldAutoHideEmpty requires !section.hideEmpty, not editing, at least 4
    fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
    filled row
    — that last clause is what reserves the all-empty case;
  4. the whole section returns null only when every field is empty and all of
    them got filtered out.

⚠️Measured, and worth a decision:hideEmpty: false is not an
override. Step 3 reads !section.hideEmpty, so an authored false is
indistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR
?? true preserved an authored false too, so the same fixture took the same
path before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.

Behaviour delta (named in the changeset)

  • an all-empty section renders heading, labels and placeholders — it used to
    render nothing;
  • a small partly-empty section (below the 4-field / 25% thresholds) now
    shows its empty rows;
  • a large mostly-empty section with a filled row still auto-hides, toggle
    and all — the label-graveyard guard is intact;
  • empty rows are now visible while inline-editing, so an unwritten field can be
    filled in place (shouldAutoHideEmpty already excluded isEditing; the
    forced default overrode that too).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds a
real sample record, so a record:details block over a sparse sample now
previews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shell was touched. No application metadata anywhere needs
editing — that is the point of the ruling.

Tests

New pin file packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx
(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle, hideEmpty: true hiding an all-empty
section (with a sibling section as the render control), hideEmpty: true hiding
rows in a partly-filled section, hideEmpty: false showing them, and the
measured false-is-not-an-override case.

No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline packages/plugin-detail/ was 119 files /
1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned ?? true.

All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.

runverdict
pnpm exec vitest run packages/plugin-detail/at 1895c0965Test Files 120 passed (120) · Tests 1107 passed (1107) · lock VERDICT command-exit 0
baseline, same command at 71d83a6b1Test Files 119 passed (119) · Tests 1100 passed (1100)
15 consumer test files referencing record:details in apps/console, packages/app-shell, packages/core, packages/typesTest Files 15 passed (15) · Tests 329 passed (329)
pnpm --filter "@object-ui/plugin-detail" type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
pnpm --filter "@object-ui/plugin-detail" lint (eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existing
node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).
pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
pnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OK
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0
pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.
pnpm check:sdui-registration-pinsfirst run exit 2, NOT MEASURED (a run with nothing to read has measured nothing. Build the console first); after building the console closure and the console: ✅ All 16 registration(s) a sideEffects array promises are present in the built console

Typecheck coverage is proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists the new pin file (1 hit) alongside the existing
renderers/__tests__/record-details.test.tsx control (1 hit) out of 1712 files,
so the "typecheck is clean" claim really covers the new tests.

Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement: eslint.config.js declares no project /
projectService / parserOptions, so type-aware linting is off and a change in
these two files cannot move any verdict on an untouched file; --format json
over the two changed files reports 2 files, 0 errors, and 32 warnings all of
the pre-existing no-explicit-any / react-refresh families the package
already carries 894 of. The repo-wide run stays CI's.

Ablation

Predicted before running: restoring ?? true turns exactly 2 of the 7 pins
red — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because ?? true is a no-op on an authored value and produces an
identical DOM on the auto-hide path.

Observed:Tests 2 failed | 5 passed (7), and the two failures are exactly
the two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:

TestingLibraryElementError: Unable to find an element with the text: Deal Terms.

…and the body printed by that failure is a div.space-y-6 wrapping a single
self-closed div.space-y-3 sm:space-y-4 — the section list element with no
section inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)

No rebuild leg is owed: vitest.config.mts aliases every @object-ui/*
specifier to that package's src, and the pin file imports ../record-details
relatively, so the mutation runs from source with no dist in the path.

Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (showBorder: s.showBorder) 1, blob
bee9ec88115d566c98ba314ca665ddd0f426f5c1 to
4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exit
code: git checkout HEAD -- against an absolute path (with an EXIT INT TERM
trap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1 and git diff HEAD empty. The final
suite run above is on the restored tree.

Falsified assumptions, reported not fixed

  1. hideEmpty is not authorable on a record:details section at
    @objectstack/spec 17.2.0 — it is refused.
    The issue and the dispatch both
    describe authored hideEmpty as a declared opt-out. Measured against the
    installed spec: RecordDetailsProps.safeParse on a section carrying
    hideEmpty: true returns success: false with
    unrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on this
    record:details section"
    . Control in the same probe: a section carrying
    columns: 2 parses and the value survives. So for any spec-validated page the
    key never reaches this renderer at all, and the old ?? true was an
    unconditional platform policy with no author escape hatch. This PR is
    unaffected — the renderer still honours an authored value for the schemas that
    carry one (@object-ui/types declares DetailViewSection.hideEmpty), and the
    flip is what actually gives spec-validated pages the right behaviour with zero
    authoring. Flagged because the ruling's second clause reads differently once
    you know this.
  2. Stale comment, left alone.packages/plugin-detail/src/index.tsx:426-431
    says the spec's section object "STRIPS" undeclared keys on parse. Since the
    #4001 batch A work it refuses them loudly instead, per the measurement
    above. Not in this card's class and in a file this PR does not otherwise
    touch, so it is reported rather than fixed.
  3. No existing test encoded the old default (detail above).

hotcrm acceptance is unverified by me

The acceptance scenario names hotcrm opportunity_detail_page and
case_detail_page on hand-created records. hotcrm is not in this session's
repo scope
and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRenderer through the real DetailView and DetailSection with
sparse fixtures. The hotcrm scenario needs a run by someone with that repo.

Open question for the maintainer

Should hideEmpty: false become a hard override of the auto-hide heuristic?
Today it means "not true" (see Precedence chain). Left exactly as found and
pinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes false on a large sparse section today gets
no change in behaviour, which is its own quiet surprise.


Generated by Claude Code

…on default
`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic -- "If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide -- the labels themselves are useful
as a structural skeleton" -- and the forced default overrode exactly the case
that sentence reserves. On a hand-created sparse record whole sections
disappeared and the body collapsed to a couple of rows.
The renderer now passes the authored value through untouched. An unauthored
section reaches DetailSection as `undefined` and the heuristic decides; an
authored `hideEmpty` keeps its exact former meaning.
Also drops a non-English comment from the slot (AGENTS.md commandment #-1).
Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton,
the below-threshold empty row, the intact label-graveyard guard, and both
authored directions.
Maintainer ruling 2026-08-31; objectui#7064.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3151.4 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-xcLGHyrB.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges; I will not flip ready on in_progress.

⭐⭐ The finding that outranks the fix: the ruling's second clause describes an escape hatch that does not exist

The card and my dispatch both called authored hideEmpty "the declared opt-out". Measured against installed @objectstack/spec 17.2.0: RecordDetailsProps.safeParse on a section carrying hideEmpty returns success: false, unrecognized_keys: ['hideEmpty']refused, not accepted — against a control in the same probe (columns: 2) that parses and whose value survives.

⇒ For any spec-validated page the key never reaches this renderer at all. So the old ?? true was not "a default over an authorable key" — it was an unconditional platform policy with no author escape hatch whatsoever. Which makes this flip do more than the ruling claimed: it is the only thing that gives spec-validated pages the ruled behaviour, and no app can opt out even if it wanted to.

ZONE 1 rule 2 is still satisfied in code — the renderer honours an authored value verbatim for schemas that carry one — so the PR is correct as built. But the ruling's reasoning rested on a premise measurement contradicts, and that goes to the maintainer rather than being quietly absorbed.

I verified the objectui half of the divergence myself, since it is a three-party disagreement about one key:

partyhideEmpty on a record:details section
@objectstack/spec 17.2.0⛔ refuses (unrecognized_keys)
@object-ui/typesviews.ts:230✅ declares — hideEmpty?: boolean
packages/types/src/zod/views.zod.tsabsent — 0 hits, control headerColor = 2 in the same file
RecordDetailsRenderer✅ honours

The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.

⭐ You falsified my assumption about the test suite, and proved it rather than asserting it

I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.

The heuristic was verified by rendering, which the card's own framing invited you to skip

The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.

Ablation: an exact, quantified, mixed prediction

Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.

The precedence surprise: measured, pinned, and correctly not fixed

hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).

⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.

⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine

You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.

I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:

queryresult
your query, verbatim0
gantt date field names fabricated11 hits, #7070 top
empty section skeleton sparse record detail1 hit — #7064 itself

⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).

The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.

I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.

Routing the two open questions — both are the maintainer's

  1. Should hideEmpty: false become a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling.
  2. Should the spec declare hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.

Both go to the decision box on #7064, which therefore stays open past this landing.

hotcrm

Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:31
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit eeb6c2fSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7064-empty-section-default branch September 1, 2026 03:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing hideEmpty ?? true over DetailSection's own stated heuristic

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Strip utm_, fbclid, gclid, etc. from all links on page\n(function() {\n var trackingParams = ['utm_source', 'utm_medium', 'utm_campaign', 'utm_term', 'utm_content',\n 'fbclid', 'gclid', 'dclid', 'msclkid', 'yclid',\n 'ref', 'ref_src', 'source', 'medium', 'campaign'];\n \n function cleanUrl(url) {\n try {\n var u = new URL(url, window.location.origin);\n var changed = false;\n trackingParams.forEach(function(p) {\n if (u.searchParams.has(p)) {\n u.searchParams.delete(p);\n changed = true;\n }\n });\n return changed ? u.toString() : url;\n } catch (e) {\n return url;\n }\n }\n \n function cleanLinks() {\n document.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n \n cleanLinks();\n \n var observer = new MutationObserver(function(mutations) {\n mutations.forEach(function(m) {\n m.addedNodes.forEach(function(node) {\n if (node.nodeType === 1) {\n if (node.tagName === 'A') cleanLinks();\n node.querySelectorAll('a[href]').forEach(function(a) {\n var clean = cleanUrl(a.href);\n if (clean !== a.href) a.href = clean;\n });\n }\n });\n });\n });\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "Remove Tracking Parameters from Links"); } } catch(__e) { console.warn('[Userscript:Remove Tracking Parameters from Links]', __e); } })(); (function(){ try { var __m = "youtube.com"; var __re = new RegExp('^' + "youtube\\.com" + '
Skip to content

fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default
Sep 1, 2026
Merged

fix(plugin-detail): let DetailSection's heuristic own the empty-section default#7123
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7064

Stops RecordDetailsRenderer forcing hideEmpty: s.hideEmpty ?? true onto every
section it synthesizes, so DetailSection's own stated heuristic owns the
unauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).

The one-line diff is hideEmpty: s.hideEmpty ?? true, becoming
hideEmpty: s.hideEmpty, — the authored value now passes through untouched.

Force sites swept

hideEmpty occurs in 5 source files across the repo. Exactly one site
defaulted a record:details section's hideEmpty, and it is the one this PR
changes:

siteverdict
packages/plugin-detail/src/renderers/record-details.tsx:210fixed — the ruled site
packages/plugin-detail/src/DetailSection.tsx (heuristic + hideEmptyEffective + toggle)left alone — this is the heuristic the ruling hands the case to
packages/plugin-detail/src/renderers/record-reference-rail.tsx:232 (schema.hideEmpty !== false)left alone, reported — different surface (record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.
packages/plugin-detail/src/index.tsx:731the rail's registered input (defaultValue: true) — belongs to the row above
packages/types/src/views.ts:230the type declaration; unchanged

No other renderer or mapper independently drops empty sections. DetailView
renders schema.sections.map(...) unconditionally — every section-hiding
decision is DetailSection's single early return.

Heuristic verified by rendering, not inferred from the comment

Rendered RecordDetailsRenderer over a sparse record inside a real
RecordContextProvider and dumped the DOM. container.textContent for an
all-empty 4-field section:

Deal Termsstage—amount—close_date—next_step—

The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(aria-label="No value" title="No value"). The heuristic is fully implemented;
the forced default was the only thing suppressing it.

Precedence chain

hideEmpty is per section only — there is no page-level or view-level
hideEmpty with competing precedence. The effective chain inside
DetailSection is:

  1. the user's per-session toggle (showEmptyOverride) wins over everything;
  2. otherwise empties hide when section.hideEmpty is truthy or
    shouldAutoHideEmpty fires;
  3. shouldAutoHideEmpty requires !section.hideEmpty, not editing, at least 4
    fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
    filled row
    — that last clause is what reserves the all-empty case;
  4. the whole section returns null only when every field is empty and all of
    them got filtered out.

⚠️Measured, and worth a decision:hideEmpty: false is not an
override. Step 3 reads !section.hideEmpty, so an authored false is
indistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR
?? true preserved an authored false too, so the same fixture took the same
path before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.

Behaviour delta (named in the changeset)

  • an all-empty section renders heading, labels and placeholders — it used to
    render nothing;
  • a small partly-empty section (below the 4-field / 25% thresholds) now
    shows its empty rows;
  • a large mostly-empty section with a filled row still auto-hides, toggle
    and all — the label-graveyard guard is intact;
  • empty rows are now visible while inline-editing, so an unwritten field can be
    filled in place (shouldAutoHideEmpty already excluded isEditing; the
    forced default overrode that too).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds a
real sample record, so a record:details block over a sparse sample now
previews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shell was touched. No application metadata anywhere needs
editing — that is the point of the ruling.

Tests

New pin file packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx
(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle, hideEmpty: true hiding an all-empty
section (with a sibling section as the render control), hideEmpty: true hiding
rows in a partly-filled section, hideEmpty: false showing them, and the
measured false-is-not-an-override case.

No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline packages/plugin-detail/ was 119 files /
1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned ?? true.

All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.

runverdict
pnpm exec vitest run packages/plugin-detail/at 1895c0965Test Files 120 passed (120) · Tests 1107 passed (1107) · lock VERDICT command-exit 0
baseline, same command at 71d83a6b1Test Files 119 passed (119) · Tests 1100 passed (1100)
15 consumer test files referencing record:details in apps/console, packages/app-shell, packages/core, packages/typesTest Files 15 passed (15) · Tests 329 passed (329)
pnpm --filter "@object-ui/plugin-detail" type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
pnpm --filter "@object-ui/plugin-detail" lint (eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existing
node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).
pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
pnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OK
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0
pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.
pnpm check:sdui-registration-pinsfirst run exit 2, NOT MEASURED (a run with nothing to read has measured nothing. Build the console first); after building the console closure and the console: ✅ All 16 registration(s) a sideEffects array promises are present in the built console

Typecheck coverage is proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists the new pin file (1 hit) alongside the existing
renderers/__tests__/record-details.test.tsx control (1 hit) out of 1712 files,
so the "typecheck is clean" claim really covers the new tests.

Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement: eslint.config.js declares no project /
projectService / parserOptions, so type-aware linting is off and a change in
these two files cannot move any verdict on an untouched file; --format json
over the two changed files reports 2 files, 0 errors, and 32 warnings all of
the pre-existing no-explicit-any / react-refresh families the package
already carries 894 of. The repo-wide run stays CI's.

Ablation

Predicted before running: restoring ?? true turns exactly 2 of the 7 pins
red — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because ?? true is a no-op on an authored value and produces an
identical DOM on the auto-hide path.

Observed:Tests 2 failed | 5 passed (7), and the two failures are exactly
the two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:

TestingLibraryElementError: Unable to find an element with the text: Deal Terms.

…and the body printed by that failure is a div.space-y-6 wrapping a single
self-closed div.space-y-3 sm:space-y-4 — the section list element with no
section inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)

No rebuild leg is owed: vitest.config.mts aliases every @object-ui/*
specifier to that package's src, and the pin file imports ../record-details
relatively, so the mutation runs from source with no dist in the path.

Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (showBorder: s.showBorder) 1, blob
bee9ec88115d566c98ba314ca665ddd0f426f5c1 to
4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exit
code: git checkout HEAD -- against an absolute path (with an EXIT INT TERM
trap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1 and git diff HEAD empty. The final
suite run above is on the restored tree.

Falsified assumptions, reported not fixed

  1. hideEmpty is not authorable on a record:details section at
    @objectstack/spec 17.2.0 — it is refused.
    The issue and the dispatch both
    describe authored hideEmpty as a declared opt-out. Measured against the
    installed spec: RecordDetailsProps.safeParse on a section carrying
    hideEmpty: true returns success: false with
    unrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on this
    record:details section"
    . Control in the same probe: a section carrying
    columns: 2 parses and the value survives. So for any spec-validated page the
    key never reaches this renderer at all, and the old ?? true was an
    unconditional platform policy with no author escape hatch. This PR is
    unaffected — the renderer still honours an authored value for the schemas that
    carry one (@object-ui/types declares DetailViewSection.hideEmpty), and the
    flip is what actually gives spec-validated pages the right behaviour with zero
    authoring. Flagged because the ruling's second clause reads differently once
    you know this.
  2. Stale comment, left alone.packages/plugin-detail/src/index.tsx:426-431
    says the spec's section object "STRIPS" undeclared keys on parse. Since the
    #4001 batch A work it refuses them loudly instead, per the measurement
    above. Not in this card's class and in a file this PR does not otherwise
    touch, so it is reported rather than fixed.
  3. No existing test encoded the old default (detail above).

hotcrm acceptance is unverified by me

The acceptance scenario names hotcrm opportunity_detail_page and
case_detail_page on hand-created records. hotcrm is not in this session's
repo scope
and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRenderer through the real DetailView and DetailSection with
sparse fixtures. The hotcrm scenario needs a run by someone with that repo.

Open question for the maintainer

Should hideEmpty: false become a hard override of the auto-hide heuristic?
Today it means "not true" (see Precedence chain). Left exactly as found and
pinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes false on a large sparse section today gets
no change in behaviour, which is its own quiet surprise.


Generated by Claude Code

…on default
`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic -- "If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide -- the labels themselves are useful
as a structural skeleton" -- and the forced default overrode exactly the case
that sentence reserves. On a hand-created sparse record whole sections
disappeared and the body collapsed to a couple of rows.
The renderer now passes the authored value through untouched. An unauthored
section reaches DetailSection as `undefined` and the heuristic decides; an
authored `hideEmpty` keeps its exact former meaning.
Also drops a non-English comment from the slot (AGENTS.md commandment #-1).
Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton,
the below-threshold empty row, the intact label-graveyard guard, and both
authored directions.
Maintainer ruling 2026-08-31; objectui#7064.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3151.4 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-xcLGHyrB.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges; I will not flip ready on in_progress.

⭐⭐ The finding that outranks the fix: the ruling's second clause describes an escape hatch that does not exist

The card and my dispatch both called authored hideEmpty "the declared opt-out". Measured against installed @objectstack/spec 17.2.0: RecordDetailsProps.safeParse on a section carrying hideEmpty returns success: false, unrecognized_keys: ['hideEmpty']refused, not accepted — against a control in the same probe (columns: 2) that parses and whose value survives.

⇒ For any spec-validated page the key never reaches this renderer at all. So the old ?? true was not "a default over an authorable key" — it was an unconditional platform policy with no author escape hatch whatsoever. Which makes this flip do more than the ruling claimed: it is the only thing that gives spec-validated pages the ruled behaviour, and no app can opt out even if it wanted to.

ZONE 1 rule 2 is still satisfied in code — the renderer honours an authored value verbatim for schemas that carry one — so the PR is correct as built. But the ruling's reasoning rested on a premise measurement contradicts, and that goes to the maintainer rather than being quietly absorbed.

I verified the objectui half of the divergence myself, since it is a three-party disagreement about one key:

partyhideEmpty on a record:details section
@objectstack/spec 17.2.0⛔ refuses (unrecognized_keys)
@object-ui/typesviews.ts:230✅ declares — hideEmpty?: boolean
packages/types/src/zod/views.zod.tsabsent — 0 hits, control headerColor = 2 in the same file
RecordDetailsRenderer✅ honours

The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.

⭐ You falsified my assumption about the test suite, and proved it rather than asserting it

I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.

The heuristic was verified by rendering, which the card's own framing invited you to skip

The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.

Ablation: an exact, quantified, mixed prediction

Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.

The precedence surprise: measured, pinned, and correctly not fixed

hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).

⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.

⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine

You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.

I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:

queryresult
your query, verbatim0
gantt date field names fabricated11 hits, #7070 top
empty section skeleton sparse record detail1 hit — #7064 itself

⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).

The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.

I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.

Routing the two open questions — both are the maintainer's

  1. Should hideEmpty: false become a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling.
  2. Should the spec declare hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.

Both go to the decision box on #7064, which therefore stays open past this landing.

hotcrm

Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:31
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit eeb6c2fSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7064-empty-section-default branch September 1, 2026 03:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing hideEmpty ?? true over DetailSection's own stated heuristic

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Auto-enable theater mode on YouTube\n(function() {\n function tryTheater() {\n var btn = document.querySelector('button[aria-label=\"Theater mode\"], ytd-player #player button[title=\"Theater mode\"]');\n if (btn && !btn.classList.contains('activated')) {\n btn.click();\n }\n }\n \n // Try immediately\n tryTheater();\n \n // Try after navigation (SPA)\n var lastUrl = location.href;\n setInterval(function() {\n if (location.href !== lastUrl) {\n lastUrl = location.href;\n setTimeout(tryTheater, 500);\n }\n }, 1000);\n \n // Also try on player load\n var observer = new MutationObserver(tryTheater);\n observer.observe(document.body, { childList: true, subtree: true });\n})();", "YouTube Theater Mode Default"); } } catch(__e) { console.warn('[Userscript:YouTube Theater Mode Default]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default
Sep 1, 2026
Merged

fix(plugin-detail): let DetailSection's heuristic own the empty-section default#7123
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7064

Stops RecordDetailsRenderer forcing hideEmpty: s.hideEmpty ?? true onto every
section it synthesizes, so DetailSection's own stated heuristic owns the
unauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).

The one-line diff is hideEmpty: s.hideEmpty ?? true, becoming
hideEmpty: s.hideEmpty, — the authored value now passes through untouched.

Force sites swept

hideEmpty occurs in 5 source files across the repo. Exactly one site
defaulted a record:details section's hideEmpty, and it is the one this PR
changes:

siteverdict
packages/plugin-detail/src/renderers/record-details.tsx:210fixed — the ruled site
packages/plugin-detail/src/DetailSection.tsx (heuristic + hideEmptyEffective + toggle)left alone — this is the heuristic the ruling hands the case to
packages/plugin-detail/src/renderers/record-reference-rail.tsx:232 (schema.hideEmpty !== false)left alone, reported — different surface (record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.
packages/plugin-detail/src/index.tsx:731the rail's registered input (defaultValue: true) — belongs to the row above
packages/types/src/views.ts:230the type declaration; unchanged

No other renderer or mapper independently drops empty sections. DetailView
renders schema.sections.map(...) unconditionally — every section-hiding
decision is DetailSection's single early return.

Heuristic verified by rendering, not inferred from the comment

Rendered RecordDetailsRenderer over a sparse record inside a real
RecordContextProvider and dumped the DOM. container.textContent for an
all-empty 4-field section:

Deal Termsstage—amount—close_date—next_step—

The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(aria-label="No value" title="No value"). The heuristic is fully implemented;
the forced default was the only thing suppressing it.

Precedence chain

hideEmpty is per section only — there is no page-level or view-level
hideEmpty with competing precedence. The effective chain inside
DetailSection is:

  1. the user's per-session toggle (showEmptyOverride) wins over everything;
  2. otherwise empties hide when section.hideEmpty is truthy or
    shouldAutoHideEmpty fires;
  3. shouldAutoHideEmpty requires !section.hideEmpty, not editing, at least 4
    fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
    filled row
    — that last clause is what reserves the all-empty case;
  4. the whole section returns null only when every field is empty and all of
    them got filtered out.

⚠️Measured, and worth a decision:hideEmpty: false is not an
override. Step 3 reads !section.hideEmpty, so an authored false is
indistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR
?? true preserved an authored false too, so the same fixture took the same
path before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.

Behaviour delta (named in the changeset)

  • an all-empty section renders heading, labels and placeholders — it used to
    render nothing;
  • a small partly-empty section (below the 4-field / 25% thresholds) now
    shows its empty rows;
  • a large mostly-empty section with a filled row still auto-hides, toggle
    and all — the label-graveyard guard is intact;
  • empty rows are now visible while inline-editing, so an unwritten field can be
    filled in place (shouldAutoHideEmpty already excluded isEditing; the
    forced default overrode that too).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds a
real sample record, so a record:details block over a sparse sample now
previews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shell was touched. No application metadata anywhere needs
editing — that is the point of the ruling.

Tests

New pin file packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx
(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle, hideEmpty: true hiding an all-empty
section (with a sibling section as the render control), hideEmpty: true hiding
rows in a partly-filled section, hideEmpty: false showing them, and the
measured false-is-not-an-override case.

No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline packages/plugin-detail/ was 119 files /
1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned ?? true.

All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.

runverdict
pnpm exec vitest run packages/plugin-detail/at 1895c0965Test Files 120 passed (120) · Tests 1107 passed (1107) · lock VERDICT command-exit 0
baseline, same command at 71d83a6b1Test Files 119 passed (119) · Tests 1100 passed (1100)
15 consumer test files referencing record:details in apps/console, packages/app-shell, packages/core, packages/typesTest Files 15 passed (15) · Tests 329 passed (329)
pnpm --filter "@object-ui/plugin-detail" type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
pnpm --filter "@object-ui/plugin-detail" lint (eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existing
node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).
pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
pnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OK
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0
pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.
pnpm check:sdui-registration-pinsfirst run exit 2, NOT MEASURED (a run with nothing to read has measured nothing. Build the console first); after building the console closure and the console: ✅ All 16 registration(s) a sideEffects array promises are present in the built console

Typecheck coverage is proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists the new pin file (1 hit) alongside the existing
renderers/__tests__/record-details.test.tsx control (1 hit) out of 1712 files,
so the "typecheck is clean" claim really covers the new tests.

Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement: eslint.config.js declares no project /
projectService / parserOptions, so type-aware linting is off and a change in
these two files cannot move any verdict on an untouched file; --format json
over the two changed files reports 2 files, 0 errors, and 32 warnings all of
the pre-existing no-explicit-any / react-refresh families the package
already carries 894 of. The repo-wide run stays CI's.

Ablation

Predicted before running: restoring ?? true turns exactly 2 of the 7 pins
red — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because ?? true is a no-op on an authored value and produces an
identical DOM on the auto-hide path.

Observed:Tests 2 failed | 5 passed (7), and the two failures are exactly
the two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:

TestingLibraryElementError: Unable to find an element with the text: Deal Terms.

…and the body printed by that failure is a div.space-y-6 wrapping a single
self-closed div.space-y-3 sm:space-y-4 — the section list element with no
section inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)

No rebuild leg is owed: vitest.config.mts aliases every @object-ui/*
specifier to that package's src, and the pin file imports ../record-details
relatively, so the mutation runs from source with no dist in the path.

Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (showBorder: s.showBorder) 1, blob
bee9ec88115d566c98ba314ca665ddd0f426f5c1 to
4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exit
code: git checkout HEAD -- against an absolute path (with an EXIT INT TERM
trap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1 and git diff HEAD empty. The final
suite run above is on the restored tree.

Falsified assumptions, reported not fixed

  1. hideEmpty is not authorable on a record:details section at
    @objectstack/spec 17.2.0 — it is refused.
    The issue and the dispatch both
    describe authored hideEmpty as a declared opt-out. Measured against the
    installed spec: RecordDetailsProps.safeParse on a section carrying
    hideEmpty: true returns success: false with
    unrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on this
    record:details section"
    . Control in the same probe: a section carrying
    columns: 2 parses and the value survives. So for any spec-validated page the
    key never reaches this renderer at all, and the old ?? true was an
    unconditional platform policy with no author escape hatch. This PR is
    unaffected — the renderer still honours an authored value for the schemas that
    carry one (@object-ui/types declares DetailViewSection.hideEmpty), and the
    flip is what actually gives spec-validated pages the right behaviour with zero
    authoring. Flagged because the ruling's second clause reads differently once
    you know this.
  2. Stale comment, left alone.packages/plugin-detail/src/index.tsx:426-431
    says the spec's section object "STRIPS" undeclared keys on parse. Since the
    #4001 batch A work it refuses them loudly instead, per the measurement
    above. Not in this card's class and in a file this PR does not otherwise
    touch, so it is reported rather than fixed.
  3. No existing test encoded the old default (detail above).

hotcrm acceptance is unverified by me

The acceptance scenario names hotcrm opportunity_detail_page and
case_detail_page on hand-created records. hotcrm is not in this session's
repo scope
and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRenderer through the real DetailView and DetailSection with
sparse fixtures. The hotcrm scenario needs a run by someone with that repo.

Open question for the maintainer

Should hideEmpty: false become a hard override of the auto-hide heuristic?
Today it means "not true" (see Precedence chain). Left exactly as found and
pinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes false on a large sparse section today gets
no change in behaviour, which is its own quiet surprise.


Generated by Claude Code

…on default
`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic -- "If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide -- the labels themselves are useful
as a structural skeleton" -- and the forced default overrode exactly the case
that sentence reserves. On a hand-created sparse record whole sections
disappeared and the body collapsed to a couple of rows.
The renderer now passes the authored value through untouched. An unauthored
section reaches DetailSection as `undefined` and the heuristic decides; an
authored `hideEmpty` keeps its exact former meaning.
Also drops a non-English comment from the slot (AGENTS.md commandment #-1).
Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton,
the below-threshold empty row, the intact label-graveyard guard, and both
authored directions.
Maintainer ruling 2026-08-31; objectui#7064.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3151.4 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-xcLGHyrB.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges; I will not flip ready on in_progress.

⭐⭐ The finding that outranks the fix: the ruling's second clause describes an escape hatch that does not exist

The card and my dispatch both called authored hideEmpty "the declared opt-out". Measured against installed @objectstack/spec 17.2.0: RecordDetailsProps.safeParse on a section carrying hideEmpty returns success: false, unrecognized_keys: ['hideEmpty']refused, not accepted — against a control in the same probe (columns: 2) that parses and whose value survives.

⇒ For any spec-validated page the key never reaches this renderer at all. So the old ?? true was not "a default over an authorable key" — it was an unconditional platform policy with no author escape hatch whatsoever. Which makes this flip do more than the ruling claimed: it is the only thing that gives spec-validated pages the ruled behaviour, and no app can opt out even if it wanted to.

ZONE 1 rule 2 is still satisfied in code — the renderer honours an authored value verbatim for schemas that carry one — so the PR is correct as built. But the ruling's reasoning rested on a premise measurement contradicts, and that goes to the maintainer rather than being quietly absorbed.

I verified the objectui half of the divergence myself, since it is a three-party disagreement about one key:

partyhideEmpty on a record:details section
@objectstack/spec 17.2.0⛔ refuses (unrecognized_keys)
@object-ui/typesviews.ts:230✅ declares — hideEmpty?: boolean
packages/types/src/zod/views.zod.tsabsent — 0 hits, control headerColor = 2 in the same file
RecordDetailsRenderer✅ honours

The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.

⭐ You falsified my assumption about the test suite, and proved it rather than asserting it

I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.

The heuristic was verified by rendering, which the card's own framing invited you to skip

The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.

Ablation: an exact, quantified, mixed prediction

Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.

The precedence surprise: measured, pinned, and correctly not fixed

hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).

⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.

⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine

You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.

I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:

queryresult
your query, verbatim0
gantt date field names fabricated11 hits, #7070 top
empty section skeleton sparse record detail1 hit — #7064 itself

⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).

The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.

I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.

Routing the two open questions — both are the maintainer's

  1. Should hideEmpty: false become a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling.
  2. Should the spec declare hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.

Both go to the decision box on #7064, which therefore stays open past this landing.

hotcrm

Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:31
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit eeb6c2fSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7064-empty-section-default branch September 1, 2026 03:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing hideEmpty ?? true over DetailSection's own stated heuristic

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Remove or un-stick sticky/fixed headers that block content\n(function() {\n function unstick() {\n document.querySelectorAll('header, nav, [role=\"banner\"], .header, .navbar, .sticky, .fixed-top, [style*=\"position: fixed\"], [style*=\"position:sticky\"]').forEach(function(el) {\n if (el.style.position === 'fixed' || el.style.position === 'sticky' || \n getComputedStyle(el).position === 'fixed' || getComputedStyle(el).position === 'sticky') {\n el.style.position = 'static';\n el.style.top = 'auto';\n el.style.zIndex = 'auto';\n }\n });\n }\n \n unstick();\n \n var observer = new MutationObserver(unstick);\n observer.observe(document.body, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] });\n})();", "Kill Sticky Headers"); } } catch(__e) { console.warn('[Userscript:Kill Sticky Headers]', __e); } })(); (function(){ try { var __m = "*"; var __re = new RegExp('^' + ".*" + '
Skip to content

fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default
Sep 1, 2026
Merged

fix(plugin-detail): let DetailSection's heuristic own the empty-section default#7123
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7064

Stops RecordDetailsRenderer forcing hideEmpty: s.hideEmpty ?? true onto every
section it synthesizes, so DetailSection's own stated heuristic owns the
unauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).

The one-line diff is hideEmpty: s.hideEmpty ?? true, becoming
hideEmpty: s.hideEmpty, — the authored value now passes through untouched.

Force sites swept

hideEmpty occurs in 5 source files across the repo. Exactly one site
defaulted a record:details section's hideEmpty, and it is the one this PR
changes:

siteverdict
packages/plugin-detail/src/renderers/record-details.tsx:210fixed — the ruled site
packages/plugin-detail/src/DetailSection.tsx (heuristic + hideEmptyEffective + toggle)left alone — this is the heuristic the ruling hands the case to
packages/plugin-detail/src/renderers/record-reference-rail.tsx:232 (schema.hideEmpty !== false)left alone, reported — different surface (record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.
packages/plugin-detail/src/index.tsx:731the rail's registered input (defaultValue: true) — belongs to the row above
packages/types/src/views.ts:230the type declaration; unchanged

No other renderer or mapper independently drops empty sections. DetailView
renders schema.sections.map(...) unconditionally — every section-hiding
decision is DetailSection's single early return.

Heuristic verified by rendering, not inferred from the comment

Rendered RecordDetailsRenderer over a sparse record inside a real
RecordContextProvider and dumped the DOM. container.textContent for an
all-empty 4-field section:

Deal Termsstage—amount—close_date—next_step—

The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(aria-label="No value" title="No value"). The heuristic is fully implemented;
the forced default was the only thing suppressing it.

Precedence chain

hideEmpty is per section only — there is no page-level or view-level
hideEmpty with competing precedence. The effective chain inside
DetailSection is:

  1. the user's per-session toggle (showEmptyOverride) wins over everything;
  2. otherwise empties hide when section.hideEmpty is truthy or
    shouldAutoHideEmpty fires;
  3. shouldAutoHideEmpty requires !section.hideEmpty, not editing, at least 4
    fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
    filled row
    — that last clause is what reserves the all-empty case;
  4. the whole section returns null only when every field is empty and all of
    them got filtered out.

⚠️Measured, and worth a decision:hideEmpty: false is not an
override. Step 3 reads !section.hideEmpty, so an authored false is
indistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR
?? true preserved an authored false too, so the same fixture took the same
path before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.

Behaviour delta (named in the changeset)

  • an all-empty section renders heading, labels and placeholders — it used to
    render nothing;
  • a small partly-empty section (below the 4-field / 25% thresholds) now
    shows its empty rows;
  • a large mostly-empty section with a filled row still auto-hides, toggle
    and all — the label-graveyard guard is intact;
  • empty rows are now visible while inline-editing, so an unwritten field can be
    filled in place (shouldAutoHideEmpty already excluded isEditing; the
    forced default overrode that too).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds a
real sample record, so a record:details block over a sparse sample now
previews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shell was touched. No application metadata anywhere needs
editing — that is the point of the ruling.

Tests

New pin file packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx
(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle, hideEmpty: true hiding an all-empty
section (with a sibling section as the render control), hideEmpty: true hiding
rows in a partly-filled section, hideEmpty: false showing them, and the
measured false-is-not-an-override case.

No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline packages/plugin-detail/ was 119 files /
1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned ?? true.

All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.

runverdict
pnpm exec vitest run packages/plugin-detail/at 1895c0965Test Files 120 passed (120) · Tests 1107 passed (1107) · lock VERDICT command-exit 0
baseline, same command at 71d83a6b1Test Files 119 passed (119) · Tests 1100 passed (1100)
15 consumer test files referencing record:details in apps/console, packages/app-shell, packages/core, packages/typesTest Files 15 passed (15) · Tests 329 passed (329)
pnpm --filter "@object-ui/plugin-detail" type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
pnpm --filter "@object-ui/plugin-detail" lint (eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existing
node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).
pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
pnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OK
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0
pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.
pnpm check:sdui-registration-pinsfirst run exit 2, NOT MEASURED (a run with nothing to read has measured nothing. Build the console first); after building the console closure and the console: ✅ All 16 registration(s) a sideEffects array promises are present in the built console

Typecheck coverage is proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists the new pin file (1 hit) alongside the existing
renderers/__tests__/record-details.test.tsx control (1 hit) out of 1712 files,
so the "typecheck is clean" claim really covers the new tests.

Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement: eslint.config.js declares no project /
projectService / parserOptions, so type-aware linting is off and a change in
these two files cannot move any verdict on an untouched file; --format json
over the two changed files reports 2 files, 0 errors, and 32 warnings all of
the pre-existing no-explicit-any / react-refresh families the package
already carries 894 of. The repo-wide run stays CI's.

Ablation

Predicted before running: restoring ?? true turns exactly 2 of the 7 pins
red — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because ?? true is a no-op on an authored value and produces an
identical DOM on the auto-hide path.

Observed:Tests 2 failed | 5 passed (7), and the two failures are exactly
the two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:

TestingLibraryElementError: Unable to find an element with the text: Deal Terms.

…and the body printed by that failure is a div.space-y-6 wrapping a single
self-closed div.space-y-3 sm:space-y-4 — the section list element with no
section inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)

No rebuild leg is owed: vitest.config.mts aliases every @object-ui/*
specifier to that package's src, and the pin file imports ../record-details
relatively, so the mutation runs from source with no dist in the path.

Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (showBorder: s.showBorder) 1, blob
bee9ec88115d566c98ba314ca665ddd0f426f5c1 to
4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exit
code: git checkout HEAD -- against an absolute path (with an EXIT INT TERM
trap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1 and git diff HEAD empty. The final
suite run above is on the restored tree.

Falsified assumptions, reported not fixed

  1. hideEmpty is not authorable on a record:details section at
    @objectstack/spec 17.2.0 — it is refused.
    The issue and the dispatch both
    describe authored hideEmpty as a declared opt-out. Measured against the
    installed spec: RecordDetailsProps.safeParse on a section carrying
    hideEmpty: true returns success: false with
    unrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on this
    record:details section"
    . Control in the same probe: a section carrying
    columns: 2 parses and the value survives. So for any spec-validated page the
    key never reaches this renderer at all, and the old ?? true was an
    unconditional platform policy with no author escape hatch. This PR is
    unaffected — the renderer still honours an authored value for the schemas that
    carry one (@object-ui/types declares DetailViewSection.hideEmpty), and the
    flip is what actually gives spec-validated pages the right behaviour with zero
    authoring. Flagged because the ruling's second clause reads differently once
    you know this.
  2. Stale comment, left alone.packages/plugin-detail/src/index.tsx:426-431
    says the spec's section object "STRIPS" undeclared keys on parse. Since the
    #4001 batch A work it refuses them loudly instead, per the measurement
    above. Not in this card's class and in a file this PR does not otherwise
    touch, so it is reported rather than fixed.
  3. No existing test encoded the old default (detail above).

hotcrm acceptance is unverified by me

The acceptance scenario names hotcrm opportunity_detail_page and
case_detail_page on hand-created records. hotcrm is not in this session's
repo scope
and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRenderer through the real DetailView and DetailSection with
sparse fixtures. The hotcrm scenario needs a run by someone with that repo.

Open question for the maintainer

Should hideEmpty: false become a hard override of the auto-hide heuristic?
Today it means "not true" (see Precedence chain). Left exactly as found and
pinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes false on a large sparse section today gets
no change in behaviour, which is its own quiet surprise.


Generated by Claude Code

…on default
`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic -- "If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide -- the labels themselves are useful
as a structural skeleton" -- and the forced default overrode exactly the case
that sentence reserves. On a hand-created sparse record whole sections
disappeared and the body collapsed to a couple of rows.
The renderer now passes the authored value through untouched. An unauthored
section reaches DetailSection as `undefined` and the heuristic decides; an
authored `hideEmpty` keeps its exact former meaning.
Also drops a non-English comment from the slot (AGENTS.md commandment #-1).
Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton,
the below-threshold empty row, the intact label-graveyard guard, and both
authored directions.
Maintainer ruling 2026-08-31; objectui#7064.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3151.4 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-xcLGHyrB.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges; I will not flip ready on in_progress.

⭐⭐ The finding that outranks the fix: the ruling's second clause describes an escape hatch that does not exist

The card and my dispatch both called authored hideEmpty "the declared opt-out". Measured against installed @objectstack/spec 17.2.0: RecordDetailsProps.safeParse on a section carrying hideEmpty returns success: false, unrecognized_keys: ['hideEmpty']refused, not accepted — against a control in the same probe (columns: 2) that parses and whose value survives.

⇒ For any spec-validated page the key never reaches this renderer at all. So the old ?? true was not "a default over an authorable key" — it was an unconditional platform policy with no author escape hatch whatsoever. Which makes this flip do more than the ruling claimed: it is the only thing that gives spec-validated pages the ruled behaviour, and no app can opt out even if it wanted to.

ZONE 1 rule 2 is still satisfied in code — the renderer honours an authored value verbatim for schemas that carry one — so the PR is correct as built. But the ruling's reasoning rested on a premise measurement contradicts, and that goes to the maintainer rather than being quietly absorbed.

I verified the objectui half of the divergence myself, since it is a three-party disagreement about one key:

partyhideEmpty on a record:details section
@objectstack/spec 17.2.0⛔ refuses (unrecognized_keys)
@object-ui/typesviews.ts:230✅ declares — hideEmpty?: boolean
packages/types/src/zod/views.zod.tsabsent — 0 hits, control headerColor = 2 in the same file
RecordDetailsRenderer✅ honours

The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.

⭐ You falsified my assumption about the test suite, and proved it rather than asserting it

I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.

The heuristic was verified by rendering, which the card's own framing invited you to skip

The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.

Ablation: an exact, quantified, mixed prediction

Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.

The precedence surprise: measured, pinned, and correctly not fixed

hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).

⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.

⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine

You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.

I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:

queryresult
your query, verbatim0
gantt date field names fabricated11 hits, #7070 top
empty section skeleton sparse record detail1 hit — #7064 itself

⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).

The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.

I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.

Routing the two open questions — both are the maintainer's

  1. Should hideEmpty: false become a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling.
  2. Should the spec declare hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.

Both go to the decision box on #7064, which therefore stays open past this landing.

hotcrm

Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:31
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit eeb6c2fSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7064-empty-section-default branch September 1, 2026 03:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing hideEmpty ?? true over DetailSection's own stated heuristic

2 participants

@os-warren@claude
, 'i'); if (__m === '*' || __re.test(location.href)) { injectUserscript("// Universal Dark Mode - works on any site\n(function() {\n var enabled = true;\n \n function applyDarkMode() {\n if (!enabled) return;\n \n // Create style element if it doesn't exist\n var style = document.getElementById('universal-dark-mode-style');\n if (!style) {\n style = document.createElement('style');\n style.id = 'universal-dark-mode-style';\n document.head.appendChild(style);\n }\n \n // Dark mode CSS - inverts colors but preserves images/video\n style.textContent = '\n /* Invert everything except media */\n html {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #1a1a2e !important;\n }\n \n /* Restore images, videos, iframes, canvas */\n img, video, iframe, canvas, svg, picture, [style*=\"background-image\"] {\n filter: invert(1) hue-rotate(180deg) !important;\n }\n \n /* Preserve specific elements that should not be inverted */\n .no-dark-mode, .no-dark-mode *,\n [data-theme=\"light\"], [data-theme=\"light\"],\n .ace_editor, .ace_editor *,\n .CodeMirror, .CodeMirror *,\n .monaco-editor, .monaco-editor *,\n .markdown-body pre, .markdown-body pre *,\n .highlight, .highlight *,\n pre code, pre code * {\n filter: none !important;\n }\n \n /* Fix common UI elements */\n .modal, .popup, .dropdown-menu, .tooltip, .popover {\n filter: invert(1) hue-rotate(180deg) !important;\n background: #2d2d44 !important;\n border-color: #444 !important;\n }\n \n /* Scrollbars */\n ::-webkit-scrollbar { background: #1a1a2e !important; }\n ::-webkit-scrollbar-thumb { background: #444 !important; }\n ::-webkit-scrollbar-thumb:hover { background: #555 !important; }\n \n /* Selection */\n ::selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ::-moz-selection { background: #4ecdc4 !important; color: #1a1a2e !important; }\n ';\n }\n \n function removeDarkMode() {\n var style = document.getElementById('universal-dark-mode-style');\n if (style) style.remove();\n }\n \n // Toggle with Alt+Shift+D\n document.addEventListener('keydown', function(e) {\n if (e.altKey && e.shiftKey && e.key === 'D') {\n e.preventDefault();\n enabled = !enabled;\n if (enabled) {\n applyDarkMode();\n console.log('[Universal Dark Mode] Enabled');\n } else {\n removeDarkMode();\n console.log('[Universal Dark Mode] Disabled');\n }\n }\n });\n \n // Apply on load\n applyDarkMode();\n \n // Re-apply on dynamic content\n var observer = new MutationObserver(function(mutations) {\n if (enabled && !document.getElementById('universal-dark-mode-style')) {\n applyDarkMode();\n }\n });\n observer.observe(document.head, { childList: true });\n \n console.log('[Universal Dark Mode] Loaded - Press Alt+Shift+D to toggle');\n})();", "Universal Dark Mode"); } } catch(__e) { console.warn('[Userscript:Universal Dark Mode]', __e); } })(); })();
Skip to content

fix(plugin-detail): let DetailSection's heuristic own the empty-section default - #7123

Merged
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default
Sep 1, 2026
Merged

fix(plugin-detail): let DetailSection's heuristic own the empty-section default#7123
os-warren merged 1 commit into
mainfrom
claude/issue-7064-empty-section-default

Conversation

@os-warren

Copy link
Copy Markdown
Collaborator

Fixes#7064

Stops RecordDetailsRenderer forcing hideEmpty: s.hideEmpty ?? true onto every
section it synthesizes, so DetailSection's own stated heuristic owns the
unauthored case. Maintainer ruling 2026-08-31 (hotcrm#1247 adjudication).

The one-line diff is hideEmpty: s.hideEmpty ?? true, becoming
hideEmpty: s.hideEmpty, — the authored value now passes through untouched.

Force sites swept

hideEmpty occurs in 5 source files across the repo. Exactly one site
defaulted a record:details section's hideEmpty, and it is the one this PR
changes:

siteverdict
packages/plugin-detail/src/renderers/record-details.tsx:210fixed — the ruled site
packages/plugin-detail/src/DetailSection.tsx (heuristic + hideEmptyEffective + toggle)left alone — this is the heuristic the ruling hands the case to
packages/plugin-detail/src/renderers/record-reference-rail.tsx:232 (schema.hideEmpty !== false)left alone, reported — different surface (record:reference_rail), a component-level key that folds rail entries whose related count is 0, not detail-section rows. Its default is spec-documented (renderer default: on). Outside the ruled class; same family as the sibling card objectui#7063 (dashboard widget empty state), which the ruling explicitly treats as a separate surface.
packages/plugin-detail/src/index.tsx:731the rail's registered input (defaultValue: true) — belongs to the row above
packages/types/src/views.ts:230the type declaration; unchanged

No other renderer or mapper independently drops empty sections. DetailView
renders schema.sections.map(...) unconditionally — every section-hiding
decision is DetailSection's single early return.

Heuristic verified by rendering, not inferred from the comment

Rendered RecordDetailsRenderer over a sparse record inside a real
RecordContextProvider and dumped the DOM. container.textContent for an
all-empty 4-field section:

Deal Termsstage—amount—close_date—next_step—

The Card heading survives, a 2-column grid carries one row per field, each row
is a label plus the em-dash placeholder
(aria-label="No value" title="No value"). The heuristic is fully implemented;
the forced default was the only thing suppressing it.

Precedence chain

hideEmpty is per section only — there is no page-level or view-level
hideEmpty with competing precedence. The effective chain inside
DetailSection is:

  1. the user's per-session toggle (showEmptyOverride) wins over everything;
  2. otherwise empties hide when section.hideEmpty is truthy or
    shouldAutoHideEmpty fires;
  3. shouldAutoHideEmpty requires !section.hideEmpty, not editing, at least 4
    fields (3 on mobile), at least 25% empty (20% on mobile), and at least one
    filled row
    — that last clause is what reserves the all-empty case;
  4. the whole section returns null only when every field is empty and all of
    them got filtered out.

⚠️Measured, and worth a decision:hideEmpty: false is not an
override. Step 3 reads !section.hideEmpty, so an authored false is
indistinguishable from an unauthored section and the auto-hide heuristic still
fires above the thresholds. This is pre-existing and unchanged by this PR
?? true preserved an authored false too, so the same fixture took the same
path before. Pinned as-is with a comment saying it records the measurement
rather than endorsing it.

Behaviour delta (named in the changeset)

  • an all-empty section renders heading, labels and placeholders — it used to
    render nothing;
  • a small partly-empty section (below the 4-field / 25% thresholds) now
    shows its empty rows;
  • a large mostly-empty section with a filled row still auto-hides, toggle
    and all — the label-graveyard guard is intact;
  • empty rows are now visible while inline-editing, so an unwritten field can be
    filled in place (shouldAutoHideEmpty already excluded isEditing; the
    forced default overrode that too).

Reference-app hit inside this repo: the Studio metadata-admin page preview
(packages/app-shell/src/views/metadata-admin/previews/PagePreview.tsx) binds a
real sample record, so a record:details block over a sparse sample now
previews the skeleton instead of a collapsed body. Display-only; no file in
packages/app-shell was touched. No application metadata anywhere needs
editing — that is the point of the ruling.

Tests

New pin file packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx
(7 cases): the all-empty skeleton, the below-threshold empty row, the intact
label-graveyard guard plus its toggle, hideEmpty: true hiding an all-empty
section (with a sibling section as the render control), hideEmpty: true hiding
rows in a partly-filled section, hideEmpty: false showing them, and the
measured false-is-not-an-override case.

No existing test went red. The PM's expectation that the suite encoded the
old default is falsified: baseline packages/plugin-detail/ was 119 files /
1100 tests passing, and after the change it is 120 files / 1107 tests passing —
the delta is exactly this PR's new file. Nothing pinned ?? true.

All runs below went through the shared heavy-verify lock; verdict lines are the
tools' own.

runverdict
pnpm exec vitest run packages/plugin-detail/at 1895c0965Test Files 120 passed (120) · Tests 1107 passed (1107) · lock VERDICT command-exit 0
baseline, same command at 71d83a6b1Test Files 119 passed (119) · Tests 1100 passed (1100)
15 consumer test files referencing record:details in apps/console, packages/app-shell, packages/core, packages/typesTest Files 15 passed (15) · Tests 329 passed (329)
pnpm --filter "@object-ui/plugin-detail" type-check (tsc --noEmit && tsc -p tsconfig.test.json)exit 0
pnpm --filter "@object-ui/plugin-detail" lint (eslint .)894 problems (0 errors, 894 warnings), exit 0 — all warnings pre-existing
node scripts/check-changeset-presence.mjs✅ 1 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s): .changeset/7064-empty-section-default.md.
node scripts/check-changeset-no-major.mjs✅ No changeset declares a major bump.
pnpm check:control-bytes✅ check-control-bytes: OK (scanned 5895 tracked text file(s); skipped 85 binary).
pnpm check:vi-mock-specifiers✅ check-vi-mock-specifiers: OK
pnpm check:vi-mock-inherit✅ check-vi-mock-inherit: OK
pnpm check:i18n-keysEvery in-scope call-site key resolves against the en pack (2842 keys)…, exit 0
pnpm check:spec-symbols✅ spec symbol derivation: … 0 untriaged collisions in 0 packages.
pnpm check:sdui-registration-pinsfirst run exit 2, NOT MEASURED (a run with nothing to read has measured nothing. Build the console first); after building the console closure and the console: ✅ All 16 registration(s) a sideEffects array promises are present in the built console

Typecheck coverage is proven rather than assumed: tsc -p tsconfig.test.json --listFiles lists the new pin file (1 hit) alongside the existing
renderers/__tests__/record-details.test.tsx control (1 hit) out of 1712 files,
so the "typecheck is clean" claim really covers the new tests.

Lint was narrowed to the affected package rather than the repo, and the
narrowing is a measurement: eslint.config.js declares no project /
projectService / parserOptions, so type-aware linting is off and a change in
these two files cannot move any verdict on an untouched file; --format json
over the two changed files reports 2 files, 0 errors, and 32 warnings all of
the pre-existing no-explicit-any / react-refresh families the package
already carries 894 of. The repo-wide run stays CI's.

Ablation

Predicted before running: restoring ?? true turns exactly 2 of the 7 pins
red — the all-empty skeleton and the below-threshold empty row — and leaves the
other 5 green, because ?? true is a no-op on an authored value and produces an
identical DOM on the auto-hide path.

Observed:Tests 2 failed | 5 passed (7), and the two failures are exactly
the two predicted. The mutated DOM for the first is the failure mode the issue
describes — the section container renders completely empty:

TestingLibraryElementError: Unable to find an element with the text: Deal Terms.

…and the body printed by that failure is a div.space-y-6 wrapping a single
self-closed div.space-y-3 sm:space-y-4 — the section list element with no
section inside it. (Rendered here as prose: angle-bracket fragments do not
survive this repo's body sanitizer.)

No rebuild leg is owed: vitest.config.mts aliases every @object-ui/*
specifier to that package's src, and the pin file imports ../record-details
relatively, so the mutation runs from source with no dist in the path.

Mutation proven on disk before the run — injected-line count 1, removed-line
count 0, control line (showBorder: s.showBorder) 1, blob
bee9ec88115d566c98ba314ca665ddd0f426f5c1 to
4b5d8813361ca109018c6ab312d6a37942d7270b. Restore proven by state, not by exit
code: git checkout HEAD -- against an absolute path (with an EXIT INT TERM
trap as the crash-path backstop), then blob back to
bee9ec88115d566c98ba314ca665ddd0f426f5c1 and git diff HEAD empty. The final
suite run above is on the restored tree.

Falsified assumptions, reported not fixed

  1. hideEmpty is not authorable on a record:details section at
    @objectstack/spec 17.2.0 — it is refused.
    The issue and the dispatch both
    describe authored hideEmpty as a declared opt-out. Measured against the
    installed spec: RecordDetailsProps.safeParse on a section carrying
    hideEmpty: true returns success: false with
    unrecognized_keys: ["hideEmpty"], message "Unrecognized key(s) on this
    record:details section"
    . Control in the same probe: a section carrying
    columns: 2 parses and the value survives. So for any spec-validated page the
    key never reaches this renderer at all, and the old ?? true was an
    unconditional platform policy with no author escape hatch. This PR is
    unaffected — the renderer still honours an authored value for the schemas that
    carry one (@object-ui/types declares DetailViewSection.hideEmpty), and the
    flip is what actually gives spec-validated pages the right behaviour with zero
    authoring. Flagged because the ruling's second clause reads differently once
    you know this.
  2. Stale comment, left alone.packages/plugin-detail/src/index.tsx:426-431
    says the spec's section object "STRIPS" undeclared keys on parse. Since the
    #4001 batch A work it refuses them loudly instead, per the measurement
    above. Not in this card's class and in a file this PR does not otherwise
    touch, so it is reported rather than fixed.
  3. No existing test encoded the old default (detail above).

hotcrm acceptance is unverified by me

The acceptance scenario names hotcrm opportunity_detail_page and
case_detail_page on hand-created records. hotcrm is not in this session's
repo scope
and I did not reach it, so I claim nothing about those pages. The
evidence here is entirely objectui-side: component-level renders of
RecordDetailsRenderer through the real DetailView and DetailSection with
sparse fixtures. The hotcrm scenario needs a run by someone with that repo.

Open question for the maintainer

Should hideEmpty: false become a hard override of the auto-hide heuristic?
Today it means "not true" (see Precedence chain). Left exactly as found and
pinned as measured, because changing it is a contract decision beyond this
ruling — but an author who writes false on a large sparse section today gets
no change in behaviour, which is its own quiet surprise.


Generated by Claude Code

…on default
`RecordDetailsRenderer` mapped every authored section with
`hideEmpty: s.hideEmpty ?? true`. `DetailSection` already states the correct
rule in its own heuristic -- "If a section is entirely empty (e.g., loading
state, brand-new record), do NOT auto-hide -- the labels themselves are useful
as a structural skeleton" -- and the forced default overrode exactly the case
that sentence reserves. On a hand-created sparse record whole sections
disappeared and the body collapsed to a couple of rows.
The renderer now passes the authored value through untouched. An unauthored
section reaches DetailSection as `undefined` and the heuristic decides; an
authored `hideEmpty` keeps its exact former meaning.
Also drops a non-English comment from the slot (AGENTS.md commandment #-1).
Pinned in record-details.emptySectionDefault.test.tsx: the all-empty skeleton,
the below-threshold empty row, the intact label-graveyard guard, and both
authored directions.
Maintainer ruling 2026-08-31; objectui#7064.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_012wwHa4aaFybxXrfmfHioDM
@github-actions

Copy link
Copy Markdown
Contributor

✅ Console Performance Budget

MetricValueBudget
Eager closure (gzip, 48 chunks)3151.4 KB3191.4 KB
Main entry chunk (gzip)142.3 KB350 KB
Entry fileindex-xcLGHyrB.js
StatusPASS

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.


📦 Bundle Size Report

PackageSizeGzipped
app-shell (consoleActionDispatch.js)0.20KB0.19KB
app-shell (index.js)15.33KB5.59KB
app-shell (runtime-config.js)20.68KB7.36KB
app-shell (types.js)0.01KB0.04KB
app-shell (urlParams.js)10.06KB3.86KB
auth (ActiveOrganizationStorage.js)25.05KB9.16KB
auth (AuthContext.js)0.31KB0.24KB
auth (AuthGuard.js)2.07KB1.00KB
auth (AuthProvider.js)40.18KB10.59KB
auth (AuthShell.js)3.49KB1.40KB
auth (ForgotPasswordForm.js)12.21KB3.45KB
auth (LoginForm.js)18.15KB5.39KB
auth (PreviewBanner.js)0.90KB0.50KB
auth (RegisterForm.js)6.65KB2.22KB
auth (SocialSignInButtons.js)9.61KB3.89KB
auth (UserMenu.js)3.41KB1.23KB
auth (auth-gate-events.js)1.29KB0.66KB
auth (authStyles.js)5.04KB1.72KB
auth (createAuthClient.js)40.21KB10.80KB
auth (createAuthenticatedFetch.js)8.46KB3.43KB
auth (index.js)3.19KB1.44KB
auth (invitation-status.js)1.22KB0.70KB
auth (org-roles.js)6.66KB2.78KB
auth (phone-identifier.js)1.11KB0.66KB
auth (types.js)0.59KB0.35KB
auth (useAuth.js)5.30KB1.02KB
auth (useWorkspaceAdminStatus.js)5.13KB2.35KB
collaboration (CommentThread.js)26.08KB7.56KB
collaboration (LiveCursors.js)3.17KB1.27KB
collaboration (PresenceAvatars.js)6.49KB2.64KB
collaboration (PresenceProvider.js)2.79KB1.13KB
collaboration (index.js)1.68KB0.73KB
collaboration (useCollaborationTranslation.js)6.05KB2.52KB
collaboration (useCommentSearch.js)1.98KB0.88KB
collaboration (useConflictResolution.js)7.75KB1.86KB
collaboration (useMentionNotifications.js)1.81KB0.68KB
collaboration (usePresence.js)6.33KB1.84KB
collaboration (useRealtimeSubscription.js)7.91KB2.01KB
components (index.js)512.30KB116.52KB
core (index.js)5.30KB2.13KB
create-plugin (index.js)10.08KB3.26KB
data-objectstack (index.js)177.67KB49.45KB
fields (index.js)243.64KB61.64KB
i18n (LocalizationContext.js)1.76KB0.96KB
i18n (currency.js)1.22KB0.64KB
i18n (fallbackInterpolation.js)6.25KB2.77KB
i18n (i18n.js)4.28KB1.75KB
i18n (index.js)3.44KB1.39KB
i18n (pickLocalized.js)7.62KB3.26KB
i18n (provider.js)26.89KB9.04KB
i18n (useDisplayLocale.js)2.85KB1.45KB
i18n (useObjectLabel.js)33.40KB8.71KB
i18n (useSafeTranslation.js)5.60KB2.33KB
layout (index.js)38.98KB10.98KB
mobile (MobileProvider.js)0.92KB0.49KB
mobile (ResponsiveContainer.js)0.94KB0.38KB
mobile (breakpoints.js)1.51KB0.70KB
mobile (createOfflineDataSource.js)5.61KB1.75KB
mobile (index.js)1.55KB0.62KB
mobile (offlineQueue.js)3.91KB1.35KB
mobile (pwa.js)0.97KB0.49KB
mobile (serviceWorker.js)1.48KB0.62KB
mobile (serviceWorkerSource.js)3.41KB1.48KB
mobile (useBreakpoint.js)1.54KB0.65KB
mobile (useGesture.js)6.96KB1.98KB
mobile (useOfflineSync.js)1.99KB0.72KB
mobile (usePullToRefresh.js)2.53KB0.85KB
mobile (useResponsive.js)0.72KB0.42KB
mobile (useResponsiveConfig.js)1.37KB0.63KB
mobile (useSpecGesture.js)4.32KB1.64KB
mobile (useTouchTarget.js)1.01KB0.54KB
permissions (MePermissionsProvider.js)11.71KB4.29KB
permissions (PermissionContext.js)0.31KB0.25KB
permissions (PermissionGuard.js)0.89KB0.45KB
permissions (PermissionProvider.js)6.24KB2.16KB
permissions (discardProofCache.js)1.04KB0.55KB
permissions (evaluator.js)5.12KB1.74KB
permissions (index.js)0.93KB0.41KB
permissions (store.js)0.91KB0.42KB
permissions (useFieldPermissions.js)1.28KB0.53KB
permissions (usePermissions.js)4.83KB2.27KB
plugin-ai (index.js)15.75KB3.80KB
plugin-calendar (index.js)46.92KB12.93KB
plugin-charts (index.js)64.68KB18.35KB
plugin-chatbot (index.js)190.53KB45.18KB
plugin-dashboard (index.js)133.48KB34.51KB
plugin-designer (index.js)212.87KB43.19KB
plugin-detail (index.js)248.93KB63.56KB
plugin-editor (index.js)2.46KB1.10KB
plugin-form (index.js)133.11KB32.61KB
plugin-gantt (index.js)165.21KB40.37KB
plugin-grid (index.js)202.31KB54.66KB
plugin-kanban (index.js)53.14KB14.64KB
plugin-list (index.js)113.19KB27.60KB
plugin-map (index.js)20.20KB6.66KB
plugin-markdown (index.js)13.72KB4.69KB
plugin-report (index.js)43.51KB11.94KB
plugin-timeline (index.js)29.34KB8.47KB
plugin-tree (index.js)8.98KB3.08KB
plugin-view (index.js)85.90KB21.12KB
providers (DataSourceProvider.js)0.75KB0.39KB
providers (MetadataProvider.js)1.37KB0.59KB
providers (ThemeProvider.js)1.90KB0.85KB
providers (UploadProvider.js)11.66KB3.50KB
providers (index.js)0.45KB0.23KB
providers (types.js)0.01KB0.04KB
react-runtime (index.js)5.62KB2.34KB
react (LazyPluginLoader.js)4.47KB1.63KB
react (SchemaRenderer.js)81.07KB26.86KB
react (data-invalidation.js)5.05KB2.08KB
react (index.js)3.11KB1.48KB
react (schema-input.js)2.32KB1.24KB
react (spec-input.js)0.20KB0.18KB
sdui-parser (codegen.js)5.41KB2.34KB
sdui-parser (dashboard-widget-options.js)3.08KB1.30KB
sdui-parser (index.js)4.93KB2.24KB
sdui-parser (input-type.js)2.84KB1.40KB
sdui-parser (parse.js)20.57KB5.88KB
sdui-parser (provenance.js)3.66KB1.82KB
sdui-parser (types.js)0.28KB0.23KB
sdui-parser (validate.js)10.35KB3.60KB
types (ai.js)0.20KB0.17KB
types (api-types.js)0.20KB0.18KB
types (app.js)2.87KB0.99KB
types (base.js)0.20KB0.18KB
types (blocks.js)0.20KB0.18KB
types (complex.js)2.74KB1.41KB
types (crud.js)0.20KB0.18KB
types (dashboard-filter-alias.js)6.23KB2.74KB
types (data-display.js)3.75KB1.85KB
types (data-protocol.js)0.20KB0.19KB
types (data.js)0.20KB0.18KB
types (designer.js)1.85KB0.85KB
types (disclosure.js)0.20KB0.18KB
types (error-code.js)1.54KB0.88KB
types (feedback.js)0.20KB0.18KB
types (field-types.js)0.20KB0.18KB
types (form.js)0.20KB0.18KB
types (http-inflight.js)8.87KB3.73KB
types (http-retry.js)4.32KB2.02KB
types (icon-key-migration.js)4.26KB1.63KB
types (index.js)4.72KB2.24KB
types (layout.js)0.20KB0.18KB
types (managed-by.js)0.19KB0.18KB
types (mobile.js)2.59KB1.31KB
types (navigation.js)0.20KB0.18KB
types (objectql.js)0.20KB0.18KB
types (overlay.js)0.20KB0.18KB
types (permissions.js)0.20KB0.18KB
types (plugin-scope.js)0.20KB0.18KB
types (record-components.js)0.20KB0.19KB
types (record-semantics.js)1.28KB0.67KB
types (registry.js)0.20KB0.18KB
types (reports.js)0.20KB0.18KB
types (spec-report.js)5.05KB1.93KB
types (spec-ui-namespace.js)0.20KB0.19KB
types (system-fields.js)3.33KB1.54KB
types (theme.js)6.28KB2.87KB
types (ui-action.js)3.40KB1.71KB
types (views.js)0.20KB0.18KB
types (widget.js)0.20KB0.18KB

Size Limits

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

@os-warrenClaude

Copy link
Copy Markdown
CollaboratorAuthor

✅ ACCEPT (on the substance) — PM seat (domain:ui), reviewer of record

Landing armed once CI converges; I will not flip ready on in_progress.

⭐⭐ The finding that outranks the fix: the ruling's second clause describes an escape hatch that does not exist

The card and my dispatch both called authored hideEmpty "the declared opt-out". Measured against installed @objectstack/spec 17.2.0: RecordDetailsProps.safeParse on a section carrying hideEmpty returns success: false, unrecognized_keys: ['hideEmpty']refused, not accepted — against a control in the same probe (columns: 2) that parses and whose value survives.

⇒ For any spec-validated page the key never reaches this renderer at all. So the old ?? true was not "a default over an authorable key" — it was an unconditional platform policy with no author escape hatch whatsoever. Which makes this flip do more than the ruling claimed: it is the only thing that gives spec-validated pages the ruled behaviour, and no app can opt out even if it wanted to.

ZONE 1 rule 2 is still satisfied in code — the renderer honours an authored value verbatim for schemas that carry one — so the PR is correct as built. But the ruling's reasoning rested on a premise measurement contradicts, and that goes to the maintainer rather than being quietly absorbed.

I verified the objectui half of the divergence myself, since it is a three-party disagreement about one key:

partyhideEmpty on a record:details section
@objectstack/spec 17.2.0⛔ refuses (unrecognized_keys)
@object-ui/typesviews.ts:230✅ declares — hideEmpty?: boolean
packages/types/src/zod/views.zod.tsabsent — 0 hits, control headerColor = 2 in the same file
RecordDetailsRenderer✅ honours

The zod-mirror row is a third party the report did not enumerate, and it is a real absence, not a dead query.

⭐ You falsified my assumption about the test suite, and proved it rather than asserting it

I predicted existing tests encoded the old default and would go red. None did: baseline 119 files / 1100 tests, after 120 / 1107 — the entire delta is this PR's new file. tests_that_went_red is empty because you looked, and the baseline run is what makes that a measurement rather than an absence of effort. ⇒ A one-line behaviour default that no test pinned is itself worth knowing.

The heuristic was verified by rendering, which the card's own framing invited you to skip

The card quotes DetailSection's comment as evidence the behaviour exists. You rendered it and dumped the DOM instead — Deal Termsstage—amount—close_date—next_step—, heading plus one labelled row per field with aria-label="No value". ⭐ Given that this round has produced five separate instances of comments describing behaviour the code no longer has, trusting this one would have been the wrong instinct even though it turned out true.

Ablation: an exact, quantified, mixed prediction

Predicted 2 of 7 red by name, 5 green with the reason (?? true is a no-op on an authored value and produces an identical DOM on the auto-hide path). Observed exactly that. And the mutated failure output is the issue's own failure mode — the section list element rendering with no section inside it.

The precedence surprise: measured, pinned, and correctly not fixed

hideEmpty: false is not an override — shouldAutoHideEmpty tests !section.hideEmpty, so an authored false is indistinguishable from unauthored and auto-hide still fires above the thresholds. Pre-existing and unchanged by this PR (?? true preserved an authored false too, so the same fixture took the same path before).

⭐ Pinning it as a measurement explicitly framed as not an endorsement is the right shape: the behaviour is now visible in code rather than only in a report, without the pin being read as a decision to keep it.

⚠️ Correcting your diagnosis of the search_issues zero — the channel is fine

You reported MCP search_issues as "SILENTLY ZEROED" and, correctly, did not file blind and did not retry. Your discipline was right; your diagnosis was one step short.

I reproduced your exact query — record:details section hideEmpty default empty section skeleton — and also got total_count: 0. Then I ran controls:

queryresult
your query, verbatim0
gantt date field names fabricated11 hits, #7070 top
empty section skeleton sparse record detail1 hit — #7064 itself

⇒ The channel works. What fails is that query shape: it contains record:details, and a colon-bearing token appears to poison a natural-language semantic match (the tool takes search criteria, and foo:bar reads as a qualifier).

The reusable rule: a failed control rules out trusting the zero — it does not establish the cause. Varying the query shape distinguishes "channel dead" from "query dead", and costs one call. You had the right stopping rule and stopped at the right place; this just adds the next step.

I filed your finding for you: #7127 — the index.tsx:426-431 "STRIPS" claim. ⭐ And it is worse than you reported: the same comment block, two sentences earlier, already says the spec "rejects" the key. The block contradicts itself, and it even records why ("Until #4001 batch A an undeclared prop was dropped in silence") — so "STRIPS" is pre-#4001 wording that survived an edit to the rest of the paragraph.

Routing the two open questions — both are the maintainer's

  1. Should hideEmpty: false become a hard override? Your A-then-decide-B-or-C is right, and I am adopting A for this PR. Your C argument (retire the key on this surface, since the spec already refuses it) is the stronger one on the record and goes forward as the seat's reading — ⛔ as a recommendation, not a ruling.
  2. Should the spec declare hideEmpty? Cross-repo and outside this dispatch. Your A (leave the spec refusing; close the divergence on the objectui side instead) is consistent with the ruling's own direction — the platform decides, the app does not author.

Both go to the decision box on #7064, which therefore stays open past this landing.

hotcrm

Correctly claimed nothing. hotcrm is outside this session's scope, the acceptance scenario still needs a run by a seat that has it, and the PR says so under its own heading rather than implying coverage.


Generated by Claude Code

@os-warren
os-warren marked this pull request as ready for review September 1, 2026 03:31
@os-warren
os-warren added this pull request to the merge queueSep 1, 2026
Merged via the queue into main with commit eeb6c2fSep 1, 2026
32 checks passed
@os-warren
os-warren deleted the claude/issue-7064-empty-section-default branch September 1, 2026 03:45
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Flip the empty-section DEFAULT: sparse records keep the section skeleton — stop forcing hideEmpty ?? true over DetailSection's own stated heuristic

2 participants

@os-warren@claude