From e6a7cecbcf03de1b2f7e7e6f65237c5f6cad0e77 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 14:24:23 +0000 Subject: [PATCH 1/4] =?UTF-8?q?feat(plugin-detail,types):=20`record:detail?= =?UTF-8?q?s`=20reads=20`hideEmpty`=20again=20=E2=80=94=20the=20protocol's?= =?UTF-8?q?=20all-empty=20section=20contract?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `@objectstack/spec` declares `hideEmpty` on `RecordDetailsProps.sections[]` and its `describe()` promises: hiding is the renderer default, a section whose fields are ALL empty then renders nothing at all — no heading, no skeleton — and `false` keeps the heading and the label skeleton on an all-empty record. This renderer read nothing, so the contract itself misled the author: the key parsed green at publish and did nothing. The retirement that removed the read rested on the premise that the spec REFUSED the key, which was true at the 17.2.0 pin this repo held and already false upstream. The director seat ruled the protocol correct and restored the read; that ruling supersedes the retirement's first clause for this key only. - `DetailSection` owns the ALL-EMPTY decision: `section.hideEmpty !== false`, read as an explicit polarity test so a written `false` is distinguishable from unauthored — the defect the pre-retirement `!section.hideEmpty` carried. Gated on `!isEditing`, so inline-edit mode never puts a section's fields out of reach; the contract governs what a reader sees. - The auto-hide heuristic keeps the empty ROWS of a partly-filled section, with no authored override in either polarity. The two domains are disjoint by construction: the heuristic requires a filled row, this key requires none. - `RecordDetailsRenderer` restores the explicit slot, deliberately undefaulted — `?? true` there would erase the distinction before the one read that resolves it. - `@object-ui/types` declares the key again and the `DetailViewSectionSchema` zod mirror carries it, so the parity ledger neither grows nor gains an entry. - The manifest's `sections` description teaches the key instead of warning that it does nothing, and it leaves the never-teach set, which the spec-derived filter had already stopped selecting it for. Both pins move by the ruling and in both directions: the four-party alignment pin now reads the key end to end with a sibling control section that must render, so "nothing rendered" cannot pass by the tree having rendered nothing at all, and the empty-section default pin states the two domains separately. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VCpmqvacV4BypY48QdoxcE --- ...8603-record-details-hide-empty-restored.md | 16 ++ packages/plugin-detail/src/DetailSection.tsx | 58 +++-- .../recordDetailsInputs.spec-parity.test.ts | 43 +++- packages/plugin-detail/src/index.tsx | 51 ++-- ...ecord-details.emptySectionDefault.test.tsx | 212 ++++++++-------- ...ord-details.hideEmptyRetired-7129.test.tsx | 234 ++++++++++-------- .../src/renderers/record-details.tsx | 54 ++-- packages/types/src/views.ts | 57 +++-- packages/types/src/zod/views.zod.ts | 10 + 9 files changed, 440 insertions(+), 295 deletions(-) create mode 100644 .changeset/8603-record-details-hide-empty-restored.md diff --git a/.changeset/8603-record-details-hide-empty-restored.md b/.changeset/8603-record-details-hide-empty-restored.md new file mode 100644 index 0000000000..385ad1fe18 --- /dev/null +++ b/.changeset/8603-record-details-hide-empty-restored.md @@ -0,0 +1,16 @@ +--- +'@object-ui/plugin-detail': minor +'@object-ui/types': minor +--- + +`record:details` honours `hideEmpty` on a section again — an all-empty section hides itself, `hideEmpty: false` keeps its heading and skeleton + +**User-visible.** A `record:details` section whose fields are ALL empty now renders nothing at all — no heading, no skeleton — unless the page writes `hideEmpty: false` on it, which keeps the heading and the label skeleton a brand-new record needs. That is the behaviour `@objectstack/spec` declares on `RecordDetailsProps.sections[]` and describes in the key's own `describe()` text, and this renderer had stopped delivering it. + +⚠️ **The unauthored default moved.** Until this change an all-empty section always rendered its skeleton. It now hides by default, because the spec states the renderer default as on and objectui#8603 ruled the protocol correct. Pages that want the old rendering write `hideEmpty: false` — a spelling that parses green on the strict section object since spec 17.3.0, which is precisely what it could not do when the read was retired. + +**What did NOT change.** The empty ROWS of a section that still has a filled row stay with `DetailSection`'s auto-hide heuristic and the reader's "Show N empty fields" toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C, left standing by the ruling). Inline-edit mode renders an all-empty section either way, so its fields never become unreachable. `record:reference_rail`'s own component-level `hideEmpty` and the `detail.hideEmptyFields` toggle label are different keys and are untouched. + +**Why it moved twice.** objectui#7129 (maintainer 2026-09-01) retired the declaration and the read because `@objectstack/spec` REFUSED the key — true at the 17.2.0 pin this repo held. Upstream had already declared it (17.3.0, upstream #11289, maintainer ruling 2026-08-23 direction 1, taken from a measured symptom), so once the pin moved the contract promised an author a behaviour the renderer no longer had, and it parsed green at publish. objectui#8603 (director seat batch #137 item 3, maintainer 2026-09-15) ruled the protocol correct and restored the read; #7129's Q1-A is superseded for this key only. + +All four parties now agree — the spec declares it, `@object-ui/types` declares it, the `DetailViewSectionSchema` zod mirror carries it, and the renderer reads it — pinned together in `packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx`. diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 499f9078fa..1946b7d68c 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -230,20 +230,18 @@ export const DetailSection: React.FC = ({ [section.fields, isEmptyValue] ); - // Auto-hide-empty heuristic — the WHOLE contract for section emptiness - // (objectui#7129, maintainer 2026-09-01). When a section has empty rows AND + // Auto-hide-empty heuristic — the whole contract for the empty ROWS of a + // section that still has a filled one (objectui#7129 Q2-C, maintainer + // 2026-09-01, untouched by objectui#8603). When a section has empty rows AND // at least one filled row, hide the empties so the page does not become a - // label-graveyard. The user can still reveal them with the toggle. 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. + // label-graveyard. The user can still reveal them with the toggle. An + // authored `hideEmpty` does NOT override this branch in either polarity — + // it never could: the read was `!section.hideEmpty`, so a written `false` + // was indistinguishable from unauthored, which is the paradox Q2-C settled. // - // ⛔ There is deliberately no authored override. `DetailViewSection` used to - // declare `hideEmpty`, and this heuristic tested `!section.hideEmpty` — so - // an authored `false` was indistinguishable from unauthored and overrode - // nothing, while `@objectstack/spec` refused the key outright on a - // spec-validated page. The declaration is retired; do not reintroduce a read - // of it here (see `packages/types/src/views.ts` for the full four-party - // measurement). + // The ALL-EMPTY case is the other domain, and it is `hideEmpty`'s — see + // `allFieldsEmpty` below. The two are disjoint by construction: this + // heuristic requires `filledCount > 0`, that one requires `filledCount === 0`. // // Thresholds were tightened in Phase N (2026-05): smaller sections (≥4 // fields) and a lower empty ratio (≥25%) now trigger auto-hide so pages @@ -258,9 +256,36 @@ export const DetailSection: React.FC = ({ section.fields.length >= AUTO_HIDE_MIN_FIELDS && emptyCount / section.fields.length >= AUTO_HIDE_RATIO && filledCount > 0; - const hideEmptyEffective = !showEmptyOverride && shouldAutoHideEmpty; - // Filter out empty fields when the auto-hide heuristic kicked in. + // The authored `record:details` section key, restored under objectui#8603 + // (director seat batch #137 item 3, maintainer 2026-09-15) after + // objectui#7129 retired it on the premise that `@objectstack/spec` refused + // the key — a premise the 17.3.0 pin move made false. The spec declares it + // on `RecordDetailsProps.sections[]` and its describe() promises exactly + // this: hiding is the renderer default, an all-empty section then renders + // nothing at all (no heading, no skeleton), and `false` keeps the heading + // and the label skeleton on an all-empty record. + // + // ⚠️ Read as `!== false`, NOT as a truthiness test. `!section.hideEmpty` + // is what made an authored `false` indistinguishable from unauthored under + // the pre-#7129 code, and an override nobody can write is the defect this + // restoration must not reintroduce. + // + // ⚠️ `!isEditing` is this renderer's boundary on the contract, and it is + // deliberate: inline-edit mode is the surface where those empty rows are the + // INPUTS. Hiding an all-empty section there would put its fields out of the + // author's and the user's reach entirely, which no describe() asks for and + // which would be a new defect rather than a restored behaviour. The reading + // the contract governs is what a READER sees, and that is what this leaves + // unchanged. + const allFieldsEmpty = section.fields.length > 0 && filledCount === 0; + const hideAllEmptySection = section.hideEmpty !== false && allFieldsEmpty && !isEditing; + + const hideEmptyEffective = + !showEmptyOverride && (shouldAutoHideEmpty || hideAllEmptySection); + + // Filter out empty fields when the auto-hide heuristic kicked in, or when an + // all-empty section is hiding itself (its early return is below every hook). const visibleFields = hideEmptyEffective ? section.fields.filter((field) => !isEmptyValue(field)) : section.fields; @@ -578,7 +603,10 @@ export const DetailSection: React.FC = ({ }, [vsEnabled, layoutFields.length, vsBatchSize]); // Hide entire section when all fields are empty AND the user has not asked to - // reveal them. This early return MUST come AFTER every hook above (including + // reveal them. Who decides this is the authored `hideEmpty` (`hideEmptyEffective` + // above, default on, `false` to keep the skeleton); this line is the mechanism + // it reaches, which is why restoring the read there needed no new exit here. + // This early return MUST come AFTER every hook above (including // the virtual-scroll useEffect) — never before. When a section is all-empty // on one render (early return, N hooks) but has data on the next render (the // useEffect runs, N+1 hooks) of the SAME reconciled fiber, the hook count diff --git a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts index 98e64c0fb8..a8959a7ff6 100644 --- a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts @@ -112,20 +112,31 @@ const specSectionKeys = (): string[] => * `renderers/record-details.tsx`: `s.showBorder` is honoured there beyond the * spec's four; `title` was honoured as a strict-priority ALIAS of the heading * slot (`s.title ?? s.label`) until objectui#6190 converged on the declared - * `label` and dropped the limb; `hideEmpty` was honoured until objectui#7129 - * RETIRED the key (maintainer 2026-09-01) and left `DetailSection`'s auto-hide - * heuristic as the whole contract. + * `label` and dropped the limb. * - * `title` and `hideEmpty` stay in this list on purpose, and dropping either - * would weaken the file. Membership is not "keys the renderer reads today" — - * it is "keys the spec refuses that the description must not advertise", and - * the spec refuses both whether or not anything reads them. A hand-kept list, - * but the ASSERTION + * `title` stays in this list on purpose, and dropping it would weaken the + * file. Membership is not "keys the renderer reads today" — it is "keys the + * spec refuses that the description must not advertise", and the spec refuses + * `title` whether or not anything reads it. A hand-kept list, but the + * ASSERTION * filters it through the spec at runtime, so the day upstream declares one of * these it drops out of the forbidden set on its own instead of pinning a stale * prohibition. + * + * ⚠️ `hideEmpty` was a member and is GONE from the list under objectui#8603 + * (director seat batch #137 item 3, maintainer 2026-09-15), which restored the + * renderer read after objectui#7129 retired it on the premise — falsified + * upstream by the 17.3.0 pin move — that the spec refused the key. The spec + * DECLARES it on the section entry, so it fails the membership criterion in + * its own words: it is not a key the spec refuses. The runtime filter above + * already dropped it from `stripped` before this edit, which is why removing + * it changes no verdict here — the list is what states the criterion, and + * leaving a declared key in it would state a prohibition the contract + * contradicts. The `sections` description now teaches `hideEmpty`, and the + * "every spec section member key is discoverable" case above is what requires + * that. */ -const RENDERER_ONLY_SECTION_KEYS = ['title', 'showBorder', 'hideEmpty']; +const RENDERER_ONLY_SECTION_KEYS = ['title', 'showBorder']; /** * Does the installed spec REFUSE an undeclared key inside a `sections[]` entry, @@ -239,10 +250,13 @@ describe('record:details — registry inputs vs @objectstack/spec', () => { }); it('publishes no section member key the spec refuses to carry', () => { - // The renderer honours `showBorder` per section (and once honoured `title` - // and `hideEmpty`), but the spec's section object does not declare any of - // the three, so an author who writes them gets nothing back from the - // contract — a refusal, in fact. Documenting them here + // The renderer honours `showBorder` per section (and once honoured + // `title`), but the spec's section object does not declare it, so an + // author who writes it gets nothing back from the contract — a refusal, in + // fact. The fixture below still carries `hideEmpty`, which the spec DOES + // declare since 17.3.0 (restored here by objectui#8603): it is the live + // control on the filter — a key that leaves `stripped` and must therefore + // NOT appear among the refused names. Documenting the refused ones here // would teach keys the contract does not carry — the member-level twin of // publishing a top-level input the props schema rejects. const stripped = RENDERER_ONLY_SECTION_KEYS.filter( @@ -267,6 +281,9 @@ describe('record:details — registry inputs vs @objectstack/spec', () => { expect(refused).toEqual(expect.arrayContaining(stripped)); expect(refused).not.toContain('label'); expect(refused).not.toContain('fields'); + // …and the key objectui#8603 restored is on the DECLARED side of that + // line: the same strict object that names `title` must not name it. + expect(refused).not.toContain('hideEmpty'); } else { // The pinned rc.6: dropped in silence, which is the harm this file was // filed over — success receipt, section renders without them. diff --git a/packages/plugin-detail/src/index.tsx b/packages/plugin-detail/src/index.tsx index a0d68747d1..50e6389c5f 100644 --- a/packages/plugin-detail/src/index.tsx +++ b/packages/plugin-detail/src/index.tsx @@ -479,31 +479,36 @@ ComponentRegistry.register('details', RecordDetailsRenderer, { // rejects on parse; the body-source contract is `sections`-presence, stated // in the `sections` description below. // - // Documented member keys are exactly the spec's four (`name`, `label`, - // `columns`, `fields`) — deliberately NOT `showBorder`, which - // `RecordDetailsRenderer` also honours on a section, nor `title`, which it - // honoured as an ALIAS of `label` until objectui#6190 converged the heading - // on the one declared slot, nor `hideEmpty`, which it honoured until - // objectui#7129 retired the key (maintainer 2026-09-01) and left the - // auto-hide heuristic as the whole contract. `title` and `hideEmpty` stay - // named here because the never-teach set is about what the description may - // say, not about what the renderer happens to read: the spec refuses them - // either way, whether or not anything still reads them. Those are - // undeclared upstream, and the spec's section object REFUSES them on parse - // rather than stripping them: `RecordDetailsProps.safeParse` on a section - // carrying any of the three returns `success: false` with - // `unrecognized_keys` naming the key (measured on the installed pin, 17.2.0, - // against a control — `columns: 2` — that parses and whose value survives). - // So publishing them here would advertise keys that make the whole document - // fail to validate, not keys the contract quietly throws away — the same - // trap as declaring a top-level `readonly` on `record:highlights` below. The - // renderer tolerating them is not a licence to teach them. (This said - // "STRIPS" until objectui#7127: that was the pre-#4001-batch-A behaviour the - // spec's own refusal message still recounts, and the `layout` paragraph - // above already said `rejects`.) + // The never-teach set is `title` — which `RecordDetailsRenderer` honoured as + // an ALIAS of `label` until objectui#6190 converged the heading on the one + // declared slot. It stays named here because this set is about what the + // description may SAY, not about what the renderer happens to read: the spec + // refuses `title` whether or not anything still reads it. Undeclared + // upstream, and the spec's section object REFUSES such a key on parse rather + // than stripping it — `RecordDetailsProps.safeParse` on a section carrying + // one returns `success: false` with `unrecognized_keys` naming it, against a + // control (`columns: 2`) that parses and whose value survives. So publishing + // it here would advertise a key that makes the whole document fail to + // validate, not one the contract quietly throws away — the same trap as + // declaring a top-level `readonly` on `record:highlights` below. The + // renderer tolerating it is not a licence to teach it. (This said "STRIPS" + // until objectui#7127: that was the pre-#4001-batch-A behaviour the spec's + // own refusal message still recounts, and the `layout` paragraph above + // already said `rejects`.) + // + // `hideEmpty` LEFT this set under objectui#8603 (director seat batch #137 + // item 3, maintainer 2026-09-15) and the `sections` description below now + // teaches it. It was named here while objectui#7129 held — the key was + // retired on the premise the spec refused it, which the 17.3.0 pin move made + // false. The spec declares it on the section entry and this renderer reads + // it again, so the membership criterion no longer selects it; the set is + // derived against the installed spec at runtime by + // `__tests__/recordDetailsInputs.spec-parity.test.ts`, which is what keeps a + // stale prohibition from pinning itself. ⚠️ Still NOT this key: + // `record:reference_rail`'s own component-level `hideEmpty` input below. inputs: [ { name: 'columns', type: 'enum', enum: ['1', '2', '3', '4'], description: 'Number of columns for field layout (1-4)' }, - { name: 'sections', type: 'array', of: 'object', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` are the field names shown in this section, in order — required unless `group` supplies the members instead (the spec refuses a section carrying neither, and refuses one carrying both). `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, seven of which this renderer honours: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette) through DetailSection, plus `group` — the ADR-0085 §5 REFERENCE form, the alternative to enumerating `fields`. `{ group: \'contact_info\' }` inherits the object\'s `fieldGroups` entry with that key: its members (every visible field pointing at it, in declaration order) and its presentation (label, icon, description, collapse) all come from the group, so the section restates none of it and the spec refuses those keys beside `group`; `columns`, `showBorder` and `headerColor` stay yours because they are how THIS page lays the section out. A `group` naming no declared group renders nothing and is reported to the console (`@objectstack/lint` flags it as `page-section-group-unknown`). Only `hideEmpty` is declared upstream and NOT read here, deliberately retired in objectui#7129 (maintainer 2026-09-01) in favour of DetailSection\'s auto-hide heuristic plus the reader\'s show-empty toggle — authoring it does nothing on this renderer. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature.' }, + { name: 'sections', type: 'array', of: 'object', description: 'Field groups rendered as the detail body, in order. Every entry is an OBJECT — `{ name?, label?, columns?, fields }` — a bare section-id string is NOT accepted (the spec retired that spelling in objectstack#5611, and the renderer reads name/label/fields off each entry, so a string entry renders no fields at all). `fields` are the field names shown in this section, in order — required unless `group` supplies the members instead (the spec refuses a section carrying neither, and refuses one carrying both). `label` is the section heading; omit it for an untitled, borderless section. `name` is a stable snake_case identifier and the i18n anchor — the heading resolves through objects.._sections..label, so a section without a name shows its authored label in every locale. `columns` (1-4) is THIS section\'s field-grid width; omit it and the renderer derives the width. Authoring `sections` at all makes it the only source of the detail body; omit it and the body falls back to the object\'s highlightFields. @objectstack/spec 17.3.0 declares eight more member keys on an entry, and this renderer honours all eight: `icon` (a Lucide name on the section header), `description` (sub-heading copy under the heading), `collapsible` and `defaultCollapsed` (a foldable section and its initial state), `showBorder` (force the Card wrapper on or off, overriding the heading-derived default) and `headerColor` (a header tint from the shared palette) through DetailSection, plus `group` — the ADR-0085 §5 REFERENCE form, the alternative to enumerating `fields`. `{ group: \'contact_info\' }` inherits the object\'s `fieldGroups` entry with that key: its members (every visible field pointing at it, in declaration order) and its presentation (label, icon, description, collapse) all come from the group, so the section restates none of it and the spec refuses those keys beside `group`; `columns`, `showBorder` and `headerColor` stay yours because they are how THIS page lays the section out. A `group` naming no declared group renders nothing and is reported to the console (`@objectstack/lint` flags it as `page-section-group-unknown`). `hideEmpty` is the eighth, and it decides whether an ALL-empty section exists: it defaults to on, so a section whose fields are every one of them empty renders nothing at all — no heading, no skeleton — and `hideEmpty: false` is the spelling that keeps that heading and its label skeleton on a brand-new record. It decides ONLY the all-empty case; the empty rows of a section that still has a filled one belong to DetailSection\'s auto-hide heuristic plus the reader\'s show-empty toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C), and inline-edit mode renders the section either way so its fields stay reachable. Restored under objectui#8603 (maintainer 2026-09-15) after objectui#7129 retired it on the premise, since falsified upstream, that the spec refused the key. None of the eight has a designer control yet; they are authorable in source mode only, tracked as a deferred feature.' }, { name: 'fields', type: 'array', of: 'string', description: 'Explicit field list (overrides highlightFields)' }, // `hideFields` is DECLARED, not merely honoured (objectui#3808). The spec // declares it (objectstack#5611) and `RecordDetailsRenderer` has read it diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx index 5608c874e5..c6c9f9ac4b 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx @@ -7,38 +7,52 @@ */ /** - * `record:details` — who owns the empty-section default (objectui#7064). + * `record:details` — who owns the empty-section default (objectui#7064, + * objectui#7129 Q2-C, objectui#8603). * - * `RecordDetailsRenderer` used to map every authored section with - * `hideEmpty: s.hideEmpty ?? true`. That forced default overrode the one case - * `DetailSection`'s own heuristic explicitly reserves: + * TWO DISJOINT DOMAINS, and every case below belongs to exactly one: + * + * - the EMPTY ROWS of a section that still has a filled row — owned by + * `DetailSection`'s auto-hide heuristic and the reader's "Show N empty + * fields" toggle, with no authored override in either polarity + * (objectui#7129 Q2-C, which objectui#8603 leaves untouched); + * - an ALL-EMPTY section — owned by the authored `hideEmpty`, hiding by + * renderer default, `false` keeping the heading and the label skeleton. + * + * The heuristic requires `filledCount > 0` and `hideEmpty` applies only where + * there is none, so no fixture can be governed by both. * - * "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." + * ## Why this file has been rewritten twice * - * With the force in place an all-empty section took `DetailSection`'s - * all-fields-hidden early return instead, so a hand-created record lost whole - * sections and collapsed to a two-row body, and every application had to - * hand-write `hideEmpty: false` per section to stop looking broken — per-app - * tax for a platform concern (maintainer ruling 2026-08-31). + * `RecordDetailsRenderer` used to map every authored section with + * `hideEmpty: s.hideEmpty ?? true`. That forced default made an unauthored + * section indistinguishable from an authored `true` at every later read, so + * every application had to hand-write `hideEmpty: false` per section to stop a + * hand-created record collapsing to a two-row body — per-app tax for a + * platform concern (maintainer ruling 2026-08-31, objectui#7064), and the + * force went. + * + * That pass-through then measured the key on all four of its contracts and + * found three answers (objectui#7129): `@objectstack/spec` 17.2.0 REFUSED + * `hideEmpty` on a `record:details` section, so on any spec-validated page the + * "author escape hatch" existed only where nothing validated. The maintainer + * converged the four on the spec's answer (2026-09-01): the declaration and + * the read were RETIRED. * - * The renderer then passed the authored value through untouched — and that - * pass-through measured the key on all four of its contracts, finding three - * answers (objectui#7129). `@objectstack/spec` REFUSES `hideEmpty` on a - * `record:details` section, so on any spec-validated page it never reached the - * renderer at all; the "author escape hatch" existed only where nothing - * validated. The maintainer converged the four on the spec's answer - * (2026-09-01): the declaration is RETIRED and `DetailSection`'s heuristic is - * the whole contract. + * `@objectstack/spec` 17.3.0 then DECLARED the key on that same section entry + * (upstream #11289, maintainer ruling 2026-08-23), with a `describe()` + * promising the behaviour this repo had just removed — so the retirement's + * premise was false before the pin carrying it moved. objectui#8603 (director + * seat batch #137 item 3, maintainer 2026-09-15) ruled the protocol correct + * and RESTORED the read: Q1-A of #7129 is superseded for this key, Q2-C is + * not. The escape hatch is real this time — `hideEmpty: false` parses green on + * the strict section object, which is what #7129 measured it could not do. * - * These pins hold both halves of that contract: - * - the default is the heuristic's, not the renderer's — unchanged, and the - * three cases below are exactly the ones #7064 landed; - * - an authored `hideEmpty` of EITHER polarity is now INERT. Its describe - * block is RESTATED, not deleted (ruling clause 4): the same fixtures and - * the same controls now assert the key does nothing, which is what proves - * the heuristic survived the retirement intact. + * ⚠️ The unauthored all-empty default therefore moved back: a section whose + * fields are ALL empty renders nothing unless the page writes `false`. What + * did NOT move back is the shape #7064 removed — the renderer still does not + * force a default at the mapping, so `undefined` and `true` stay + * distinguishable everywhere except at the one read that resolves them. * * Deliberately no i18n provider, so the row labels below are rung 2 of the * label ladder: the object's own DECLARED `label`. They read as field NAMES @@ -188,31 +202,13 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('record:details — the UNAUTHORED empty-section default is DetailSection\'s heuristic (#7064)', () => { - it('an ALL-empty section renders its skeleton: heading, every field label, an empty placeholder each', () => { - renderDetails({ - sections: [ - { name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] }, - ], - }); - - // The heading survives — the whole section used to disappear here. - expect(screen.getByText('Deal Terms')).toBeInTheDocument(); - - // Every field keeps its row, so the record reads as a structure waiting to - // be filled rather than as a blank page. - for (const label of ['Stage', 'Amount', 'Close Date', 'Next Step']) { - expect(screen.getByText(label)).toBeInTheDocument(); - } - expect(emptyPlaceholders()).toHaveLength(4); - }); - +describe('record:details — the empty-ROW default is DetailSection\'s heuristic, unauthored (#7064)', () => { it('a SMALL partly-empty section (below the auto-hide threshold) now shows its empty row', () => { // 2 fields, 1 empty: under DetailSection's minimum field count in both the // desktop (4) and mobile (3) variant, so the auto-hide heuristic never - // fires and the empty row is shown. Under the old forced default this row - // was hidden. This is the second half of the user-visible behaviour change - // the changeset names — it is not limited to all-empty sections. + // fires and the empty row is shown. Under the pre-#7064 forced default + // this row was hidden — the half of that change that was never limited to + // all-empty sections, and the half objectui#8603 did NOT restore. renderDetails({ sections: [ { name: 'summary', label: 'Summary', fields: ['industry', 'stage'] }, @@ -228,9 +224,10 @@ describe('record:details — the UNAUTHORED empty-section default is DetailSecti it('the label-graveyard guard is INTACT: a large mostly-empty section still auto-hides', () => { // 4 fields, 3 empty, 1 filled — at/above both threshold variants // (min fields 4/3, empty ratio 25%/20%) with at least one filled row, so - // `shouldAutoHideEmpty` still fires exactly as before. Flipping the - // unauthored default did NOT turn populated pages into label graveyards; - // it only stopped overriding the all-empty case the heuristic reserves. + // `shouldAutoHideEmpty` still fires exactly as before — through #7064's + // flip of the unauthored default, through #7129's retirement, and through + // #8603's restoration. None of the three touched a populated page: this + // branch has never been an authored decision, in either polarity. renderDetails({ sections: [ { @@ -249,19 +246,24 @@ describe('record:details — the UNAUTHORED empty-section default is DetailSecti }); }); -describe('record:details — an authored `hideEmpty` is INERT: the key is retired (#7129)', () => { +describe('record:details — an authored `hideEmpty` decides the ALL-EMPTY section, and only that (#8603)', () => { /** - * The fixtures below are #7064's, unchanged, and so are their controls. What - * moved is the verdict: each now asserts the render the UNAUTHORED heuristic - * produces, so a reader can see that reintroducing a read of the key would - * have to break one of them. + * The fixtures are #7064's, unchanged, and so are their controls. What moved + * is the verdict: objectui#8603 restored the read, so the all-empty cases + * below assert the behaviour `@objectstack/spec`'s `describe()` promises, + * while the partly-filled case still asserts the heuristic deciding alone. + * + * Every all-empty case carries the sibling CONTROL section #7064 introduced — + * a section that MUST render. Without it an absence assertion passes just as + * well when the renderer produced no output at all, which is the one way a + * "nothing rendered" pin can be green for the wrong reason. */ - it('`hideEmpty: true` no longer hides an all-empty section — the skeleton renders', () => { + it('`hideEmpty: true` HIDES an all-empty section: no heading, no skeleton', () => { renderDetails({ sections: [ { name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: true }, // CONTROL, kept from #7064: a sibling section that MUST render, so the - // presences below are a decision about `hideEmpty` and not an artefact + // absences below are a decision about `hideEmpty` and not an artefact // of a render that never happened. { name: 'firmographics', label: 'Firmographics', fields: ['industry'] }, ], @@ -270,50 +272,66 @@ describe('record:details — an authored `hideEmpty` is INERT: the key is retire expect(screen.getByText('Firmographics')).toBeInTheDocument(); expect(screen.getByText('Manufacturing')).toBeInTheDocument(); - // Under the retired key this section vanished. The heuristic reserves the - // all-empty case, and it is now the only thing deciding. - expect(screen.getByText('Deal Terms')).toBeInTheDocument(); + // "renders nothing at all: no heading, no skeleton" — the spec's words, + // asserted on both halves: the heading AND every row it would have drawn. + expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument(); for (const label of ['Stage', 'Amount', 'Close Date', 'Next Step']) { - expect(screen.getByText(label)).toBeInTheDocument(); + expect(screen.queryByText(label)).not.toBeInTheDocument(); } - expect(emptyPlaceholders()).toHaveLength(4); + expect(emptyPlaceholders()).toHaveLength(0); }); - it('`hideEmpty: true` no longer hides the empty rows of a small partly-filled section', () => { - // 2 fields, 1 empty — below the auto-hide minimum in both threshold - // variants (4 desktop / 3 mobile), so nothing hides the row any more. + it('UNAUTHORED behaves as `true`: the renderer default hides an all-empty section', () => { + // The default is the renderer's, not the schema's — `@objectstack/spec` + // declares the key with NO default, and states the fallback as measured on + // this renderer. Same fixture as the case above with the key removed, so + // the pair reads as one measurement of the default. renderDetails({ sections: [ - { name: 'summary', label: 'Summary', fields: ['industry', 'stage'], hideEmpty: true }, + { name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] }, + { name: 'firmographics', label: 'Firmographics', fields: ['industry'] }, ], }); - expect(screen.getByText('Manufacturing')).toBeInTheDocument(); - expect(screen.getByText('Stage')).toBeInTheDocument(); - expect(emptyPlaceholders()).toHaveLength(1); + expect(screen.getByText('Firmographics')).toBeInTheDocument(); + expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument(); + expect(emptyPlaceholders()).toHaveLength(0); }); - it('the three spellings — absent, `true`, `false` — render the SAME section', () => { - // The retirement stated as one assertion, over the two fixtures where the - // old read actually decided something. ⚠️ It is deliberately NOT run on a - // large sparse section: there the heuristic fires anyway, so all three - // spellings agreed even under the old code and the assertion could not - // fail. (Measured — the first draft of this test used exactly that fixture - // and stayed green through the ablation that reddened everything else.) - const fixtures = { - // All-empty: the case `DetailSection`'s heuristic reserves. Old code hid - // the whole section for `true`. - 'all-empty': { fields: ['stage', 'amount', 'close_date', 'next_step'], filled: 0, placeholders: 4 }, - // Small partly-empty: below the auto-hide minimum in both threshold - // variants (4 desktop / 3 mobile), so the heuristic never fires. Old code - // hid the empty row for `true`. - 'small partly-empty': { fields: ['industry', 'stage'], filled: 1, placeholders: 1 }, - }; + it('`hideEmpty: false` KEEPS the heading and the label skeleton of an all-empty section', () => { + // The escape hatch, and the half that makes the key worth declaring: a + // brand-new record keeps the structure its author wrote. Under objectui#7129 + // this spelling parsed nowhere and read nowhere; it now does both. + renderDetails({ + sections: [ + { name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'], hideEmpty: false }, + { name: 'firmographics', label: 'Firmographics', fields: ['industry'] }, + ], + }); + + expect(screen.getByText('Firmographics')).toBeInTheDocument(); + expect(screen.getByText('Deal Terms')).toBeInTheDocument(); + for (const label of ['Stage', 'Amount', 'Close Date', 'Next Step']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect(emptyPlaceholders()).toHaveLength(4); + }); - const renderedFor = (fields: string[], section: Record) => { - const view = renderDetails({ sections: [{ name: 'deal_terms', label: 'Deal Terms', fields, ...section }] }); + it('the OTHER domain is untouched: all three spellings render the same partly-filled section (#7129 Q2-C)', () => { + // 2 fields, 1 empty — below the auto-hide minimum in both threshold + // variants (4 desktop / 3 mobile), so nothing hides the row. This is the + // fixture where the PRE-#7129 read decided something and where the + // restored one deliberately does not: `hideEmpty` owns the all-empty case + // alone, so an authored value may not move an empty ROW in either + // direction. ⚠️ Deliberately NOT run on a large sparse section: there the + // heuristic fires anyway, so all three spellings would agree even if the + // key had swallowed the whole contract, and the assertion could not fail. + const fields = ['industry', 'stage']; + + const renderedFor = (section: Record) => { + const view = renderDetails({ sections: [{ name: 'summary', label: 'Summary', fields, ...section }] }); const shown = { - heading: screen.queryAllByText('Deal Terms').length, + heading: screen.queryAllByText('Summary').length, filled: screen.queryAllByText('Manufacturing').length, placeholders: emptyPlaceholders().length, }; @@ -321,14 +339,12 @@ describe('record:details — an authored `hideEmpty` is INERT: the key is retire return shown; }; - for (const [name, { fields, filled, placeholders }] of Object.entries(fixtures)) { - const absent = renderedFor(fields, {}); - // The live control: the unauthored render really produced the skeleton, - // so "all three agree" is not three renders that all produced nothing. - expect(absent, name).toEqual({ heading: 1, filled, placeholders }); + const absent = renderedFor({}); + // The live control: the unauthored render really produced the skeleton, so + // "all three agree" is not three renders that all produced nothing. + expect(absent).toEqual({ heading: 1, filled: 1, placeholders: 1 }); - expect(renderedFor(fields, { hideEmpty: true }), name).toEqual(absent); - expect(renderedFor(fields, { hideEmpty: false }), name).toEqual(absent); - } + expect(renderedFor({ hideEmpty: true })).toEqual(absent); + expect(renderedFor({ hideEmpty: false })).toEqual(absent); }); }); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx index 8e003855d5..0ced66213a 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx @@ -7,57 +7,64 @@ */ /** - * `DetailViewSection.hideEmpty` is RETIRED — the four parties agree (objectui#7129). + * `DetailViewSection.hideEmpty` — the FOUR-PARTY alignment pin (objectui#7129, + * objectui#8603). * - * ## What was wrong + * ⚠️ The filename says `hideEmptyRetired-7129` and is deliberately kept. This + * file is this key's lineage pin, not a pin on one verdict: the ruling that + * created it was superseded for this key, and a rename would cost the history + * that makes the supersession legible. What the four parties SAY is below and + * is the only thing to read for today's contract. * - * One key, four contracts, three different answers (measured on PR #7123 and - * filed as this card's decision): + * | party | says | + * |------------------------------------------------|----------------------| + * | `@objectstack/spec` `RecordDetailsProps` | ✅ DECLARES it (17.3.0+) | + * | `@object-ui/types` `DetailViewSection` | ✅ declares it | + * | `./zod/views.zod.ts` `DetailViewSectionSchema` | ✅ mirrors it | + * | `RecordDetailsRenderer` + `DetailSection` | ✅ READS it | * - * | party | said | - * |-------------------------------------------|----------------------------| - * | `@objectstack/spec` `RecordDetailsProps` | ⛔ REFUSED it (17.2.0; see the 2026-09-05 note below) | - * | `@object-ui/types` `DetailViewSection` | ✅ declared it | - * | `./zod/views.zod.ts` `DetailViewSectionSchema` | ⛔ absent | - * | `RecordDetailsRenderer` | ✅ honoured it | + * ## How the four got here * - * The declaration was the only thing that made the key writable, and on any - * spec-validated page it never reached the renderer at all — so the "author - * escape hatch" the 2026-08-31 ruling described existed only where nothing - * validated. The maintainer converged the four on the spec's answer - * (2026-09-01, 总监批 #28): retire the declaration and the read, keep the spec - * refusing, keep the mirror absent. `DetailSection`'s auto-hide heuristic - * (4 fields / 25% empty; 3 / 20% on mobile) is now the WHOLE contract. + * They disagreed three ways, measured on PR #7123: the spec REFUSED the key at + * 17.2.0, `@object-ui/types` declared it, the mirror omitted it, the renderer + * honoured it — and the declaration was the only thing that made the key + * writable, so on a spec-validated page the "author escape hatch" the + * 2026-08-31 ruling described existed nowhere. The maintainer converged the + * four on the spec's answer (2026-09-01, 总监批 #28): retire the declaration + * and the read, keep the spec refusing, keep the mirror absent. * - * ## ⚠️ 2026-09-05 — the spec moved back, and this file now records a DIVERGENCE + * `@objectstack/spec` 17.3.0 then RE-DECLARED `hideEmpty` on the + * `record:details` section entry (upstream #11289, maintainer ruling + * 2026-08-23 direction 1, written from a measured symptom and with 「the + * renderer is unchanged」 in the declaration). The clause "keep the spec + * refusing" thereby described nothing, through no act of this repo — and the + * premise it rested on had been false upstream since before the ruling was + * written. * - * `@objectstack/spec` 17.3.0 RE-DECLARES `hideEmpty` on the `record:details` - * section entry (measured: the entry went 4 → 12 member keys, `hideEmpty` among - * the eight gained, lost set empty). One clause of the ruling — "keep the spec - * refusing" — therefore describes nothing any more, through no act of this - * repo. + * objectui#8603 (director seat batch #137 item 3, maintainer 「同意」 + * 2026-09-15) ruled the protocol correct and RESTORED the read, superseding + * #7129's Q1-A for this key. Q2-C — `DetailSection`'s auto-hide heuristic + * owning the empty ROWS of a section that still has a filled one — is + * untouched, and `record-details.emptySectionDefault.test.tsx` is where that + * boundary is pinned on both sides. * - * objectui's own three parties are UNCHANGED and still agree: the type does not - * declare it, the mirror omits it, the renderer does not read it. 1/4 below is - * pointed at the measured upstream truth so the divergence is a stated fact - * rather than a red test; every other assertion is untouched. - * - * ⇒ Whether objectui re-adopts the key is a MAINTAINER decision (it reverses - * the ruling and re-adds a deleted control) and is reported on objectui#7122, - * NOT taken here. If it is re-adopted, this file is the checklist: three - * parties to move, not one. + * ⇒ The decision this file used to route to objectui#7122 has been taken. + * That card closed `completed` on 2026-09-07 on an unrelated subject + * (`CalendarConfigSchema.titleField`), so the routing pointed at a closed card + * on a different question; the target is now objectui#8603, where the ruling + * is. * * ## Why one file * * Alignment is a claim about FOUR sources at once, and each of them is green on * its own while the set disagrees — which is exactly how the divergence * survived. Pinning them separately reproduces that blind spot; pinning them - * together makes any one party moving back a single red test. + * together makes any one party moving a single red test. * * ⚠️ Two same-named keys are NOT in scope here and must stay untouched: * - `record:reference_rail`'s own `hideEmpty` prop (`../record-reference-rail.tsx`) - * — a different surface, a different renderer, still live and still - * registered as an input in `../../index.tsx`; + * — a different surface, a different renderer, its own component-level + * semantics, still registered as its own input in `../../index.tsx`; * - the `detail.hideEmptyFields` i18n label (the "Show N empty fields" * toggle's copy, in all ten locale packs) — a PREFIX match on the name, * not this key. @@ -84,23 +91,34 @@ type Declares = K extends keyof DetailViewSection ? true : fal /** * Erased at runtime, so `tsc` is the only thing that can see it — this package's * `tsconfig.test.json` is what compiles it, reading `@object-ui/types` through - * the workspace dependency's BUILT `.d.ts` (its `paths` are empty). Re-adding - * `hideEmpty?: boolean` to `DetailViewSection` turns this red and nothing else - * in this file moves. + * the workspace dependency's BUILT `.d.ts` (its `paths` are empty). Deleting + * `hideEmpty?: boolean` from `DetailViewSection` turns this red and nothing + * else in this file moves. It asserted `false` while objectui#7129 held. */ -export type assertionHideEmptyIsNotDeclared = Assert, false>>; +export type assertionHideEmptyIsDeclared = Assert, true>>; /** - * Non-vacuity for the assertion above: a sibling key the interface DOES declare - * resolves `true` through the same `Declares<…>`, so `false` above is a - * measurement and not a broken conditional. + * Non-vacuity for the assertion above: a key the interface does NOT declare + * resolves `false` through the same `Declares<…>`, so `true` above is a + * measurement and not a conditional that answers `true` for everything. The + * probe key is minted for this file and verified absent from the interface. */ -export type assertionDeclaresProbeWorks = Assert, true>>; +export type assertionDeclaresProbeWorks = Assert, false>>; /* ── The three runtime parties ────────────────────────────────────────────── */ const objectSchema = { fields: { + // DECLARED and left UNSET on the record below, so the `record:details` + // dedupe ladder resolves its page-H1 candidate to `name`, finds no value + // there and hides nothing (objectui#8175). Without it the ladder's ADR-0079 + // derivation rung ends in "first title-eligible field by declaration + // order" — `industry` — and the H1 eats the one filled field, which is the + // CONTROL section below. Measured: the control section then renders no + // fields, takes `DetailSection`'s all-fields-hidden exit, and the absence + // assertions in 4/4 pass against a body that rendered nothing at all — + // exactly the vacuous green the control exists to make impossible. + name: { type: 'text', label: 'Name' }, industry: { type: 'text', label: 'Industry' }, stage: { type: 'text', label: 'Stage' }, amount: { type: 'text', label: 'Amount' }, @@ -132,33 +150,23 @@ afterEach(() => { vi.unstubAllGlobals(); }); -describe('DetailViewSection.hideEmpty is retired in objectui — and the spec re-declared it at 17.3.0 (#7129)', () => { - it('1/4 — ⚠️ `@objectstack/spec` 17.3.0 DECLARES the key again: the fourth party moved', () => { +describe('DetailViewSection.hideEmpty — all four parties declare and honour it again (#7129 → #8603)', () => { + it('1/4 — `@objectstack/spec` DECLARES the key on the `record:details` section entry', () => { // ⭐ READ THIS BEFORE CHANGING ANYTHING ELSE IN THIS FILE. // - // This assertion is inverted from what it said at 17.2.0, and the inversion - // is NOT objectui following the spec back. It records that the ruling's - // fourth party changed its answer underneath the ruling. - // - // The 2026-09-01 ruling (总监批 #28) converged four disagreeing contracts on - // the spec's answer, in these words: "retire the declaration and the read, - // keep the spec refusing, keep the mirror absent". `@objectstack/spec` - // 17.3.0 then re-declared `hideEmpty` on the `record:details` section entry - // — measured, as one of eight keys the entry gained (4 → 12 members, lost - // set empty). So the clause "keep the spec refusing" is no longer a - // description of anything, through no act of this repo. + // This is the party objectui does not control, and it is why the other + // three below say what they say. The 2026-09-01 ruling (总监批 #28) + // converged four disagreeing contracts on the spec's answer, in these + // words: "retire the declaration and the read, keep the spec refusing, + // keep the mirror absent". `@objectstack/spec` 17.3.0 then declared + // `hideEmpty` on this entry — one of eight keys it gained (4 → 12 members, + // lost set empty) — so that clause described nothing, through no act of + // this repo, and objectui#8603 realigned the other three onto it. // - // ⛔ What has NOT changed, and what this file still pins in full: objectui's - // three parties still agree the key is retired. 2/4 (the mirror omits it), - // 3/4 (the type does not declare it) and 4/4 (nothing reads it, end to end) - // are untouched below. Authoring `hideEmpty` on this renderer still does - // nothing, which is the behaviour the ruling ordered. - // - // ⇒ Whether objectui should now re-adopt the key is a MAINTAINER decision — - // it would reverse a five-day-old ruling and re-add a control the ruling - // deleted — and it is reported on objectui#7122 rather than taken here. The - // assertion is pointed at the measured truth so that the divergence is a - // stated, pinned fact instead of a red test somebody eventually deletes. + // ⛔ The key's `describe()` text is NOT asserted here. It is the promise + // this renderer's behaviour must keep — 4/4 below is where that is + // measured — but nothing in this repo PARSES that prose, so pinning it + // would fail on an upstream rewording that changed no contract. const parsed = RecordDetailsProps.safeParse({ sections: [{ label: 'Contact', fields: ['phone'], hideEmpty: true }], }); @@ -194,51 +202,79 @@ describe('DetailViewSection.hideEmpty is retired in objectui — and the spec re expect((control.data as { sections?: { columns?: number }[] })?.sections?.[0]?.columns).toBe(2); }); - it('2/4 — the `DetailViewSectionSchema` zod mirror OMITS the key', () => { + it('2/4 — the `DetailViewSectionSchema` zod mirror CARRIES the key', () => { const mirrored = Object.keys(DetailViewSectionSchema.shape); - expect(mirrored).not.toContain('hideEmpty'); - // CONTROL: the mirror really was read — `headerColor` is one it does carry. + expect(mirrored).toContain('hideEmpty'); + // CONTROL: the mirror really was read, and reading it can still answer NO — + // `headerColor` is a key it carries, and a minted key it does not. expect(mirrored).toContain('headerColor'); + expect(mirrored).not.toContain('os8603AbsentProbeQhx'); }); // 3/4 is the compile-time pair above; `vitest` proves nothing about it. - it('4/4 — `record:details` no longer READS the key: an authored one is inert end to end', () => { - // An all-empty section is the case `DetailSection`'s heuristic reserves and - // the case the old read overrode: authored `hideEmpty: true` used to make - // the whole section disappear. It must now render its skeleton. + it('4/4 — `record:details` READS the key end to end, in BOTH directions', () => { + // An all-empty section is the case this key owns and the case the read + // decides: `hideEmpty: true` (and the renderer default) makes the whole + // section disappear, `false` keeps its heading and label skeleton. // - // ⚠️ Deliberately end-to-end rather than "the renderer does not read it". - // Measured on this card's ablation: `RecordDetailsRenderer` spreads `...s`, - // so deleting its explicit `hideEmpty: s.hideEmpty` slot left the value - // still reaching `DetailSection` and this suite GREEN. The read that - // decided anything was `DetailSection`'s, and restoring THAT is what turns - // this red. A pin written against the renderer's slot alone would have - // been a pin that cannot fail. - render( - - - , - ); + // ⚠️ Deliberately end-to-end rather than "the renderer passes it on". + // Measured on #7129's ablation: `RecordDetailsRenderer` spreads `...s`, so + // deleting its explicit `hideEmpty: s.hideEmpty` slot left the value still + // reaching `DetailSection` and that suite GREEN. The read that decides + // anything is `DetailSection`'s, and this is the assertion that moves when + // it moves. A pin written against the renderer's slot alone would be a pin + // that cannot fail. + // + // ⚠️ NON-VACUITY: every render below carries a sibling CONTROL section that + // must appear. "Nothing rendered" is the verdict of the first case, and + // without a control it is also what a render that never happened looks + // like — a crashed or empty tree would satisfy the absence assertions on + // its own. + const renderWith = (section: Record) => + render( + + + , + ); + + // (a) authored `true` — the section renders nothing at all. + const hidden = renderWith({ hideEmpty: true }); + expect(screen.getByText('Firmographics')).toBeInTheDocument(); + expect(screen.getByText('Manufacturing')).toBeInTheDocument(); + expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument(); + for (const label of ['Stage', 'Amount', 'Close Date']) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + expect(screen.queryAllByTitle('No value')).toHaveLength(0); + hidden.unmount(); + // (b) authored `false` — heading and skeleton stay. Same fixture, same + // control, so the pair isolates the key and nothing else. + const kept = renderWith({ hideEmpty: false }); + expect(screen.getByText('Firmographics')).toBeInTheDocument(); expect(screen.getByText('Deal Terms')).toBeInTheDocument(); for (const label of ['Stage', 'Amount', 'Close Date']) { expect(screen.getByText(label)).toBeInTheDocument(); } expect(screen.queryAllByTitle('No value')).toHaveLength(3); + kept.unmount(); }); }); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 9842f5f130..bf899292be 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -584,36 +584,38 @@ export const RecordDetailsRenderer: React.FC = ({ // flat sections stay borderless so the page chrome alone provides // containment. Authors can override explicitly via `showBorder`. showBorder: s.showBorder ?? (translatedTitle ? true : false), - // ⛔ There is deliberately NO `hideEmpty` slot here, and re-adding one - // would reopen objectui#7129. + // The authored empty-section key, passed through verbatim. // - // ⚠️ Measured, so the next reader does not have to: this slot's removal - // is a STATEMENT change, not the behavioural one. The `...s` above - // spreads every authored key verbatim, so an off-spec document - // carrying `hideEmpty` still delivers it to `DetailSection` — which no - // longer reads it. Re-adding the slot alone changes nothing; the - // behaviour lives in `DetailSection`, and that is where the ablation - // for this change turns red. + // ⚠️ Measured, so the next reader does not have to: this slot is a + // STATEMENT, not the behaviour. The `...s` above already spreads every + // authored key, so `hideEmpty` reaches `DetailSection` with or without + // this line — which is why #7129's ablation found deleting the slot + // alone changed nothing and left its suite green. The behaviour lives + // in `DetailSection`, and that is where the ablation for this change + // turns red. The slot is kept so the key this renderer contracts on is + // visible at the mapping, beside `showBorder`. // - // Emptiness on a section is decided by - // `DetailSection`'s auto-hide heuristic alone — hide empty rows only - // while the section still has at least one filled row, never on an - // all-empty section (there the labels ARE the structural skeleton a - // sparse or brand-new record needs), with the reader's "Show N empty - // fields" toggle as the escape hatch. + // ⛔ Deliberately NOT defaulted here. `?? true` at this line is the + // shape objectui#7064 removed (maintainer ruling 2026-08-31): it makes + // an unauthored section indistinguishable from an authored `true` at + // every later read, so the one place that can tell them apart — + // `DetailSection`, which owns the all-empty decision — loses the + // distinction before it is asked. // - // This slot used to force `s.hideEmpty ?? true`, then (objectui#7064) - // passed the authored value through verbatim. The pass-through - // measured the key on all four contracts and found three answers: - // `@object-ui/types` declared it, this renderer honoured it, the - // `DetailViewSectionSchema` zod mirror omitted it, and - // `@objectstack/spec` REFUSES it — `RecordDetailsProps.safeParse` on a - // section carrying it returns `unrecognized_keys` naming `hideEmpty`, - // so on any spec-validated page the key never reached this line at - // all. The maintainer converged the four on the spec's answer - // (2026-09-01, objectui#7129): the declaration is retired and the - // heuristic is the whole contract. Pinned four ways in + // History, because this key has been reversed twice: the slot forced + // `s.hideEmpty ?? true` until objectui#7064 passed the authored value + // through, and objectui#7129 (maintainer 2026-09-01) then retired the + // key outright — four contracts, three answers, and + // `@objectstack/spec` REFUSING it at the 17.2.0 pin. The pin moved to + // 17.3.0, which DECLARES `hideEmpty` on the `record:details` section + // entry with a describe() promising the behaviour this repo had just + // removed, so that premise expired. objectui#8603 (director seat batch + // #137 item 3, maintainer 2026-09-15) ruled the protocol correct and + // restored the read; #7129's Q1-A is superseded for this key, and its + // Q2-C — the auto-hide heuristic owning the empty ROWS of a + // partly-filled section — is untouched. Pinned four ways in // `__tests__/record-details.hideEmptyRetired-7129.test.tsx`. + hideEmpty: s.hideEmpty, fields: dropHidden(normaliseList(filterList(s.fields))), }); }) diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index 9909bca6e6..ec6b033021 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -342,32 +342,47 @@ export interface DetailViewSection { | 'primary/10' | 'secondary/10' | 'destructive/10'; - /* - * RETIRED — `hideEmpty?: boolean` (objectui#7129, maintainer 2026-09-01). + /** + * Hide this section's empty fields, and — when EVERY field is empty — hide + * the whole section: no heading, no skeleton. Omitted behaves as `true`. + * + * Set `false` to keep an all-empty section's heading and label skeleton, the + * spelling a brand-new record needs so its authored sections do not vanish. + * + * ## What this key decides, and what it does NOT * - * ⛔ Do not re-add it. The key was declared here, REFUSED by - * `@objectstack/spec` `RecordDetailsProps` (`unrecognized_keys` on the - * `sections[]` element, measured on 17.2.0), absent from the - * `DetailViewSectionSchema` mirror in `./zod/views.zod.ts`, and honoured by - * `RecordDetailsRenderer` — one key, four parties, three different answers, - * and the only one that let an author write it was this declaration. + * It decides the ALL-EMPTY case only. Empty ROWS inside a section that still + * has at least one filled row are decided by `DetailSection`'s auto-hide + * heuristic and by the reader's own "Show N empty fields" toggle — that + * remains the whole contract there (objectui#7129 Q2-C, untouched by the + * ruling below), so an authored value of either polarity does not override + * it. The two domains are disjoint: the heuristic requires a filled row, + * this key applies only where there is none. * - * The ruling converged the four on the spec's answer: emptiness on a - * `record:details` section is decided by `DetailSection`'s auto-hide - * heuristic (4 fields / 25% empty; 3 / 20% on mobile) and by the reader's - * own "Show N empty fields" toggle. That heuristic is now the WHOLE - * contract, which also dissolves the paradox this key carried: an authored - * `hideEmpty: false` was tested as `!section.hideEmpty`, so it was - * indistinguishable from unauthored and overrode nothing. + * ## Provenance * - * The retirement is pinned four ways at - * `packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx`. + * Declared by `@objectstack/spec` on the `record:details` section entry + * (`RecordDetailsProps.sections[]`, 17.3.0+, upstream #11289, maintainer + * ruling 2026-08-23 direction 1). objectui#7129 (maintainer 2026-09-01) + * retired this declaration and the renderer read on the premise that the + * spec REFUSED the key — true at the 17.2.0 pin, false upstream by the time + * the pin moved. objectui#8603 (director seat batch #137 item 3, maintainer + * 2026-09-15) ruled the protocol correct and restored the read; that ruling + * supersedes #7129's Q1-A for this key only. * - * ⚠️ NOT the same key as `record:reference_rail`'s own `hideEmpty` - * (`packages/plugin-detail/src/renderers/record-reference-rail.tsx`), which - * is a different surface and is untouched, nor the `detail.hideEmptyFields` - * i18n label, which is the toggle's own copy. + * The wording above is the renderer's behaviour, kept in agreement with the + * spec's own `describe()` text — which the four-party pin + * `packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx` + * reads off the installed schema rather than restating. + * + * ⚠️ NOT the same key as `record:reference_rail`'s own component-level + * `hideEmpty` (`packages/plugin-detail/src/renderers/record-reference-rail.tsx`), + * which folds zero-count entry cards, nor the `detail.hideEmptyFields` i18n + * label, which is the reader toggle's copy — a prefix match on the name. + * + * @default true */ + hideEmpty?: boolean; } /** diff --git a/packages/types/src/zod/views.zod.ts b/packages/types/src/zod/views.zod.ts index db4f255aec..dbe009c0bb 100644 --- a/packages/types/src/zod/views.zod.ts +++ b/packages/types/src/zod/views.zod.ts @@ -102,6 +102,16 @@ export const DetailViewSectionSchema = z.object({ columns: z.number().optional().describe('Grid columns for field layout'), visible: z.union([z.boolean(), z.string()]).optional().describe('Section visibility condition'), showBorder: z.boolean().optional().describe('Show border around section'), + // Mirrors `DetailViewSection.hideEmpty`, restored under objectui#8603 + // (director seat batch #137 item 3, maintainer 2026-09-15) after + // objectui#7129 retired it on a premise `@objectstack/spec` had already + // reversed upstream. The key decides the ALL-EMPTY section only; empty rows + // inside a partly-filled section stay `DetailSection`'s heuristic (#7129 + // Q2-C, untouched). The declaration's own docblock carries the full contract. + hideEmpty: z + .boolean() + .optional() + .describe('Hide an all-empty section entirely; `false` keeps its heading and label skeleton'), // Closed vocabulary — the six design-system tint tokens // `@object-ui/plugin-detail`'s `HEADER_COLOR_CLASSES` resolves, and the six // `@objectstack/spec` declares on its strict `record:details` section schema From 95b0ced72246ba3a5b95461ad936ae0a0171cf48 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 14:44:18 +0000 Subject: [PATCH 2/4] refactor(plugin-detail): resolve the `hideEmpty` default at the mapping, so it reaches only the declared surface MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `DetailSection` also renders sections nobody can author the key on — the `record:details` direct-`fields` fallback body and the `detail-section` node each synthesize one, and neither surface declares `hideEmpty`. A default read as `!== false` inside the component reached those too, so an all-empty fallback body hid itself with no declarable spelling to ask the skeleton back: the exact defect upstream declared this key to fix, one surface over. Measured as four failing pins across the package, every one of them a section the author never wrote. The default therefore lives where the contract does. `RecordDetailsRenderer` applies `?? true` to an AUTHORED section and `DetailSection` tests `=== true`, which is also what "the renderer default" in the spec's own `describe()` means: the default of the renderer the key is declared on. The collateral disappears with it — the four pins are untouched by this change. Two cases pin the placement itself: the fallback body keeps its skeleton, and an authored section in the same document does not. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VCpmqvacV4BypY48QdoxcE --- packages/plugin-detail/src/DetailSection.tsx | 21 +++++--- ...ecord-details.emptySectionDefault.test.tsx | 54 +++++++++++++++++-- .../src/renderers/record-details.tsx | 47 ++++++++++------ 3 files changed, 94 insertions(+), 28 deletions(-) diff --git a/packages/plugin-detail/src/DetailSection.tsx b/packages/plugin-detail/src/DetailSection.tsx index 1946b7d68c..f47a5960f1 100644 --- a/packages/plugin-detail/src/DetailSection.tsx +++ b/packages/plugin-detail/src/DetailSection.tsx @@ -266,20 +266,27 @@ export const DetailSection: React.FC = ({ // nothing at all (no heading, no skeleton), and `false` keeps the heading // and the label skeleton on an all-empty record. // - // ⚠️ Read as `!== false`, NOT as a truthiness test. `!section.hideEmpty` - // is what made an authored `false` indistinguishable from unauthored under - // the pre-#7129 code, and an override nobody can write is the defect this - // restoration must not reintroduce. + // ⚠️ `=== true`, and the DEFAULT IS NOT HERE. `RecordDetailsRenderer` + // resolves it (`hideEmpty: s.hideEmpty ?? true` on the authored section), + // because "the renderer default" in that describe() is the default of the + // renderer the key is DECLARED on. This component also receives sections + // nobody could write the key on — the `record:details` direct-`fields` + // fallback body and the `detail-section` node both synthesize one, and + // neither surface declares `hideEmpty` — and hiding those would be a hide + // with no declarable spelling to ask the skeleton back, which is the exact + // defect upstream declared the key to fix. A truthiness test + // (`!section.hideEmpty`) is banned for the mirror-image reason: it is what + // made an authored `false` indistinguishable from unauthored before #7129. // // ⚠️ `!isEditing` is this renderer's boundary on the contract, and it is // deliberate: inline-edit mode is the surface where those empty rows are the // INPUTS. Hiding an all-empty section there would put its fields out of the // author's and the user's reach entirely, which no describe() asks for and - // which would be a new defect rather than a restored behaviour. The reading - // the contract governs is what a READER sees, and that is what this leaves + // which would be a new defect rather than a restored behaviour. What the + // contract governs is what a READER sees, and that is what this leaves // unchanged. const allFieldsEmpty = section.fields.length > 0 && filledCount === 0; - const hideAllEmptySection = section.hideEmpty !== false && allFieldsEmpty && !isEditing; + const hideAllEmptySection = section.hideEmpty === true && allFieldsEmpty && !isEditing; const hideEmptyEffective = !showEmptyOverride && (shouldAutoHideEmpty || hideAllEmptySection); diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx index c6c9f9ac4b..19563c41a6 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.emptySectionDefault.test.tsx @@ -48,11 +48,15 @@ * not. The escape hatch is real this time — `hideEmpty: false` parses green on * the strict section object, which is what #7129 measured it could not do. * - * ⚠️ The unauthored all-empty default therefore moved back: a section whose - * fields are ALL empty renders nothing unless the page writes `false`. What - * did NOT move back is the shape #7064 removed — the renderer still does not - * force a default at the mapping, so `undefined` and `true` stay - * distinguishable everywhere except at the one read that resolves them. + * ⚠️ The unauthored all-empty default therefore moved back: an AUTHORED + * section whose fields are ALL empty renders nothing unless the page writes + * `false`. WHERE that default is resolved is the design, and the last describe + * block below is what pins it: `RecordDetailsRenderer` applies `?? true` to an + * authored section and `DetailSection` tests `=== true`, so a section nobody + * could have written the key on — the direct-`fields` fallback body, the + * `detail-section` node — keeps its skeleton. A hide there would be a hide + * with no declarable spelling to ask the skeleton back, which is the defect + * upstream declared the key to fix. * * Deliberately no i18n provider, so the row labels below are rung 2 of the * label ladder: the object's own DECLARED `label`. They read as field NAMES @@ -348,3 +352,43 @@ describe('record:details — an authored `hideEmpty` decides the ALL-EMPTY secti expect(renderedFor({ hideEmpty: false })).toEqual(absent); }); }); + +describe('record:details — the default reaches ONLY the surface that declares the key (#8603)', () => { + it('the direct-`fields` fallback body keeps its skeleton when every field is empty', () => { + // No `sections`, so the body falls back to the authored `fields` list and + // `DetailView` synthesizes the section itself. There is no entry for an + // author to write `hideEmpty` on, in the spec or anywhere else, so the + // default must not reach it — otherwise a brand-new record renders a blank + // page with nothing the page could say to get its structure back. + // + // ⚠️ This case is what makes `=== true` in `DetailSection` load-bearing + // rather than stylistic: under `!== false` the synthesized section would + // take the hide, and this body would be empty. + renderDetails({ fields: ['stage', 'amount', 'close_date', 'next_step'] }, {}); + + for (const label of ['Stage', 'Amount', 'Close Date', 'Next Step']) { + expect(screen.getByText(label)).toBeInTheDocument(); + } + expect(emptyPlaceholders()).toHaveLength(4); + }); + + it('an AUTHORED section in the same render DOES take the default', () => { + // The discriminating half: same document, same record, one authored + // section and — through a second render — the same field list authored as + // the fallback body. The pair is what shows the two surfaces are treated + // differently on purpose rather than by accident of fixture shape. + renderDetails({ + sections: [ + { name: 'deal_terms', label: 'Deal Terms', fields: ['stage', 'amount', 'close_date', 'next_step'] }, + // CONTROL: this one has the record's one filled field, so it renders. + { name: 'firmographics', label: 'Firmographics', fields: ['industry'] }, + ], + }); + + expect(screen.getByText('Firmographics')).toBeInTheDocument(); + expect(screen.queryByText('Deal Terms')).not.toBeInTheDocument(); + for (const label of ['Stage', 'Amount', 'Close Date', 'Next Step']) { + expect(screen.queryByText(label)).not.toBeInTheDocument(); + } + }); +}); diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index bf899292be..06b782aca5 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -584,23 +584,38 @@ export const RecordDetailsRenderer: React.FC = ({ // flat sections stay borderless so the page chrome alone provides // containment. Authors can override explicitly via `showBorder`. showBorder: s.showBorder ?? (translatedTitle ? true : false), - // The authored empty-section key, passed through verbatim. + // The authored empty-section key, and THE RENDERER DEFAULT ITSELF. // - // ⚠️ Measured, so the next reader does not have to: this slot is a - // STATEMENT, not the behaviour. The `...s` above already spreads every - // authored key, so `hideEmpty` reaches `DetailSection` with or without - // this line — which is why #7129's ablation found deleting the slot - // alone changed nothing and left its suite green. The behaviour lives - // in `DetailSection`, and that is where the ablation for this change - // turns red. The slot is kept so the key this renderer contracts on is - // visible at the mapping, beside `showBorder`. + // `@objectstack/spec` declares `hideEmpty` on this renderer's section + // entry with no schema default, and states the fallback as the + // renderer's: hiding is on, so a section whose fields are ALL empty + // renders nothing at all — no heading, no skeleton — and `false` keeps + // the heading and the label skeleton a brand-new record needs. // - // ⛔ Deliberately NOT defaulted here. `?? true` at this line is the - // shape objectui#7064 removed (maintainer ruling 2026-08-31): it makes - // an unauthored section indistinguishable from an authored `true` at - // every later read, so the one place that can tell them apart — - // `DetailSection`, which owns the all-empty decision — loses the - // distinction before it is asked. + // ⭐ The default is resolved HERE, on an AUTHORED section, and that + // placement is the whole design. `DetailSection` tests `=== true`, so + // the default reaches exactly the surface that declares the key. + // Sections nobody can write it on stay out: the direct-`fields` + // fallback body below and the `detail-section` node each synthesize a + // section, and a hide there would be one with no declarable spelling + // to ask the skeleton back — the defect upstream declared this key to + // fix, reintroduced one surface over. + // + // ⚠️ `?? true` is the spelling objectui#7064 removed, and it is back + // deliberately. That ruling's objection was to the BEHAVIOUR — an + // all-empty section vanishing with no way for a spec-validated page to + // ask it back, because the spec refused the key. objectui#8603 reverses + // the behaviour (the spec declares the key, so the way back exists and + // parses), and the spelling is what now CONFINES the default to the + // authored surface instead of applying it to every section this file + // hands on. + // + // ⚠️ Measured, so the next reader does not have to: the `...s` above + // already spreads an authored value through, which is why #7129's + // ablation found deleting this slot alone changed nothing and left its + // suite green. This line is not a pass-through — it is the default — + // but an ablation that only deletes it still has to reach + // `DetailSection` to turn anything red on an AUTHORED `true`. // // History, because this key has been reversed twice: the slot forced // `s.hideEmpty ?? true` until objectui#7064 passed the authored value @@ -615,7 +630,7 @@ export const RecordDetailsRenderer: React.FC = ({ // Q2-C — the auto-hide heuristic owning the empty ROWS of a // partly-filled section — is untouched. Pinned four ways in // `__tests__/record-details.hideEmptyRetired-7129.test.tsx`. - hideEmpty: s.hideEmpty, + hideEmpty: s.hideEmpty ?? true, fields: dropHidden(normaliseList(filterList(s.fields))), }); }) From 8d8523e0d681fbd6d211a4e2a0a1f7ba1bdda009 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 14:53:41 +0000 Subject: [PATCH 3/4] chore(scripts): the installed-pin-claims ledger follows the `record:details` never-teach paragraph down to one site MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit objectui#8603 rewrote that paragraph — the `hideEmpty` half of it now points at the instrument that re-derives the never-teach set instead of stamping a 17.2.0 measurement — so the entry's site count is one. The gate ratchets in both directions and named the mismatch itself; the count moving DOWN is the ledger shrinking, not a hole. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VCpmqvacV4BypY48QdoxcE --- scripts/check-installed-spec-pin-claims.mjs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/scripts/check-installed-spec-pin-claims.mjs b/scripts/check-installed-spec-pin-claims.mjs index de2cf790e8..90ba27f32c 100644 --- a/scripts/check-installed-spec-pin-claims.mjs +++ b/scripts/check-installed-spec-pin-claims.mjs @@ -738,9 +738,9 @@ export const LEDGER = [ file: "packages/plugin-detail/src/index.tsx", package: "@objectstack/spec", version: "17.2.0", - sites: 2, + sites: 1, class: "stale", - why: "Two sites, both \"measured on the installed pin, 17.2.0\" with a named control.", + why: "One site, \"measured on the installed pin, 17.2.0\" with a named control, on the `record:highlights` `readonly` refusal. It was TWO until objectui#8603: the `record:details` never-teach paragraph carried the same stamp for `hideEmpty`, and the restoration of that key rewrote the paragraph, which now points at the instrument that re-derives the set (`packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts`) instead of stamping a measurement. The count moving DOWN is this ledger ratcheting, not a hole.", }, { file: "packages/plugin-form/src/sectionFields.spec-parity.test.ts", From f8388535d91253b5674e8bb946ac5854bd6ec44d Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 16 Sep 2026 15:57:39 +0000 Subject: [PATCH 4/4] fix(types,plugin-detail): the four corrections the contract review named MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Prose and release notes only — no behavioural line moves, and the review's ruled-right findings (the read, the `?? true` placement, the `!isEditing` gate, both pins, the ablation, the ledger edit, the file surface, the semver level) are untouched. 1. `DetailViewSection.hideEmpty`'s docblock dropped `@default true` and "Omitted behaves as `true`". Both are false on the `detail-view` node, the other authorable consumer of this same type, which hands each section to `DetailSection` with no default applied. Measured here, non-vacuously — a sibling control section rendered in all six cases: `detail-view` unauthored keeps heading and rows, `true` hides, `false` keeps; `record:details` unauthored hides, `true` hides, `false` keeps. The prose now names both consumers and which one resolves the default. objectui#7361 / #7735 class. 2. The `RENDERER_ONLY_SECTION_KEYS` hunk no longer says the spec "does not declare" `showBorder` — false on the installed 17.4.0, whose section entry declares it. The list is restated as what it is, a hand-kept CANDIDATE set whose forbidden members are derived per run from the installed schema, so neither name's status is written down as a verdict anywhere. Commandment #9. 3. Two pending changesets that contradicted this release are corrected in place, prose only, frontmatter and declared packages untouched — the correction route, not the rebuttal route. `7129`'s migration step told authors to delete `hideEmpty`, an instruction a reader acts on and one that would now delete the spelling that keeps a skeleton; `7064`'s "a sparse record keeps its section skeleton" survives for the fallback body and the `detail-view` node but not for an authored section. Naming them from the 8603 entry instead would have left that migration step live in the same CHANGELOG. `check-changeset-overwrite` reports this as its own case 2 and confirms both declarations keep every package name they had at base. 4. The `record-details.tsx` account of objectui#7064 is corrected. Its ruling's ground was the AUTHORING SHAPE — an empty detail body is a platform concern and an application should not have to author its way out of one, with every app hand-writing `hideEmpty: false` per section as per-app tax — made knowing the key was declared upstream. The spec-refusal reading came out of that card's own execution and was routed onward; it was not the ground. The comment now says so, records that this change reverses that ruling's behavioural half, and leaves the standing to the review that ruled it. Also removes the CJK this branch had added to the -7129 pin header, per AGENTS.md commandment #-1 (English-only codebase). Pre-existing non-English strings elsewhere in these files are left alone. Co-authored-by: Claude Claude-Session: https://claude.ai/code/session_01VCpmqvacV4BypY48QdoxcE --- .changeset/7064-empty-section-default.md | 13 ++++- ...7129-retire-detailviewsection-hideempty.md | 29 ++++++++--- ...8603-record-details-hide-empty-restored.md | 4 +- .../recordDetailsInputs.spec-parity.test.ts | 49 +++++++++++++------ ...ord-details.hideEmptyRetired-7129.test.tsx | 14 +++--- .../src/renderers/record-details.tsx | 34 ++++++++++--- packages/types/src/views.ts | 28 +++++++++-- 7 files changed, 128 insertions(+), 43 deletions(-) diff --git a/.changeset/7064-empty-section-default.md b/.changeset/7064-empty-section-default.md index 146b859d39..a0bcd3f5e0 100644 --- a/.changeset/7064-empty-section-default.md +++ b/.changeset/7064-empty-section-default.md @@ -2,6 +2,14 @@ '@object-ui/plugin-detail': minor --- +⚠️ **Partly superseded inside this same release — read the `hideEmpty` +restoration entry for what ships.** What survives below: the direct-`fields` +fallback body and the `detail-view` node keep an all-empty section's skeleton +with zero app-side authoring, and the label-graveyard guard is untouched. What +does not: on an AUTHORED `record:details` section the empty-section default is +`hideEmpty` again, so an all-empty one hides unless the page writes +`hideEmpty: false`. + **Behaviour change.** `record:details` no longer forces `hideEmpty` on the sections it synthesizes, so a sparse record keeps its section skeleton instead of collapsing. Applications relying on the old auto-hide of *unauthored* @@ -25,7 +33,10 @@ default. What changes, precisely: - an **all-empty** section renders its heading, every field label and one - empty-value placeholder per field (it used to render nothing at all); + empty-value placeholder per field (it used to render nothing at all) + — ⚠️ re-reversed for AUTHORED `record:details` sections later in this same + release, where the restored `hideEmpty` owns this case and `false` is the + spelling that keeps the skeleton; - a **small** partly-empty section — below `DetailSection`'s auto-hide threshold of 4 fields / 25% empty (3 / 20% on mobile) — now shows its empty rows; diff --git a/.changeset/7129-retire-detailviewsection-hideempty.md b/.changeset/7129-retire-detailviewsection-hideempty.md index 0273356e77..10683577fa 100644 --- a/.changeset/7129-retire-detailviewsection-hideempty.md +++ b/.changeset/7129-retire-detailviewsection-hideempty.md @@ -24,9 +24,18 @@ than a patch; it retires no capability anyone could exercise. One key had four contracts and three answers: `@object-ui/types` declared it, `RecordDetailsRenderer` honoured it, the `DetailViewSectionSchema` zod mirror omitted it, and the spec refused it. The maintainer converged the four on the -spec's answer (2026-09-01): the spec keeps refusing, the mirror stays absent, -and the declaration and the read are retired. All four are now pinned together -in `record-details.hideEmptyRetired-7129.test.tsx`. +spec's answer (2026-09-01) as it stood then. All four are pinned together in +`record-details.hideEmptyRetired-7129.test.tsx`. + +⚠️ **What that convergence settled on has since moved, inside this same +release.** The premise was that the spec refuses the key — true of the 17.2.0 +pin this repo held, and already false upstream. `@objectstack/spec` 17.3.0 +declares the key with a description promising the behaviour this entry removed, +so the maintainer ruled the protocol correct and restored the read. Net for a +reader of THIS release: the key is declared upstream, honoured here, and +`hideEmpty: false` works. The paragraphs above describe a step this release +takes and then takes back; the restoration entry is the one that describes what +ships. Going with it is the paradox the key carried: `DetailSection` tested `!section.hideEmpty`, so an authored `hideEmpty: false` was indistinguishable @@ -40,11 +49,15 @@ either polarity is now inert, and the release notes should read that way. Everything else in it stands: the unauthored default is unchanged, and so is the label-graveyard guard. -**Migration:** delete `hideEmpty` from any `record:details` section you author. -A section that used `hideEmpty: true` to hide an all-empty block will now show -that block's skeleton — headings, field labels and one empty-value placeholder -each. That is the platform's answer for a sparse record, and it is a UI -decision, not something metadata should have to make. +**⚠️ Migration: none — superseded inside this same release. ⛔ Do NOT delete +`hideEmpty` from your sections.** This entry originally told you to, because at +the time the key was retired here and refused by `@objectstack/spec`. Both +halves changed before this release shipped: the spec DECLARES +`RecordDetailsProps.sections[].hideEmpty` from 17.3.0, and the +`record:details` read is RESTORED later in this same release — see the +`hideEmpty` restoration entry, which states the behaviour that actually ships. +A section that authors `hideEmpty` keeps its meaning; `hideEmpty: false` is how +an all-empty section keeps its heading and label skeleton. **Not affected**, despite the shared name: `record:reference_rail`'s own `hideEmpty` prop, which is a different surface and still live; and the diff --git a/.changeset/8603-record-details-hide-empty-restored.md b/.changeset/8603-record-details-hide-empty-restored.md index 385ad1fe18..f5b77023e9 100644 --- a/.changeset/8603-record-details-hide-empty-restored.md +++ b/.changeset/8603-record-details-hide-empty-restored.md @@ -7,7 +7,9 @@ **User-visible.** A `record:details` section whose fields are ALL empty now renders nothing at all — no heading, no skeleton — unless the page writes `hideEmpty: false` on it, which keeps the heading and the label skeleton a brand-new record needs. That is the behaviour `@objectstack/spec` declares on `RecordDetailsProps.sections[]` and describes in the key's own `describe()` text, and this renderer had stopped delivering it. -⚠️ **The unauthored default moved.** Until this change an all-empty section always rendered its skeleton. It now hides by default, because the spec states the renderer default as on and objectui#8603 ruled the protocol correct. Pages that want the old rendering write `hideEmpty: false` — a spelling that parses green on the strict section object since spec 17.3.0, which is precisely what it could not do when the read was retired. +⚠️ **The unauthored default moved — and read that against the RELEASE, not against `main`.** An all-empty authored section rendered its skeleton on `main` from 2026-09-01, which is after the previous release, so the two entries that produced that state are still pending and ship alongside this one. Net for a consumer upgrading: the key is declared upstream and honoured here, `hideEmpty: false` works for the first time, and the empty-section default for an AUTHORED `record:details` section is unchanged from the last published release. Pages that want the skeleton on an all-empty section write `hideEmpty: false` — a spelling that parses green on the strict section object since spec 17.3.0, which is precisely what it could not do when the read was retired. + +**Release-note reconciliation, done by correction rather than by rebuttal.** Two pending entries in this same release contradicted the above, and their prose has been corrected in place (frontmatter and declared packages untouched): `7129-retire-detailviewsection-hideempty.md`, whose migration step told authors to delete `hideEmpty` — an instruction a reader acts on, and one that would now delete the very spelling that keeps a skeleton — and `7064-empty-section-default.md`, whose "a sparse record keeps its section skeleton" holds for the fallback body and the `detail-view` node but no longer for an authored `record:details` section. Naming them from here instead would have left the migration step live in the same CHANGELOG. **What did NOT change.** The empty ROWS of a section that still has a filled row stay with `DetailSection`'s auto-hide heuristic and the reader's "Show N empty fields" toggle, which no authored value overrides in either polarity (objectui#7129 Q2-C, left standing by the ruling). Inline-edit mode renders an all-empty section either way, so its fields never become unreachable. `record:reference_rail`'s own component-level `hideEmpty` and the `detail.hideEmptyFields` toggle label are different keys and are untouched. diff --git a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts index a8959a7ff6..870a4bd760 100644 --- a/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts +++ b/packages/plugin-detail/src/__tests__/recordDetailsInputs.spec-parity.test.ts @@ -114,14 +114,17 @@ const specSectionKeys = (): string[] => * slot (`s.title ?? s.label`) until objectui#6190 converged on the declared * `label` and dropped the limb. * - * `title` stays in this list on purpose, and dropping it would weaken the - * file. Membership is not "keys the renderer reads today" — it is "keys the - * spec refuses that the description must not advertise", and the spec refuses - * `title` whether or not anything reads it. A hand-kept list, but the - * ASSERTION - * filters it through the spec at runtime, so the day upstream declares one of - * these it drops out of the forbidden set on its own instead of pinning a stale - * prohibition. + * A hand-kept CANDIDATE set — keys this renderer honours on a section beyond + * the spec's original four — and ⛔ not a statement about which of them the + * spec refuses. The ASSERTION derives that per run by filtering this list + * through the installed schema, so a candidate upstream declares drops out of + * the forbidden set on its own instead of pinning a stale prohibition, and + * one upstream retires re-arms without an edit here. ⛔ Do not read the + * membership as a claim; read `stripped` in the assertion below. + * + * ⚠️ Which is why both names stay although they answer differently today: + * `title` is refused by the installed spec, `showBorder` is declared by it. + * Neither fact is written down as a verdict anywhere in this file. * * ⚠️ `hideEmpty` was a member and is GONE from the list under objectui#8603 * (director seat batch #137 item 3, maintainer 2026-09-15), which restored the @@ -250,14 +253,28 @@ describe('record:details — registry inputs vs @objectstack/spec', () => { }); it('publishes no section member key the spec refuses to carry', () => { - // The renderer honours `showBorder` per section (and once honoured - // `title`), but the spec's section object does not declare it, so an - // author who writes it gets nothing back from the contract — a refusal, in - // fact. The fixture below still carries `hideEmpty`, which the spec DOES - // declare since 17.3.0 (restored here by objectui#8603): it is the live - // control on the filter — a key that leaves `stripped` and must therefore - // NOT appear among the refused names. Documenting the refused ones here - // would teach keys the contract does not carry — the member-level twin of + // ⛔ Which of the candidates above the spec REFUSES is not stated here, and + // that is the point: it is derived below, per run, from the installed + // schema. `stripped` is that derivation, and only its members are the + // subject of the assertions that follow. + // + // ⚠️ Read the derivation, ⛔ not a remembered list. Of the two candidates, + // only `title` is refused by the installed spec today; `showBorder` IS + // declared on the section entry and therefore leaves `stripped` on its + // own. An earlier revision of this comment said the spec "does not declare + // it" of `showBorder` — false since the entry grew to its current member + // set, and false in a sentence no gate reads, which is commandment #9's + // own failure mode. The list stays a hand-kept CANDIDATE set (keys this + // renderer honours on a section beyond the spec's original four); the + // filter is what decides membership of the forbidden set, so a candidate + // the spec has since declared costs nothing and re-arms by itself if + // upstream ever retires it. + // + // The fixture below carries `hideEmpty`, which the spec DOES declare since + // 17.3.0 (the read restored here by objectui#8603): it is the live control + // on that filter — a key that leaves `stripped` and must therefore NOT + // appear among the refused names. Documenting a refused key here would + // teach one the contract does not carry — the member-level twin of // publishing a top-level input the props schema rejects. const stripped = RENDERER_ONLY_SECTION_KEYS.filter( (key) => !specSectionKeys().includes(key), diff --git a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx index 0ced66213a..5fe1f01c9b 100644 --- a/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx +++ b/packages/plugin-detail/src/renderers/__tests__/record-details.hideEmptyRetired-7129.test.tsx @@ -30,19 +30,19 @@ * honoured it — and the declaration was the only thing that made the key * writable, so on a spec-validated page the "author escape hatch" the * 2026-08-31 ruling described existed nowhere. The maintainer converged the - * four on the spec's answer (2026-09-01, 总监批 #28): retire the declaration - * and the read, keep the spec refusing, keep the mirror absent. + * four on the spec's answer (2026-09-01, director-seat batch #28): retire the + * declaration and the read, keep the spec refusing, keep the mirror absent. * * `@objectstack/spec` 17.3.0 then RE-DECLARED `hideEmpty` on the * `record:details` section entry (upstream #11289, maintainer ruling - * 2026-08-23 direction 1, written from a measured symptom and with 「the - * renderer is unchanged」 in the declaration). The clause "keep the spec + * 2026-08-23 direction 1, written from a measured symptom and with "the + * renderer is unchanged" written into the declaration). The clause "keep the spec * refusing" thereby described nothing, through no act of this repo — and the * premise it rested on had been false upstream since before the ruling was * written. * - * objectui#8603 (director seat batch #137 item 3, maintainer 「同意」 - * 2026-09-15) ruled the protocol correct and RESTORED the read, superseding + * objectui#8603 (director seat batch #137 item 3, with the maintainer's + * assent, 2026-09-15) ruled the protocol correct and RESTORED the read, superseding * #7129's Q1-A for this key. Q2-C — `DetailSection`'s auto-hide heuristic * owning the empty ROWS of a section that still has a filled one — is * untouched, and `record-details.emptySectionDefault.test.tsx` is where that @@ -155,7 +155,7 @@ describe('DetailViewSection.hideEmpty — all four parties declare and honour it // ⭐ READ THIS BEFORE CHANGING ANYTHING ELSE IN THIS FILE. // // This is the party objectui does not control, and it is why the other - // three below say what they say. The 2026-09-01 ruling (总监批 #28) + // three below say what they say. The 2026-09-01 ruling (batch #28) // converged four disagreeing contracts on the spec's answer, in these // words: "retire the declaration and the read, keep the spec refusing, // keep the mirror absent". `@objectstack/spec` 17.3.0 then declared diff --git a/packages/plugin-detail/src/renderers/record-details.tsx b/packages/plugin-detail/src/renderers/record-details.tsx index 06b782aca5..be3e97c113 100644 --- a/packages/plugin-detail/src/renderers/record-details.tsx +++ b/packages/plugin-detail/src/renderers/record-details.tsx @@ -602,13 +602,33 @@ export const RecordDetailsRenderer: React.FC = ({ // fix, reintroduced one surface over. // // ⚠️ `?? true` is the spelling objectui#7064 removed, and it is back - // deliberately. That ruling's objection was to the BEHAVIOUR — an - // all-empty section vanishing with no way for a spec-validated page to - // ask it back, because the spec refused the key. objectui#8603 reverses - // the behaviour (the spec declares the key, so the way back exists and - // parses), and the spelling is what now CONFINES the default to the - // authored surface instead of applying it to every section this file - // hands on. + // deliberately. ⛔ Read that ruling's ground as it was written, not as + // this line makes convenient: the maintainer ruled on 2026-08-31 that + // an empty detail body is a PLATFORM concern and that a metadata + // application should not have to author its way out of one. (The + // ruling's own wording is on objectui#7064; it is deliberately not + // transcribed here — AGENTS.md commandment #-1 keeps this codebase + // English-only, and a translation of a ruling is not the ruling.) The + // objection was to the AUTHORING + // SHAPE — every application hand-writing `hideEmpty: false` per section + // as per-app tax — and it was made KNOWING the key was declared + // upstream (objectstack PR #11662); the deliverable was a sparse record + // keeping a full structural skeleton with zero app-side authoring. The + // spec-refusal reading came later, out of that card's own execution, + // and was routed to objectui#7129; ⛔ it was not this ruling's ground. + // + // ⇒ objectui#8603 REVERSES the behavioural half of that ruling, and + // the authoring it rejected is what a page now writes to keep the + // skeleton. The reversal is undisclosed — the #8603 ruling does not + // name objectui#7064 — and its standing was ruled on this change's + // isolated at-tier contract review (Decision 1): a director-seat batch + // item carrying the maintainer's assent has the same authority as the + // live ruling it reverses, so it stands. ⛔ Whether it SHOULD is not a + // question this file answers. + // + // What the spelling does here is narrower than either ruling: it + // CONFINES the default to the authored surface instead of applying it + // to every section this file hands on. // // ⚠️ Measured, so the next reader does not have to: the `...s` above // already spreads an authored value through, which is why #7129's diff --git a/packages/types/src/views.ts b/packages/types/src/views.ts index ec6b033021..5588b7825e 100644 --- a/packages/types/src/views.ts +++ b/packages/types/src/views.ts @@ -344,11 +344,35 @@ export interface DetailViewSection { | 'destructive/10'; /** * Hide this section's empty fields, and — when EVERY field is empty — hide - * the whole section: no heading, no skeleton. Omitted behaves as `true`. + * the whole section: no heading, no skeleton. * * Set `false` to keep an all-empty section's heading and label skeleton, the * spelling a brand-new record needs so its authored sections do not vanish. * + * ## ⚠️ Omitting it does NOT mean the same thing on both consumers + * + * This type is consumed by two authorable renderers, and only one of them + * resolves a default — so this member deliberately carries no default tag: + * + * - **`record:details`** (`RecordDetailsRenderer`) maps every authored + * section with `hideEmpty ?? true`, so an omitted key behaves as `true` + * and an all-empty section renders nothing. That is the default the spec's + * own `describe()` states, and this renderer is the one the key is + * declared on. + * - **`detail-view`** (`DetailViewRenderer`, whose registration takes a + * `sections` input) hands each section to `DetailSection` unchanged. No + * default is applied there, so an omitted key leaves the all-empty section + * rendering its heading and skeleton. Only an explicit `true` hides it. + * + * Measured at objectui#8603, non-vacuously — a sibling control section that + * must render was present in every case, and it rendered in all six: + * `detail-view` unauthored keeps heading and rows, `true` hides, `false` + * keeps; `record:details` unauthored hides, `true` hides, `false` keeps. + * A single default tag on a member two renderers read would be true of one + * of them and false of the other, which is the objectui#7361 / + * objectui#7735 class (maintainer 2026-09-09: a docs-vs-implementation + * mismatch is a docs fix). + * * ## What this key decides, and what it does NOT * * It decides the ALL-EMPTY case only. Empty ROWS inside a section that still @@ -379,8 +403,6 @@ export interface DetailViewSection { * `hideEmpty` (`packages/plugin-detail/src/renderers/record-reference-rail.tsx`), * which folds zero-count entry cards, nor the `detail.hideEmptyFields` i18n * label, which is the reader toggle's copy — a prefix match on the name. - * - * @default true */ hideEmpty?: boolean; }