Skip to content

Commit abc1b18

Browse files
os-steveclaude
andauthored
feat(core): isEmptyValue — the shared emptiness floor, and five surfaces that state their answer against it (#8981)
Five surfaces each held their own answer to "is this value empty", and objectui#8481 was the third rediscovery of the same hole. The weakest common claim — null, undefined, the empty string, the empty array — now lives in `@object-ui/core` as `isEmptyValue`, below every consumer. The floor was not invented: `evaluator/optionRules.ts` had spelled exactly those four members privately, and this promotes that copy. Every surface that answers differently keeps its own answer, rewritten as an explicit call on the floor with the justification at the site: - `hasCellValue` and `RelatedList.isValueEmpty` extend it with a trim; - `BooleanCellRenderer` extends it with every non-boolean (false stays a value); the date cells with every falsy scalar (the epoch stays empty); - `JsonCellRenderer` DECLINES its `[]` member — the array literal is drawn on purpose — and `LocationCellRenderer` / `AddressCellRenderer` inherit that through the JSON fallback; `FileCellRenderer` states "0 files". Two visible fixes: a gallery card and a kanban card holding `[]` in a card field now omit that field, as they already did for `null`, instead of drawing a labelled "No value" em-dash beside fields that were omitted. Claude-Session: https://claude.ai/code/session_01MPaVWWMuWeT5LgB1qoXjVB Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6112e0d commit abc1b18

14 files changed

Lines changed: 1036 additions & 86 deletions

File tree

‎.changeset/8496-emptiness-floor.md‎

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
---
2+
'@object-ui/core': minor
3+
'@object-ui/fields': patch
4+
'@object-ui/plugin-detail': patch
5+
'@object-ui/plugin-list': patch
6+
'@object-ui/plugin-kanban': patch
7+
---
8+
9+
Add `isEmptyValue` to `@object-ui/core` — the weakest common claim about "is
10+
this value empty": `null`, `undefined`, the empty string, the empty array, and
11+
never a fifth member (objectui#8496, director seat, decision batch #86).
12+
13+
Five surfaces had each grown their own copy of those four members, and
14+
objectui#8481 was the third rediscovery of the same hole. They now call the
15+
shared floor and state their own answer against it: `record:details`'
16+
`hasCellValue` and `RelatedList` extend it with a trim, `BooleanCellRenderer`
17+
with every non-boolean, the date cells with every falsy scalar; `JsonCellRenderer`
18+
declines its `[]` member out loud (the array literal is drawn on purpose) and
19+
`FileCellRenderer` states "0 files" instead.
20+
21+
Two visible fixes come with it: a gallery card and a kanban card holding an
22+
empty array in a card field now OMIT that field, as they already did for `null`,
23+
instead of drawing a labelled "No value" em-dash for it.

‎packages/core/src/evaluator/optionRules.ts‎

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,7 @@
3131
*/
3232
import type { DependsOnInput } from '@object-ui/types';
3333
import { evalFieldPredicate, type FieldRulePredicate } from './fieldRules.js';
34+
import { isEmptyValue } from '../utils/emptiness.js';
3435

3536
/**
3637
* Minimal shape of a select/radio option this module reads. Deliberately has no
@@ -68,10 +69,12 @@ export function resolveDependsOnFields(dependsOn: DependsOnInput): string[] {
6869
.filter((f): f is string => typeof f === 'string' && f.length > 0);
6970
}
7071

71-
/** A value counts as "empty" (dependency unmet) when nullish, blank, or an empty array. */
72-
function isEmptyValue(v: unknown): boolean {
73-
return v === undefined || v === null || v === '' || (Array.isArray(v) && v.length === 0);
74-
}
72+
// A dependency counts as UNMET on exactly the shared floor — `null`,
73+
// `undefined`, the empty string, the empty array — and this module is where
74+
// those four members were first written down. objectui#8496 promoted them out
75+
// of here into `utils/emptiness.ts` (byte-for-byte the same four) so the four
76+
// other surfaces that had each re-spelled them could stop. No extension and no
77+
// declension: a gated option list asks the floor and nothing more.
7578

7679
/**
7780
* True when at least one `dependsOn` field is empty in the record — the option

‎packages/core/src/index.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,13 @@ export * from './utils/dom-props.js';
2323
export * from './utils/filter-converter.js';
2424
export * from './utils/managedBy.js';
2525
export * from './utils/extract-records.js';
26+
// The emptiness FLOOR (objectui#8496, director seat, decision batch #86): the
27+
// weakest common claim about "is this value empty" — `null`, `undefined`, the
28+
// empty string, the empty array — below `plugin-detail`, `plugin-list`,
29+
// `plugin-kanban` and `@object-ui/fields`, each of which used to spell those
30+
// four members privately. Surfaces EXTEND it or DECLINE a member out loud; ⛔
31+
// the floor itself never grows past the four.
32+
export * from './utils/emptiness.js';
2633
export * from './utils/expand-fields.js';
2734
// The RETIREMENT gate (objectui#4914, maintainer ruling B). Homed here rather
2835
// than in `@object-ui/fields` because `@object-ui/components` is one of its six
Lines changed: 128 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,128 @@
1+
/**
2+
* ObjectUI
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* The FLOOR itself (objectui#8496 — director seat, decision batch #86).
11+
*
12+
* This file pins the cheap half: the four members, and the ⛔ that keeps them
13+
* four. The EXPENSIVE half — that no surface's deliberate disagreement was
14+
* flattened into the floor — is pinned next to each surface:
15+
* `emptinessFloorExtensions-8496.test.tsx` in `@object-ui/fields` and in
16+
* `@object-ui/plugin-detail`, `galleryEmptinessFloor-8496.test.tsx` in
17+
* `@object-ui/plugin-list`, `kanbanEmptinessFloor-8496.test.tsx` in
18+
* `@object-ui/plugin-kanban`.
19+
*
20+
* ⚠️ A suite that only proves the floor works proves the half that was never
21+
* in doubt. Read the four files above as one pin.
22+
*/
23+
24+
import { describe, it, expect } from 'vitest';
25+
import { isEmptyValue } from '../emptiness.js';
26+
import { isOptionGroupGated, isValueStillOffered } from '../../evaluator/optionRules.js';
27+
28+
/** The four members, and nothing else is one. */
29+
const MEMBERS: Array<[string, unknown]> = [
30+
['null', null],
31+
['undefined', undefined],
32+
['the empty string', ''],
33+
['the empty array', []],
34+
];
35+
36+
/**
37+
* Every candidate FIFTH member, each with the measurement that refused it.
38+
* These are values, and the floor calling any of them empty is the failure
39+
* this table exists to catch.
40+
*/
41+
const REFUSED_FIFTH_MEMBERS: Array<[string, unknown, string]> = [
42+
['a whitespace-only string', ' ',
43+
'EMPTY only on record:details and RelatedList (objectui#8350) — an extension, not a member'],
44+
['an empty object literal', {},
45+
'measured a VALUE and pinned (objectui#8474): a type-aware renderer draws it'],
46+
['a populated object', { a: 1 }, 'a populated object is drawn by a type-aware renderer'],
47+
['a one-entry array', [1], 'one entry is one thing to draw'],
48+
['an array of one undefined', [undefined], 'length 1: the container has an entry'],
49+
['zero', 0, 'a stored zero is a value on every surface'],
50+
['false', false, 'BooleanCellRenderer keeps false a value (objectui#8582)'],
51+
['the numeric epoch', 0, "DateCellRenderer's `!value` calls it empty — that is its extension"],
52+
['the Date epoch', new Date(0),
53+
'Object.keys(new Date(0)).length === 0, which is why that shape is not the test'],
54+
['a populated Map', new Map([['a', 1]]),
55+
'Object.keys() is empty on it — a false-empty the floor must not have'],
56+
['a populated Set', new Set([1]), 'same false-empty shape as Map'],
57+
['a class instance behind getters', new (class { get a() { return 1; } })(),
58+
'same false-empty shape: state that Object.keys() cannot see'],
59+
['the string "0"', '0', 'a non-empty string is a value however falsy it coerces'],
60+
['NaN', NaN, 'falsy, but not one of the four members'],
61+
];
62+
63+
describe('objectui#8496 — the emptiness floor in @object-ui/core', () => {
64+
describe('THE FLOOR — exactly four members', () => {
65+
for (const [label, value] of MEMBERS) {
66+
it(`${label} is EMPTY`, () => {
67+
expect(isEmptyValue(value), `${label} must be a floor member`).toBe(true);
68+
});
69+
}
70+
});
71+
72+
describe('⛔ THE FLOOR NEVER GROWS — every candidate fifth member is a VALUE', () => {
73+
for (const [label, value, why] of REFUSED_FIFTH_MEMBERS) {
74+
it(`${label} is a VALUE — ${why}`, () => {
75+
expect(
76+
isEmptyValue(value),
77+
`${label}: the floor grew a fifth member. ${why}`,
78+
).toBe(false);
79+
});
80+
}
81+
});
82+
83+
describe('THE MEMBER COUNT — stated as a number, so a widening cannot pass unnoticed', () => {
84+
it('exactly 4 of the probed shapes are empty', () => {
85+
const probes: unknown[] = [
86+
...MEMBERS.map(([, v]) => v),
87+
...REFUSED_FIFTH_MEMBERS.map(([, v]) => v),
88+
];
89+
expect(
90+
probes.filter((v) => isEmptyValue(v)).length,
91+
'the floor answered EMPTY for something outside its four members',
92+
).toBe(MEMBERS.length);
93+
});
94+
});
95+
96+
/**
97+
* The floor was not invented: it was PROMOTED out of this package's own
98+
* private copy in `evaluator/optionRules.ts`, which had spelled the same four
99+
* members since before the card. These two exports are that copy's only
100+
* readers, so their answers are the promotion's non-regression evidence.
101+
*/
102+
describe('THE PROMOTION — core’s own former private copy still answers the same', () => {
103+
for (const [label, value] of MEMBERS) {
104+
it(`a dependency holding ${label} gates the option list`, () => {
105+
expect(
106+
isOptionGroupGated('parent', { parent: value }),
107+
`${label}: an unmet dependency must still gate`,
108+
).toBe(true);
109+
});
110+
}
111+
112+
it('a dependency holding a value does NOT gate', () => {
113+
expect(isOptionGroupGated('parent', { parent: 'cn' })).toBe(false);
114+
expect(isOptionGroupGated('parent', { parent: 0 })).toBe(false);
115+
expect(isOptionGroupGated('parent', { parent: false })).toBe(false);
116+
});
117+
118+
it('an empty value is always still offered (nothing to clear)', () => {
119+
for (const [label, value] of MEMBERS) {
120+
expect(
121+
isValueStillOffered(value, [{ label: 'A', value: 'a' }]),
122+
`${label}: an empty value has no stale choice to clear`,
123+
).toBe(true);
124+
}
125+
expect(isValueStillOffered('gone', [{ label: 'A', value: 'a' }])).toBe(false);
126+
});
127+
});
128+
});
Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
/**
2+
* ObjectUI — the shared emptiness floor
3+
* Copyright (c) 2024-present ObjectStack Inc.
4+
*
5+
* This source code is licensed under the MIT license found in the
6+
* LICENSE file in the root directory of this source tree.
7+
*/
8+
9+
/**
10+
* THE floor under "is this value empty" (objectui#8496 — director seat,
11+
* decision batch #86, 2026-09-08, option B).
12+
*
13+
* Exactly four members, and it never grows past them:
14+
*
15+
* `null` · `undefined` · the empty string · the empty array
16+
*
17+
* ## What a floor IS, and what it is not
18+
*
19+
* It is the WEAKEST claim the surfaces below it can all make — not the answer
20+
* any one of them gives. A caller does one of two things with it, and both are
21+
* legitimate:
22+
*
23+
* - **extends** it — `isEmptyValue(v) || <its own clause>` — when its surface
24+
* calls MORE things empty (a grid trims whitespace; a boolean column calls
25+
* every non-boolean empty);
26+
* - **declines a member** in the open — `isEmptyValue(v) && !Array.isArray(v)`
27+
* — when its surface has MEASURED that member to be a value there (a `json`
28+
* cell draws the two-character literal `[]` on purpose, objectui#8474).
29+
*
30+
* What is NOT legitimate is a sixth private re-spelling of these four members.
31+
* That is the defect this function exists to close: `plugin-detail`'s
32+
* `hasCellValue`, `RelatedList`'s `isValueEmpty`, `ObjectGallery`'s and
33+
* `ObjectKanban`'s inline guards and the guard idioms across
34+
* `@object-ui/fields`' cell renderers each grew their own copy, and
35+
* objectui#8481 was the THIRD rediscovery of the same hole — objectui#8474 and
36+
* objectui#8459 had each closed it at their own door first. A copy that agrees
37+
* today stops agreeing; one definition cannot.
38+
*
39+
* ## ⛔ The floor never grows past those four members
40+
*
41+
* The ruling fixed the member list, and every candidate fifth member is a
42+
* measured disagreement rather than an oversight:
43+
*
44+
* - **whitespace-only strings.** `' '` is EMPTY on `record:details` and in
45+
* `RelatedList` (objectui#8350 measured the damage a blank cell does there)
46+
* and a VALUE on the gallery, the kanban and the shared renderers. Both are
47+
* right for their surface, so the trim is an EXTENSION, not a member.
48+
* - **`{}`.** Measured as a VALUE and pinned (objectui#8474): a populated or
49+
* empty object literal is handed to a type-aware renderer that draws it, and
50+
* the shape that would sweep it in — `Object.keys(v).length === 0` — is also
51+
* true of `new Date(0)`, of a populated `Map`, of a populated `Set` and of
52+
* any class instance whose state sits behind getters.
53+
* - **`0` / `false`.** Values everywhere. `BooleanCellRenderer` keeping
54+
* `false` a value is the pinned case (objectui#8582).
55+
*
56+
* ## Why `@object-ui/core` and not `@object-ui/types`
57+
*
58+
* It is a runtime predicate, not a protocol type, so it belongs in the engine.
59+
* The ruling made that conditional on a measurement — `@object-ui/fields` is
60+
* the lowest consumer, and if it did not already depend on `core` the floor
61+
* would have had to fall back to `types`. Measured on the implementing branch:
62+
* `@object-ui/fields`' `package.json` lists `@object-ui/core` in
63+
* `dependencies`, and its barrel already imports from it. No new dependency
64+
* edge is created by this file, in either direction — `core` reaches no
65+
* consumer, which is why exporting the helper from `@object-ui/fields` instead
66+
* (option C) was refused: the gallery and the kanban would then import a
67+
* `fields` helper to decide whether to call a `fields` renderer.
68+
*
69+
* ## "Empty" is two questions; this floor answers the half both share
70+
*
71+
* objectui#8496's later evidence (comment 5603203484) measured that the word
72+
* has split in two on this codebase: SCALAR-MISSING (`EmptyValue`, the em-dash
73+
* affordance whose accessible name is fixed) and COLLECTION-EMPTY
74+
* (`EmptyDescription`, an author's own sentence). The floor serves both and
75+
* does not have to choose: its four members ARE two scalar-missing members,
76+
* one blank scalar and one empty collection, and no call site asks a boolean to
77+
* tell those apart — each one knows statically which affordance it is drawing.
78+
* ⛔ So this function is deliberately NOT the place to grow a second axis. Which
79+
* COMPONENT states the emptiness is a different question, carried by
80+
* objectui#8570 / objectui#8526 / objectui#8507.
81+
*
82+
* ## Readers
83+
*
84+
* `isOptionGroupGated` / `isValueStillOffered` here in `core` (this function's
85+
* origin: it was written privately in `evaluator/optionRules.ts`, byte-for-byte
86+
* these four members, before the ruling promoted it); `hasCellValue` and
87+
* `RelatedList.isValueEmpty` in `@object-ui/plugin-detail`; `ObjectGallery`'s
88+
* card-field row filter; `ObjectKanban`'s card-field loop; and the cell-renderer
89+
* guards in `@object-ui/fields`.
90+
*
91+
* The extensions and the declensions are pinned — the assertion that each
92+
* surface still answers DIFFERENTLY from the floor, not merely that the floor
93+
* works — in `__tests__/emptiness-floor-8496.test.ts` here and in
94+
* `emptinessFloorExtensions-8496.test.tsx` in `@object-ui/fields` and
95+
* `@object-ui/plugin-detail`.
96+
*/
97+
export function isEmptyValue(value: unknown): boolean {
98+
return (
99+
value === undefined ||
100+
value === null ||
101+
value === '' ||
102+
(Array.isArray(value) && value.length === 0)
103+
);
104+
}

0 commit comments

Comments
 (0)