Skip to content

Commit 9cbe4db

Browse files
feat(plugin-detail): declare hideEmpty on the detail-section node (objectui#10485) (#10552)
Fixes #10485 Clause-②: yes Declares `hideEmpty` on the `detail-section` node, following the objectui#9529 ruling (comment `5724939941`) that the triage (`5828292064`) reuses: declare a key the renderer already honours. The registration in `packages/plugin-detail/src/index.tsx` gains `{ name: 'hideEmpty', type: 'boolean', description }`, and `DETAIL_SECTION_NODE_INPUTS` gains `'hideEmpty'`, the same two edits PR objectui#10484 made for `icon`. `DetailSection`'s hide logic is not touched: it already reads `section.hideEmpty === true`. ## What changed | File | Change | |---|---| | `packages/plugin-detail/src/index.tsx` | the `detail-section` registration gains `hideEmpty` (boolean). Its description states the node's omitted default. No `defaultValue`, since no other input of this node carries one | | `packages/plugin-detail/src/DetailSectionNode.tsx` | `DETAIL_SECTION_NODE_INPUTS` gains `'hideEmpty'`, so the adapter folds it into `section`. One comment phrase ("a ninth input") made count-free | | `packages/plugin-detail/src/__tests__/detailSectionAuthoredHideEmpty-10485.test.tsx` | the new pin, six rows | | `.changeset/10485-detail-section-declares-hideempty.md` | `minor` on `@object-ui/plugin-detail`. It names the one widened key and carries the Clause-② line | ## The omitted default: one value for the node, so it is declared, not escalated The triage asked me to stop if the `record:details` / `detail-view` difference turned out to be a real semantic split rather than a default to declare. I measured all three paths on base `0d5d66aee`, each with a sibling control section that must render (it rendered every time). The node row comes from the new pin. The other two rows come from a throwaway probe, which is not committed: `detail-view` through the real `SchemaRenderer`, `record:details` through `RecordDetailsRenderer`. | Surface | `hideEmpty` omitted | `true` | `false` | |---|---|---|---| | `detail-section` node, base | keeps heading + skeleton | keeps (warned `unknown-prop`, dropped by the fold) | keeps (warned, dropped) | | `detail-section` node, this PR | keeps | hides (no heading, no skeleton) | keeps | | `detail-view` sections | keeps | hides | keeps | | `record:details` sections | hides (`hideEmpty: s.hideEmpty ?? true` on its own authored sections) | hides | keeps | `true` and `false` mean the same thing on all three surfaces. Only `record:details` gives an omitted key a different meaning, and it does that by applying a default to its own authored sections, which never go through this node. On the node's own path nothing applies a default, and `DetailSection` checks `=== true`, so an omitted key has one meaning here: keep the section. That is exactly what `detail-view` does. The description records that default, and nothing about the behaviour changes. ## Premises, measured on base `0d5d66aee` before the edit 1. `DETAIL_SECTION_NODE_INPUTS` lives in `DetailSectionNode.tsx`, `icon` is in it, and `hideEmpty` is not. **Held.** 2. The validator warns about an authored `hideEmpty`. I checked with a manifest built the same way `page.tsx` builds it: the result was code `unknown-prop`, message "DETAIL-SECTION has no prop hideEmpty" (the tag name is spelled out to get past the body sanitizer). **Held, with one correction to the mechanism.** The validator does not drop anything. It only warns at authoring time. The drop happens in the fold: `DetailSectionNode` sends an undeclared key to `hostProps` and not to `section`, and `DetailSection` ignores that prop. So `hideEmpty: true` on an all-empty section still drew its heading and skeleton (base: the pin's `true` row is red with "expected span to be null"). 3. Readers that enumerate node inputs need a new row, as PR objectui#10484 did for `icon`. **Falsified: nothing to update, and PR objectui#10484 did not update them either.** Each of the four console readers (`registry-inputs-spec-parity`, `ga-honoured-inputs-author-reach`, `public-contract`, `component-input-union-specimens`) has 0 hits for `detail-section`, and they stay green. `check:component-surface-parity` is report-only and has no pin file. `check:sdui-registration-pins` pins registration KEYS derived from the `sideEffects` array. The script never reads `inputs`, and this diff adds no registration. The in-package readers stay green without changes: the #8626 fold-parity row (sorted, both directions), the #9529 pin and the #6955 pin. 4. Regeneration: none of `sdui.manifest.json`, `sdui-intrinsics.d.ts` or `sdui-blocks.md` is checked in. `git ls-files` finds none of them; as a control, the same listing does find `gen-manifest.ts`. `detail-section` is also not in `public-blocks.ts` (0 hits), while `record:details` is (1 hit). **Nothing to regenerate.** ## The pin: `detailSectionAuthoredHideEmpty-10485.test.tsx` | Row | Base `0d5d66aee` | Head `d0f0649d8` | |---|---|---| | no diagnostic for an authored boolean `hideEmpty`, in either polarity | red: `unknown-prop` | green | | judges `hideEmpty` as a declared BOOLEAN input (`'yes'` draws `type-mismatch` naming `hideEmpty`) | red: `unknown-prop` | green | | CONTROL: `name` still draws `unknown-prop` | green | green | | `hideEmpty: true` hides an all-empty section. A sibling node, identical except that it has no key, renders its heading and placeholder in the same render | red | green | | `hideEmpty: false` keeps the heading and the label skeleton | green | green | | an omitted `hideEmpty` keeps the all-empty section (the declared default) | green | green | Base: `Tests 3 failed | 3 passed (6)`. Head: `Tests 6 passed (6)`. The last two rows are green on both sides on purpose. They pin the default this PR declares, so a later change of that default shows up as a red row and not as silence. ## Ablation: three legs, each proven on disk and restored by blob Each mutation went through `node ../objectstack/scripts/ablation-replace.mjs --delete`. The anchor had to hit exactly once, the blob had to change, and the restore is `git checkout HEAD --` on an absolute path, verified by blob equality with HEAD and an empty `git diff HEAD`. The wrapper has its own `trap ... EXIT INT TERM`, which repeats that verification for both files. All legs ran on the committed implementation `d0f0649d8`. There is no build between mutation and assertion: the pin imports `../index` relatively, and the root `vitest.config.mts` aliases `@object-ui/core`, `@object-ui/react` and `@object-ui/sdui-parser` to their `src`. Expected directions were written down before each run, and every leg matched them. | Leg | Mutation | Landed | Red | Stayed green | Restored | |---|---|---|---|---|---| | A | delete the `hideEmpty` entry from the registration's `inputs` | anchor 1 to 0, blob `bd23f817f` to `04615591b` | both validate rows, #8626 fold parity | the `name` control, all three render rows, the #9529 pin | blob `bd23f817f` equals HEAD, diff empty | | B | delete `'hideEmpty'` from `DETAIL_SECTION_NODE_INPUTS` | anchor 1 to 0, blob `d41d873c3` to `637368d62` | the `true` render row, #8626 fold parity | both validate rows, the control, the `false` and omitted rows | blob `d41d873c3` equals HEAD, diff empty | | C (the dispatch's "warned and dropped") | both deletions together | both as above | both validate rows, the `true` render row | the control, #8626 fold parity (the two lists agree again), the `false` and omitted rows | both blobs equal HEAD, diff empty | Legs A and C read `Tests 3 failed | 15 passed (18)`. Leg B read `2 failed | 16 passed (18)`. All three ran over the #10485, #8626 and #9529 pins. ## Verification, at the final head `d0f0649d8` | What | Command | Result | |---|---|---| | package + the four console readers | from the repo root: `pnpm exec vitest run packages/plugin-detail/` plus the four `apps/console/src/__tests__` readers | `Test Files 212 passed / 1 skipped (213)`, `Tests 2301 passed / 8 skipped (2309)`. The readers on their own, verbose: `Test Files 4 passed (4)`, `Tests 240 passed (240)`, each file named in the output | | dependency closure | `pnpm exec turbo run build --filter='@object-ui/plugin-detail^...' --concurrency=2` | `Tasks: 11 successful, 11 total` | | types | `pnpm --filter @object-ui/plugin-detail type-check` | exit 0; `tsc -p tsconfig.test.json --listFiles` lists the new pin (1 hit) | | control bytes | `pnpm check:control-bytes` | `OK`, exit 0 | | line citations | `pnpm check:new-line-citations` | `0 new citation(s), enforcement report-only -> exit 0` | | changeset | `node scripts/check-changeset-presence.mjs` | `3 source file(s) of 1 released package(s) changed, and this change declares 1 changeset(s)` | | no major | `node scripts/check-changeset-no-major.mjs` | `No changeset declares a major bump` | | changeset claims | `pnpm check:changeset-claims` | report-only, exit 0. It flags two pending changesets that name `packages/plugin-detail/src/index.tsx`: `9280-record-highlights-entry-icon-retired.md` (the `record:highlights` `fields` description) and `record-alert-cta-label-i18n-4998.md` (`record:alert`'s `action`). I read both paragraphs and both are still true, because this diff touches neither registration | | gates reading these files | `check:doc-types`, `check:registry-bare-names`, `check:icon-record-names`, `check:test-path-roots`, `check:pending-changeset-literals`, `check:vi-mock-specifiers`, `check:handler-key-reads`, `check:governed-queue-guard` | all exit 0. `--test` on the four paths answers NOT GOVERNED | | component-surface parity (report-only) | `pnpm check:component-surface-parity` | exit 0. It lists `detail-section.hideEmpty` as `input-outside-keyset`, next to eight pre-existing `detail-section` inputs including `icon`, for the same reason as those: the gate sees no named read through `DetailSectionNode`, which folds by iterating the list | | NOT MEASURED | `pnpm check:sdui-registration-pins` | exit 2, `No console build to weigh` (it needs a full console build). Left to CI. It reads registration keys, not `inputs` | **Lint is narrowed, and I am declaring the narrowing.** `eslint --no-inline-config --format json` over the three touched source files: 3 files linted, 0 errors. Per-rule counts on the two edited files are equal to base (base read with `git show 0d5d66a:PATH` piped to `eslint --stdin --stdin-filename PATH`): `DetailSectionNode.tsx` has 1 `react-refresh/only-export-components`; `index.tsx` has 29 `react-refresh/only-export-components` and 2 `@typescript-eslint/no-explicit-any`. The new pin has 0 messages. The narrowing counts as a measurement because type-aware linting is not enabled: `eslint --print-config` on `index.tsx` resolves an empty `parserOptions` (no `project` / `projectService`), so this diff cannot change the verdict on any file outside it. The repo-wide run belongs to CI. ## Acceptance notes - **The node's omitted default is stated, not reopened.** The #8603 design gave the node no default because it was "a section nobody could write the key on". Now the key can be written on the node in both polarities. Whether the node should therefore adopt `record:details`' hide-by-default is a product question. This PR does not take it up: the card forbids a behaviour change, and the spec's `describe()` is scoped to `record:details`. The node matches `detail-view`, the other non-spec surface. - **Four texts outside this claim's file surface now describe the node as unable to write the key.** Their conclusions still hold: the node keeps its skeleton when the key is omitted, and `DetailSection` checks `=== true`. Only their premise is out of date. I did not edit them, because they are outside the claimed surface. Each one is quoted by content: - `DetailSection.tsx`, the comment above `hideAllEmptySection`: "the `detail-section` node both synthesize one, and neither surface declares `hideEmpty`". Fix: drop the node from that sentence, or say it declares the key without a default. - `renderers/record-details.tsx`, the comment on `hideEmpty: s.hideEmpty ?? true`: "Sections nobody can write it on stay out: the direct-`fields` fallback body below and the `detail-section` node". Same fix. - `renderers/__tests__/record-details.emptySectionDefault.test.tsx`, the file header: "a section nobody could have written the key on — the direct-`fields` fallback body, the `detail-section` node". Same fix. - `packages/types/src/views.ts`, the `DetailViewSection.hideEmpty` docblock: "This type is consumed by two authorable renderers". The node is now a third one, and it behaves like `detail-view` (omitted keeps). This is an incomplete list, not a wrong statement about behaviour, and it ships in the `.d.ts`. - **A pending changeset states an old count.** `.changeset/8626-detail-section-authored-node.md` is unreleased and says in present tense that the registration "declares eight FLAT inputs". PR objectui#10484 already noted this when `icon` made the count nine, and it is ten now. I did not edit it, because it is outside the claimed surface. - `packages/plugin-detail/README.md` and `content/docs` document no input list for `detail-section`, so no doc line became false. I added none, to stay inside the claimed surface. Dispatched by the `domain:ui` seat #1; session `https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC`. --- _Generated by [Claude Code](https://claude.ai/code/session_01BA3nKVUwKQJf8DBxrSVtNC)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 1434bb4 commit 9cbe4db

8 files changed

Lines changed: 263 additions & 17 deletions
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
---
2+
'@object-ui/plugin-detail': minor
3+
---
4+
5+
An authored `detail-section` node now takes `hideEmpty`, and it reaches the section.
6+
7+
`DetailSection` hides a section whose fields are all empty when
8+
`section.hideEmpty` is `true`, but the `detail-section` registration did not
9+
declare `hideEmpty` among its `inputs`, and the node adapter folds only declared
10+
names into the section it hands the component. So an author who wrote
11+
`hideEmpty` on the node was warned `unknown-prop "hideEmpty"` by the JSX-page
12+
compiler's validator — which judges an authored page against these same
13+
`inputs` — and an all-empty section still drew its heading and skeleton.
14+
15+
The registration now declares `{ name: 'hideEmpty', type: 'boolean' }`, and the
16+
adapter folds it like the other declared inputs. On this node, `true` hides an
17+
all-empty section (no heading, no skeleton); omitted or `false`, the section
18+
keeps its heading and label skeleton — the behaviour an unauthored node already
19+
had, now stated in the input's description rather than changed. A non-boolean
20+
`hideEmpty` now draws `type-mismatch` instead of `unknown-prop`.
21+
22+
The omitted default is the node's own. `record:details` still resolves an
23+
omitted `hideEmpty` to `true` on its own authored sections, which never pass
24+
through this node; `detail-view` sections, like this node, apply no default.
25+
What `true` and `false` mean is the same on all three.
26+
27+
**Clause-②: yes** — the authoring surface of `detail-section` widens by one
28+
key, `hideEmpty`, which the renderer already honoured. No other key's verdict
29+
moves: `name`, which the block neither declares nor reads, still draws
30+
`unknown-prop`. No exported symbol is added, removed, renamed or retyped — the
31+
package entry re-exports neither `DetailSectionNode` nor
32+
`DETAIL_SECTION_NODE_INPUTS`. The tag is outside the public block tier, so the
33+
generated `sdui.manifest.json` and `sdui-intrinsics.d.ts` never carried it and
34+
do not change.

‎.changeset/8626-detail-section-authored-node.md‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,11 @@ invalidates none of them.
3838

3939
`DetailSection` itself is byte-identical: every in-repo caller passes
4040
`section={…}` as a direct JSX child and is untouched.
41+
42+
Superseded in this release by objectui#9529 and objectui#10485: the
43+
registration now declares ten flat inputs, not eight — the eight above plus
44+
`icon` (objectui#9529) and then `hideEmpty` (objectui#10485) — and the adapter
45+
folds all ten. The count of eight above describes the tree this entry was
46+
written against; the live list is `DETAIL_SECTION_NODE_INPUTS`, held against
47+
the registration in both directions by the fold-parity row of
48+
`detailSectionAuthoredNode-8626.test.tsx`.

‎packages/plugin-detail/src/DetailSection.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -270,11 +270,13 @@ export const DetailSection: React.FC<DetailSectionProps> = ({
270270
// resolves it (`hideEmpty: s.hideEmpty ?? true` on the authored section),
271271
// because "the renderer default" in that describe() is the default of the
272272
// renderer the key is DECLARED on. This component also receives sections
273-
// nobody could write the key on — the `record:details` direct-`fields`
274-
// fallback body and the `detail-section` node both synthesize one, and
275-
// neither surface declares `hideEmpty` — and hiding those would be a hide
276-
// with no declarable spelling to ask the skeleton back, which is the exact
277-
// defect upstream declared the key to fix. A truthiness test
273+
// that default must not reach. The `record:details` direct-`fields`
274+
// fallback body synthesizes one nobody could write the key on, and hiding
275+
// it would be a hide with no declarable spelling to ask the skeleton back,
276+
// which is the exact defect upstream declared the key to fix. The
277+
// `detail-section` node declares `hideEmpty` (objectui#10485) but resolves
278+
// no default of its own, so an omitted key keeps the skeleton there too and
279+
// only an authored `true` hides. A truthiness test
278280
// (`!section.hideEmpty`) is banned for the mirror-image reason: it is what
279281
// made an authored `false` indistinguishable from unauthored before #7129.
280282
//

‎packages/plugin-detail/src/DetailSectionNode.tsx‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -83,7 +83,7 @@ import { DetailSection, type DetailSectionProps } from './DetailSection';
8383
* is a name that stops reaching `DetailSection` — which is how the ablation
8484
* for objectui#8626 reddens a per-input row.
8585
* `detailSectionAuthoredNode-8626.test.tsx` additionally pins the list against
86-
* the registration's own declared input names in BOTH directions, so a ninth
86+
* the registration's own declared input names in BOTH directions, so a new
8787
* input declared without a fold — the shape of the original defect — reds
8888
* rather than arriving silently inert.
8989
*/
@@ -100,6 +100,10 @@ export const DETAIL_SECTION_NODE_INPUTS = [
100100
'columns',
101101
'showBorder',
102102
'headerColor',
103+
// objectui#10485: `DetailSection` reads `section.hideEmpty === true` (the
104+
// all-empty hide), so it is declared and folded like the rest. Nothing here
105+
// defaults it: an omitted key stays omitted, and keeps the section.
106+
'hideEmpty',
103107
] as const;
104108

105109
type DetailSectionNodeInput = (typeof DETAIL_SECTION_NODE_INPUTS)[number];
Lines changed: 173 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,173 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* objectui#10485 — an authored `hideEmpty` on a `detail-section` node
11+
* VALIDATES and REACHES `DetailSection`.
12+
*
13+
* ## The gap
14+
*
15+
* `DetailSection` reads `section.hideEmpty === true` (the all-empty hide that
16+
* objectui#8603 restored), but the `detail-section` registration did not
17+
* declare `hideEmpty` among its `inputs`, and `DetailSectionNode` folds only
18+
* the declared names into the `section` it hands the component. So an author
19+
* who wrote `hideEmpty` on the node met both halves of one gap — the same two
20+
* halves objectui#9529 closed for `icon`:
21+
*
22+
* - the JSX-page compiler's validator (a manifest built from
23+
* `getKnownTypes()` plus these `inputs`, the way
24+
* `packages/components/src/renderers/layout/page.tsx` builds it) warned
25+
* `unknown-prop "hideEmpty"`, steering the author away from a key the
26+
* renderer honours; and
27+
* - the fold passed `hideEmpty` on as a host prop, which `DetailSection`
28+
* ignores, so an all-empty section still drew its heading and skeleton.
29+
*
30+
* The triage on the card reuses objectui#9529's ruling (declare a key the
31+
* renderer already honours) and asks for the node's OMITTED default to be
32+
* stated. On this node it is one value: nothing on the node's path resolves a
33+
* default (`record:details` resolves `?? true` on its OWN authored sections,
34+
* which never pass through this node), and `DetailSection` tests `=== true`,
35+
* so an omitted key keeps the all-empty section. The declaration states that;
36+
* it does not change it.
37+
*
38+
* ## What each row reads, and why it is a verdict
39+
*
40+
* - VALIDATES — the node with an authored `hideEmpty` of either polarity draws
41+
* ZERO diagnostics from the live manifest. The declared TYPE is read through
42+
* the same judge: a non-boolean `hideEmpty` draws `type-mismatch` naming the
43+
* key, which only a declared `boolean` input can produce (an undeclared key
44+
* draws `unknown-prop` instead).
45+
* - THE CONTROL IS LIT — `name`, a `DetailViewSection` member this block
46+
* neither declares nor reads, still draws `unknown-prop` from the same
47+
* manifest in the same run. A silenced validator would pass the rows above
48+
* vacuously; it cannot pass this one.
49+
* - REACHES `DetailSection` — measured in the DOM through the real
50+
* `SchemaRenderer` and the real registry, over a record on which every field
51+
* of the section is empty. `true` hides the section: no heading, no
52+
* skeleton. A sibling node in the SAME render, identical but for the key,
53+
* draws its heading and its placeholder, so the absence is a decision about
54+
* `hideEmpty` and not an artefact of the fixture. `false` and an omitted key
55+
* both keep the heading and the placeholder — the node's declared default.
56+
*/
57+
58+
import React from 'react';
59+
import { describe, it, expect, afterEach } from 'vitest';
60+
import { render, screen, cleanup } from '@testing-library/react';
61+
import { ComponentRegistry } from '@object-ui/core';
62+
import { SchemaRenderer } from '@object-ui/react';
63+
import { manifestFromConfigs, validateTree } from '@object-ui/sdui-parser';
64+
65+
// Module scope, not a hook (the test-discipline section of AGENTS.md):
66+
// importing the package index executes its registration side-effects, so the
67+
// entry under test is the very one production resolves.
68+
import '../index';
69+
70+
const TAG = 'detail-section';
71+
const TITLE = 'Billing Address';
72+
const CONTROL_TITLE = 'Shipping Address';
73+
74+
/** A minimal authored node: the one required input plus a title to anchor on. */
75+
const authoredNode = (overrides: Record<string, unknown> = {}, title = TITLE) =>
76+
({
77+
type: TAG,
78+
title,
79+
fields: [{ name: 'street', label: 'Street' }],
80+
...overrides,
81+
}) as never;
82+
83+
/** A record on which every field of the section above is empty. */
84+
const EMPTY_RECORD = {};
85+
86+
/** Built the way `page.tsx` builds the JSX-page compiler's manifest. */
87+
const liveManifest = () =>
88+
manifestFromConfigs(
89+
ComponentRegistry.getKnownTypes().map((type) => {
90+
const meta = ComponentRegistry.getMeta(type);
91+
return {
92+
type,
93+
namespace: meta?.namespace,
94+
isContainer: meta?.isContainer,
95+
inputs: meta?.inputs,
96+
};
97+
}) as unknown as Parameters<typeof manifestFromConfigs>[0],
98+
);
99+
100+
const diagnose = (overrides: Record<string, unknown>) =>
101+
validateTree(authoredNode(overrides), liveManifest()).diagnostics.map((d) => ({
102+
code: d.code,
103+
message: d.message,
104+
}));
105+
106+
/** The empty-value placeholder `DetailSection` draws for a field with no value. */
107+
const placeholdersIn = (heading: HTMLElement | null) => {
108+
const card = heading?.closest('.bg-card') ?? null;
109+
return card ? card.querySelectorAll('[title="No value"]').length : 0;
110+
};
111+
112+
/**
113+
* Renders the node under test beside a CONTROL node that is identical except
114+
* for its title and for carrying no `hideEmpty`, over the same all-empty record.
115+
*/
116+
const renderBesideControl = (overrides: Record<string, unknown>) =>
117+
render(
118+
<>
119+
<SchemaRenderer schema={authoredNode(overrides)} data={EMPTY_RECORD} />
120+
<SchemaRenderer schema={authoredNode({}, CONTROL_TITLE)} data={EMPTY_RECORD} />
121+
</>,
122+
);
123+
124+
afterEach(() => {
125+
cleanup();
126+
});
127+
128+
describe('objectui#10485 — an authored detail-section hideEmpty validates', () => {
129+
it('draws no diagnostic for an authored boolean hideEmpty, in either polarity', () => {
130+
expect(diagnose({ hideEmpty: true })).toEqual([]);
131+
expect(diagnose({ hideEmpty: false })).toEqual([]);
132+
});
133+
134+
it('judges the authored hideEmpty as a declared BOOLEAN input', () => {
135+
const found = diagnose({ hideEmpty: 'yes' });
136+
expect(found.map((d) => d.code)).toEqual(['type-mismatch']);
137+
expect(found[0].message).toContain('"hideEmpty"');
138+
});
139+
140+
it('CONTROL: still warns on name, which the block neither declares nor reads', () => {
141+
const found = diagnose({ name: 'billing' });
142+
expect(found.map((d) => d.code)).toEqual(['unknown-prop']);
143+
expect(found[0].message).toContain('"name"');
144+
});
145+
});
146+
147+
describe('objectui#10485 — an authored detail-section hideEmpty reaches DetailSection', () => {
148+
it('`hideEmpty: true` hides an all-empty section: no heading, no skeleton', () => {
149+
renderBesideControl({ hideEmpty: true });
150+
151+
// The lit control: the same all-empty section, without the key, in the
152+
// same render, draws its heading and its placeholder.
153+
const control = screen.getByText(CONTROL_TITLE);
154+
expect(placeholdersIn(control)).toBe(1);
155+
156+
expect(screen.queryByText(TITLE)).toBeNull();
157+
expect(screen.getAllByTitle('No value')).toHaveLength(1);
158+
});
159+
160+
it('`hideEmpty: false` keeps the heading and the label skeleton of an all-empty section', () => {
161+
renderBesideControl({ hideEmpty: false });
162+
163+
expect(placeholdersIn(screen.getByText(TITLE))).toBe(1);
164+
expect(placeholdersIn(screen.getByText(CONTROL_TITLE))).toBe(1);
165+
});
166+
167+
it('an OMITTED hideEmpty keeps the all-empty section too: the node declares no hide by default', () => {
168+
render(<SchemaRenderer schema={authoredNode()} data={EMPTY_RECORD} />);
169+
170+
expect(screen.getByText('Street')).toBeTruthy();
171+
expect(placeholdersIn(screen.getByText(TITLE))).toBe(1);
172+
});
173+
});

‎packages/plugin-detail/src/index.tsx‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,6 +380,26 @@ ComponentRegistry.register('detail-section', DetailSectionNode, {
380380
description:
381381
'Header tint, from the design system\'s closed palette: one of `muted`, `muted/50`, `accent`, `primary/10`, `secondary/10`, `destructive/10`. Any other value is refused — `@object-ui/types` parses this key as that same six-member enum (objectui#6594), matching @objectstack/spec\'s strict `record:details` section schema. Omit the key for no tint.',
382382
},
383+
{
384+
/**
385+
* objectui#10485 — declared because `DetailSection` already honours it
386+
* (`section.hideEmpty === true`), the objectui#9529 ruling applied to a
387+
* second key on this node.
388+
*
389+
* The description states THIS node's omitted default, and it is one
390+
* value: nothing on the node's path resolves a default, and
391+
* `DetailSection` tests `=== true`, so an omitted key keeps the
392+
* all-empty section. That matches `detail-view`, which hands its
393+
* sections on unchanged; `record:details` resolves `?? true` on its OWN
394+
* authored sections, which never pass through this node. `true` and
395+
* `false` mean the same on all three. No `defaultValue`: no other input
396+
* of this node carries one.
397+
*/
398+
name: 'hideEmpty',
399+
type: 'boolean',
400+
description:
401+
'When every field in the section is empty, `true` hides the whole section (no heading, no skeleton). Omitted or `false`, an all-empty section keeps its heading and label skeleton. Empty fields in a section that still has a filled one are not governed by this key.',
402+
},
383403
],
384404
});
385405

‎packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx‎

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -52,11 +52,13 @@
5252
* section whose fields are ALL empty renders nothing unless the page writes
5353
* `false`. WHERE that default is resolved is the design, and the last describe
5454
* block below is what pins it: `RecordDetailsRenderer` applies `?? true` to an
55-
* authored section and `DetailSection` tests `=== true`, so a section nobody
56-
* could have written the key on — the direct-`fields` fallback body, the
57-
* `detail-section` node — keeps its skeleton. A hide there would be a hide
58-
* with no declarable spelling to ask the skeleton back, which is the defect
59-
* upstream declared the key to fix.
55+
* authored section and `DetailSection` tests `=== true`, so a section this
56+
* renderer did not resolve keeps its skeleton. The direct-`fields` fallback
57+
* body is one nobody could have written the key on; a hide there would be a
58+
* hide with no declarable spelling to ask the skeleton back, which is the
59+
* defect upstream declared the key to fix. The `detail-section` node declares
60+
* the key (objectui#10485) but resolves no default of its own, so an omitted
61+
* key keeps the skeleton there too and only an authored `true` hides.
6062
*
6163
* Deliberately no i18n provider, so the row labels below are rung 2 of the
6264
* label ladder: the object's own DECLARED `label`. They read as field NAMES

‎packages/plugin-detail/src/renderers/record-details.tsx‎

Lines changed: 9 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -726,12 +726,15 @@ export const RecordDetailsRenderer: React.FC<RecordDetailsRendererProps> = ({
726726
//
727727
// ⭐ The default is resolved HERE, on an AUTHORED section, and that
728728
// placement is the whole design. `DetailSection` tests `=== true`, so
729-
// the default reaches exactly the surface that declares the key.
730-
// Sections nobody can write it on stay out: the direct-`fields`
731-
// fallback body below and the `detail-section` node each synthesize a
732-
// section, and a hide there would be one with no declarable spelling
733-
// to ask the skeleton back — the defect upstream declared this key to
734-
// fix, reintroduced one surface over.
729+
// the default reaches exactly the authored sections this renderer
730+
// resolves it on. Every other section stays out. The direct-`fields`
731+
// fallback body below synthesizes a section nobody can write the key
732+
// on, and a hide there would be one with no declarable spelling to
733+
// ask the skeleton back — the defect upstream declared this key to
734+
// fix, reintroduced one surface over. The `detail-section` node
735+
// declares the key (objectui#10485) but applies no default of its
736+
// own, so an omitted one keeps the skeleton there too; only an
737+
// authored `true` hides.
735738
//
736739
// ⚠️ `?? true` is the spelling objectui#7064 removed, and it is back
737740
// deliberately. ⛔ Read that ruling's ground as it was written, not as

0 commit comments

Comments
 (0)