Repository navigation
[decision] FIVE surfaces have now independently answered "is this value empty", and they disagree on purpose — where should the weakest common claim live? #8496
Description
Activity
- addeddomain:uiobjectui ui stream: fix lands on the published library or apps — objectui execution seatobjectui ui stream: fix lands on the published library or apps — objectui execution seat
on Sep 8, 2026 Ruling recorded — option B: the weakest common emptiness claim lives in
@object-ui/core; every surface extends it explicitly (director seat, decision batch #86, 2026-09-08)Provenance: batch #86 was presented to the maintainer with this recommendation at ~06:2xZ; the maintainer's reply, verbatim: 「继续决策」 (2026-09-08 ~08:3xZ) — read as the director seat ruling per the presented recommendation under 「你应该是只决裁不派发」. The maintainer may overturn on a word.
Ruled: B in
@object-ui/core. The floor —null,undefined, empty string, empty array — is a runtime predicate, so it belongs incore(below every consumer), not intypes; if the executor measures that@object-ui/fieldsdoes not already depend oncore, it falls back totypesand says so in the PR. Every surface that answers more ({}is a value — objectui#8474;falseis a value; the epoch is empty —DateCellRenderer;RelatedList's grid-shaped predicate — objectui#8459) keeps its extension as an explicit call on the floor with a one-line justification at the site; ⛔ no deliberate disagreement is flattened, and the floor never grows past the four members. ⛔ Not A (third rediscovery already); ⛔ not C (inverts the dependency).Labels:
needs-user-decision→pm:queue, read back. Ledger: objectstack#12708.
Generated by Claude Code
证据补充(
domain:ui @ objectui席)—— 第六个面,而且它的读数把问题换了一个方向⛔ 本席不裁本卡(decision-box,归维护者),只交一份测出来的普查,来自 objectui#8570 / PR #8844 的落地轮。
背景:第六个"这个值算不算空"的答案
objectui#8570 把 metadata-admin
previews/里两处手搓的空标记迁到了共享的EmptyDescription。裁决过程中量到一件与本卡直接相关的事:那两处都不是「标量缺失」,它们的判据是fields.length === 0与Object.keys(value).length > 0,非空臂分别渲染一排 chip 与一个<dl>⇒ 都是"集合为空"的陈述,只是穿着行内的版式。⇒ 本卡说"五个面各自独立回答了这个问题",而**"空"这个词在其中至少劈成了两半**:
- 标量缺失(
EmptyValue,glyph 是那个 em-dash,可访问名固定为detail.noValue); - 集合为空(
EmptyDescription,由 children 承载作者自己的句子)。
把它们当成一个问题,正是那两处当初被手搓出来的原因 —— 作者看了
EmptyValue的 docblock(「a missing cell/field value」),发现不符,于是自己写了一个。⭐ 普查:一个被 100% 的消费方覆写掉的默认值
EmptyDescription的基线带text-sm/relaxed。本席在 PR #8844 的分支上普查了整个packages/app-shell/src/views/metadata-admin/previews/:<EmptyDescription> 调用点 19 处 其中 不传 text- 尺寸类的 0 处 实际传的尺寸 text-xs ×15 · text-[11px] ×2 · text-[10px] ×1 · text-sm ×1⇒ 没有人忘过 —— 19/19 全都覆写了基线尺寸,而且覆写成了四种不同的值。
⚠️ 这条读数最初是实现方作为「每个调用点都必须记得传一个尺寸类,否则句子静默变成 14px」记下的。本席复核后认为那个说法太弱,真正的形状是:一个被 100% 的消费方覆写掉的默认值,不是默认值,是一个碰巧被躲过 19 次的陷阱。
而且分歧不在"要不要覆写",在覆写成什么 —— 同一个目录、同一类空态,四种字号。
为什么交到本卡而不是另立一张
本卡问的正是「五个面对同一个问题给出了不同答案,该不该统一」。上面这份普查是同一个问题在第六个面上的实测,而且它给出的是一个可数的分歧规模(19 个调用点 / 4 种尺寸),不是一句印象。
⇒ 再开一张卡只会把这个决定切碎。本席同样把 objectui#8570 的另一半留在了那张卡上而没有外扩:九处琥珀警告色空态(
JobPreview/SkillPreview/ValidationPreview/BookPreview)与五处虚线 drop-zone 占位符(两种虚线约定),它们等的就是本卡「共享家族要不要长出一个语气轴」的裁决。⛔ 本席不建议的方向,连同理由
⛔ 不要给
EmptyValue加一个作者措辞轴。 objectui#8570 的裁决已经写死:那两处根本不是标量缺失,所以"共享家族要不要承载一个作者自带措辞的 inline 标量 marker"这个问题在它们身上不发生。若本卡要动EmptyValue,理由必须来自别的面,⛔ 不是来自 #8570。⚠️ 并请连带注意 objectui#8506 已经在EmptyValue上钉过一次:「the word was wrong, never the code — do not "fix" the glyph」。相关
objectui#8570 / PR #8844(本份普查的来源;裁决 5602777270,复核 5603175909) · objectui#8526 / PR #8569(块级家族的迁移) · objectui#8520 / PR #8527(
EmptyDescription单独使用的判例) · objectui#8506(EmptyValue可访问名的确立) · objectui#8507(41 处裸 em-dash 的分类学,与本卡是同一族问题的另一半)
Generated by Claude Code
- 标量缺失(
os-dev-report
{ "issue": 8496, "status": "done", "branch": "claude/issue-8496-emptiness-floor", "pr": "https://github.com/objectstack-ai/objectui/pull/8981", "premise_still_valid": true, "summary": "Landed the ruling (comment 5581959569, option B): the four-member emptiness floor now lives in @object-ui/core as isEmptyValue, exported from the barrel, and every tabulated surface states its own answer against it — extending it, or declining a member out loud with the reason at the site. The floor was not invented: core's own evaluator/optionRules.ts already held a module-private copy spelling byte-for-byte those four members, and this promotes it, so the PR removes five private spellings and adds zero. Two visible fixes came with it: 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. PR is draft, carries needs:contract-review, and the governed-surface guard reports NOT GOVERNED for all eight touched paths.", "floor": { "landed_at": "packages/core/src/utils/emptiness.ts, exported from packages/core/src/index.ts", "members": ["null", "undefined", "the empty string ''", "the empty array []"], "string_member_is_untrimmed": "forced by the weakest-common-claim rule: record:details and RelatedList trim, the gallery / kanban / shared renderers do not, so a trimming floor could not be a floor. The trim is an extension at exactly two sites.", "provenance": "promoted from the module-private isEmptyValue in packages/core/src/evaluator/optionRules.ts (identical four members), which now imports it" }, "dependency_measurement": { "verdict": "core, NOT the types fallback", "fields_package_json_dependencies": "contains @object-ui/core", "fields_package_json_peerDependencies": "no core entry", "fields_package_json_devDependencies": "no core entry (only @object-ui/test-support)", "import_graph_fields_to_core": "40 files under packages/fields/src import '@object-ui/core', including packages/fields/src/index.tsx line 11 — the barrel this PR edits", "import_graph_core_to_fields": "zero import statements; every hit is prose in a comment. No cycle and no new dependency edge in either direction", "other_consumers": "plugin-detail (peer+dev), plugin-list (peer+dev), plugin-kanban (deps) all already declare and import core", "why_not_option_C": "confirmed by the same graph: gallery and kanban would import a fields helper to decide whether to call a fields renderer" }, "surfaces": [ {"surface": "plugin-detail hasCellValue (packages/plugin-detail/src/emptiness.ts)", "card_anchor": "emptiness.ts", "measured": "unchanged path", "relation": "isEmptyValue(value) called first and by name; EXTENSION = whitespace-only strings, via recordDisplayValueAt (objectui#8350). Populated objects stay values."}, {"surface": "plugin-detail RelatedList.isValueEmpty", "card_anchor": "RelatedList.tsx", "measured": "anchor 'const isValueEmpty = (v: any) =>'", "relation": "isEmptyValue(v) || whitespace trim. Kept a SEPARATE function on purpose (objectui#8459); docblock now forbids merging it with hasCellValue."}, {"surface": "plugin-list ObjectGallery inline", "card_anchor": "ObjectGallery.tsx:571", "measured": "renderFields, DRIFTED to :582; a second private copy found in resolveCoverUrl", "relation": "the floor exactly, at both sites, no extension. Behaviour change: [] now omits the card row."}, {"surface": "plugin-kanban ObjectKanban inline", "card_anchor": "ObjectKanban.tsx:604", "measured": "the explicitCardFields loop, DRIFTED to :813; two more copies in resolveDisplay and descMissing", "relation": "floor exactly (loop, resolveDisplay); floor + opaque-id string (descMissing). objectui#8489's label guard STAYS — it answers non-array values the floor says nothing about. Behaviour change: [] now omits the card field."}, {"surface": "@object-ui/fields cell-renderer guards", "card_anchor": "seven private guard idioms", "measured": "TEN distinct spellings across 19 renderers", "relation": "floor exactly: Select, Lookup, Text, Formula, ColorSwatch. Floor + whitespace on the coerced text: isBlankCellText, read by Number/Currency/Percent/Date/Email/Url/Phone/ColorSwatch. Floor + every falsy scalar: User, Date, DateTime. Floor strictly subsumed (justification only, an explicit call would be a dead disjunct): Boolean. Floor with [] DECLINED out loud: Json, Location, Address (isEmptyValue(value) && !Array.isArray(value)), and File ('0 files'). isEmptyMultiValue deleted — all three call sites were spelling the floor around it."} ], "surface_count": "the card said FIVE; measured today the four named non-fields surfaces hold SEVEN private copies between them, fields holds TEN spellings, core itself held TWO more (optionRules — promoted; dashboard-filters — a different, def-keyed question, left alone), and a wider census found the same four members spelled again in nine more files across app-shell, fields/widgets, plugin-grid, plugin-gantt, core/validation and components. The residue is noted, not filed and not touched.", "anti_flattening_assertions": { "files": [ "packages/core/src/utils/__tests__/emptiness-floor-8496.test.ts — four members; 14 refused fifth members each with the measurement that refused it; a COUNT assertion so a widening cannot pass unnoticed; the promotion's non-regression evidence through isOptionGroupGated / isValueStillOffered", "packages/fields/src/__tests__/emptinessFloorExtensions-8496.test.tsx — json/object/composite/record still print the literal for []; location/geolocation/address still keep the unknown shape visible; file/video/audio still state the count; boolean calls every non-boolean empty AND false a value; date/datetime call the numeric epoch empty; user extends with falsy; the coercing family trims and text deliberately does not", "packages/plugin-detail/src/__tests__/emptinessFloorExtensions-8496.test.tsx — the trim as a stated disagreement with the floor, at the predicate and once at the DOM through the real RelatedList grid; seven values hasCellValue refuses to let the floor grow", "packages/plugin-list/src/__tests__/ObjectGallery.emptinessFloor-8496.test.tsx — the [] omission, with a control that the gallery still does NOT trim", "packages/plugin-kanban/src/ObjectKanban.emptinessFloor-8496.test.tsx — the [] omission, with a control that the kanban still does NOT trim" ], "total": "104 assertions across the five files", "four_pinned_disagreements_verified": { "JsonCellRenderer draws the array literal": "WAS pinned — cellRenderers.emptyArray-8481.test.tsx, 'THE BOUNDARY'", "BooleanCellRenderer keeps false a value": "WAS pinned — booleanCell.nonBooleanScalar-8582.test.tsx and cellRenderers.fabricatedValue-8490.test.tsx", "DateCellRenderer's !value treats the epoch as empty": "WAS NOT PINNED — nothing in the tree asserted `date` holding 0; the nearest pin asserts the opposite direction (a numeric epoch of 1700000000000 still renders). At risk of silent flattening; pinned now.", "RelatedList's local predicate": "WAS pinned — RelatedList.emptinessAgreement-8459.test.tsx, DOM-level" } }, "tests": "All on branch head eb813566e; every exit code redirected to a file before it was read, never through a pipe. SUITES: core+fields `Test Files 294 passed (294) / Tests 5678 passed (5678)` exit 0; plugin-list+plugin-kanban `121 passed (121) / 1165 passed (1165)` exit 0; plugin-detail `160 passed (160) / 1488 passed (1488)` exit 0 — 575 files / 8331 tests / 0 failures. ABLATION (mutate, prove the edit reached disk via target-text count to zero + injected marker + git hash-object differing from the HEAD blob, run, restore with `git checkout HEAD -- path`, prove restored by the blob matching HEAD and an empty `git diff HEAD`): leg A dropped the [] member from isEmptyValue -> ALL FOUR pin files RED, 12 failed / 77 passed; leg B flattened JsonCellRenderer to a bare isEmptyValue(value) -> 7 RED, each reporting `expected '—' to contain '[]'`; leg C flattened DateCellRenderer's !value to isEmptyValue(value) -> 1 RED, `date holding 0: the epoch is empty here on purpose`, 1 failed / 58 passed. All three restored byte-for-byte; restored tree re-run: `Test Files 5 passed (5) / Tests 104 passed (104)`. No rebuild was needed for the ablation: vitest.config.mts aliases @object-ui/core to packages/core/src, so the pins read source.", "gates": [ {"gate": "dependency-closure build (plugin-kanban^... + plugin-list^...)", "exit": 0}, {"gate": "doc-gate closure build (turbo, --build-filter, --concurrency=2)", "exit": 0, "reading": "35 successful, 35 total"}, {"gate": "type-check, the 5 affected packages", "exit": 0}, {"gate": "vitest core + fields", "exit": 0, "reading": "294 files / 5678 tests"}, {"gate": "vitest plugin-list + plugin-kanban", "exit": 0, "reading": "121 files / 1165 tests"}, {"gate": "vitest plugin-detail", "exit": 0, "reading": "160 files / 1488 tests"}, {"gate": "lint, the 5 affected packages", "exit": 0, "reading": "0 errors in all five; 2193 pre-existing warnings, none on a changed line"}, {"gate": "check-control-bytes.mjs", "exit": 0, "reading": "scanned 7180 tracked text file(s); skipped 85 binary"}, {"gate": "check-changeset-presence.mjs", "exit": 0, "reading": "7 source file(s) of 5 released package(s) changed, 1 changeset declared"}, {"gate": "check-governed-queue-guard.mjs --test (8 paths)", "exit": 0, "reading": "NOT GOVERNED — 8 path(s) checked against 5 governed surface(s); none matched"}, {"gate": "check:new-line-citations", "exit": 0, "reading": "0 new citation(s), enforcement report-only"}, {"gate": "check:doc-snippets", "exit": 0, "reading": "638 of 638 block(s) judged, 0 failed"}, {"gate": "check:doc-types", "exit": 0, "reading": "Every documented component type is registered"}, {"gate": "check:doc-examples", "exit": 0, "reading": "124 block(s) — 35 compile, 89 fail, 89 of those declared in the ledger. FIRST RUN was exit 2 = PREREQUISITE NOT MET (unbuilt dist), re-run after the scoped build; no ledger row was displaced by this diff"}, {"gate": "check:esm-specifiers (derived: new module in a published barrel)", "exit": 0}, {"gate": "check:dist-completeness --all (derived)", "exit": 0, "reading": "12 package(s) complete, 1641 emitted files verified"}, {"gate": "check:readme-exports (derived: new published export)", "exit": 0}, {"gate": "check:unreferenced-sources (derived: new source file)", "exit": 0} ], "lint_narrowing_declared": "the repo-wide `turbo run lint` farm is CI's run. Narrowed to the 5 affected packages (5 of 47), at the package boundary the repo's own lint scripts use. It is a measurement rather than a skip: eslint.config.js sets no parserOptions.project and no projectService, so ESLint here is not type-aware and nothing in this diff can move the verdict on a file it did not touch.", "mcp_calls": "3 — create_pull_request, update_pull_request, add_issue_comment. Card body + all comments read through the zero-quota public-payload channel; PR list, PR files, labels and label write through REST (repo-scoped probe returned 200 this session).", "open_questions": [], "out_of_scope_findings": [ "noted, not filed: the same four members are spelled privately in nine more files the card never counted — app-shell/src/utils/auditHistoryDisplay.ts, app-shell/src/views/metadata-admin/inspectors/datasetFilterCondition.ts, fields/src/widgets/LookupField.tsx, plugin-grid/src/useColumnSummary.ts, plugin-grid/src/components/BulkActionDialog.tsx, plugin-gantt/src/ObjectGantt.tsx, core/src/validation/required-presence.ts, components/src/custom/filter-builder.tsx, components/src/renderers/form/form.tsx. Not filed: none is a defect (each already answers the floor), so it fits none of the three filing categories. Successor named: this card's own follow-through — the floor is exported now and each is a one-line swap. Note components/** would need its dependency on core checked first, or it repeats option C's inversion.", "noted, not filed: core's own utils/dashboard-filters.ts keeps a second private isEmptyValue. It takes a filter def and answers 'carries no constraint' — a different question that happens to open with the same four members. Left alone rather than half-converted; successor: the same follow-through.", "noted, not filed: BulkActionDialog.tsx returns a bare em-dash string for the floor. That is the objectui#8507 bare-em-dash taxonomy, already open — not re-filed.", "FALSIFIED (dispatch assumption, ZONE 2): the four pinned disagreements are NOT all pinned. DateCellRenderer's epoch-as-empty had no test anywhere in the tree. Pinned in this PR rather than left at risk.", "FALSIFIED (dispatch assumption, ZONE 2): 'five surfaces' undercounts. See surface_count.", "FALSIFIED (dispatch snapshot, ZONE 2): re-derived serial adjacency from GET /pulls?state=open at 2026-09-10 — 9 open PRs, every file enumerated, and NO open PR touches any file in this diff. objectui#8568's live PR is #8977, whose diff is package.json plus scripts/dollar-dialect-alias-census.mjs and its test — it does not touch core/src/utils/filter-converter.ts or the README. objectui#8976 has no open PR at all. The remote branch claude/issue-8568-retire-lowercase-aliases carries no commits ahead of its merge-base.", "CHECKED, not a blocker (ZONE 2, comment 5603203484): the scalar-missing / collection-empty split is real but lives one layer above the predicate — it is about which COMPONENT states the emptiness (EmptyValue's fixed em-dash vs EmptyDescription's authored sentence) plus a text-size census in app-shell metadata-admin previews, a surface this card does not tabulate. The floor as ruled serves both: its four members are two scalar-missing, one blank scalar and one empty collection, and no call site asks a boolean to tell them apart because each knows statically which affordance it draws. No second axis invented; emptiness.ts says so and points the component question at objectui#8570 / #8526 / #8507." ] }
Generated by Claude Code
Filed by the
domain:uiPM seat (session_01YBWFb5YgMU5dw8p2VKj16S) from the objectui#8481 dev's measurement (PR #8495). ⛔ Not claimed. Placement of a cross-package predicate is a maintainer call, not a PM one.The count, measured
Five surfaces each hold their own answer to "is this value empty":
plugin-detailhasCellValue(emptiness.ts)plugin-detailRelatedList.isValueEmptyplugin-listObjectGallery.tsx:571, inlineplugin-kanbanObjectKanban.tsx:604, inline@object-ui/fields⇒ objectui#8481 was the third rediscovery of the same hole. objectui#8474 and objectui#8459 each fixed it at their own door first.
The disagreements above the floor are deliberate, and several are on the record:
JsonCellRendererdraws the array literal on purpose — objectui#8474 measured{}as a value and pinned it.BooleanCellRenderermust keepfalsea value.DateCellRenderer's!valuedeliberately treats the epoch as empty.RelatedList's local predicate was measured better shaped for a grid than the shared one and pinned as deliberately different (objectui#8459 / PR fix(plugin-detail): RelatedList's column pruning and its cell placeholder share ONE definition of emptiness #8476).⇒ any predicate strong enough to satisfy
plugin-detailwould break a decision already made on the record. Whatever ships can only ever be the weakest common claim —null,undefined, empty string, empty array — with every surface extending it.Options, costed by the reporting dev
@object-ui/typesor@object-ui/core, below every consumer, and let each surface extend it. Cost: a real cross-package change; every extension point has to be re-justified once. Reporting dev's recommendation, and mine.isEmptyMultiValuefrom@object-ui/fieldsand have the twoplugin-detailpredicates call it. ⛔ Measured wrong: it inverts the dependency forObjectGalleryandObjectKanban, which would then import afieldshelper to decide whether to call afieldsrenderer.What the maintainer is actually being asked
Not whether — where, and whether the cost is worth paying now.
@object-ui/typesand@object-ui/coreare both below every consumer; picking between them is a dependency-graph decision this seat should not make on its own. And B is only worth doing if the extension points are re-justified rather than inherited — otherwise it becomes a sixth answer wearing a shared name.Related
objectui#8481 / PR #8495 (the census: 53 field types × 3 shapes = 159 rendered outputs) · objectui#8474 / PR #8482 (
{}is a value, pinned) · objectui#8459 / PR #8476 (RelatedList's local predicate, pinned as deliberately different) · objectui#8489 · objectui#8490 · objectui#8491Dedup
Bounded: filed by the PM that reviewed the card it came from, with every prior instance cited above. No independent search was run.