Skip to content
This repository was archived by the owner on Nov 4, 2026. It is now read-only.

Commit 1b2e58e

Browse files
committed
Merge origin/main into the open-record parity branch
Brings 27 commits of `main` onto this branch and resolves the one conflicting path, `packages/types/src/__tests__/zod-mirror-parity.test.ts`. Both sides had rewritten the same header bullet for `WiderThanDeclared`. Kept both intents: * main's objectui#8338 retirement of `feedback.zod.ts#ToastSchema::action` (its ledger entry, arm row and header history), objectui#8248's LIVE / HISTORICAL ruling and objectui#8458's movement pin; * this branch's OPEN-RECORD operator clause, its synthetic recognition pins and the caricature negative. What the merge falsified, measured rather than assumed. PR #8573 (`d4733f27e`, objectui#8516 / objectui#8556) narrowed BOTH mirrors this card ledgered — `GridSchema.columns` and `ReportComponentSchema.exportConfigs` — to `z.partialRecord`, so the operator reports neither on the merged tree. The two ledger entries and their arm rows are removed (with the entries in place, `tsc -p tsconfig.test.json` reddens at `assertionWiderMatchesLedger` and `assertionWiderLedgerRecordsEveryKey`), and the runtime describe block that asserted `safeParse({ columns: { xxl: 6 } })` returns green is removed because it no longer does — that reachability is now pinned, inverted, by main's own `mirror-partial-record-narrowing-8516.test.ts`. Every header figure is re-derived from the tree by the file's own pins, not stepped by hand: `WiderThanDeclared` reads 22 / 35 / 45 — 6 / 29 / 0 / 10, which is main's reading unchanged, because the clause moves no live key today. Three prose figures that the merge left standing without an instrument are anchored to the revision they were read at, and one bare count no pin reached is removed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01YBWFb5YgMU5dw8p2VKj16S
2 parents ca7a9cc + 7bf133d commit 1b2e58e

106 files changed

Lines changed: 9707 additions & 554 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
---
3+
4+
Correct the Live E2E lane's claim about what a failing run leaves behind, and pin it to the
5+
config that decides it (objectui#8238). `playwright.live.config.ts` declares
6+
`reporter: [['list']]`, which writes to stdout only, so no run of this lane could ever produce
7+
the `playwright-report/` the workflow header, the job summary, the upload glob and
8+
`content/docs/guide/ci-cd-pipeline.md` all promised. CI, docs and tests only; no package is
9+
released by this change.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
---
2+
---
3+
4+
Record the `#6150` ledger's own blind spot in its header: the census it pins is
5+
complete only for the un-cast `schema.KEY` spelling, and the cast-aware re-run
6+
(objectui#8327) finds 112 `(schema as any).KEY` reads across 23 files that it
7+
could never have seen. Comment only, in a test file; no package is released by
8+
this change.
Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,88 @@
1+
---
2+
'@object-ui/types': minor
3+
---
4+
5+
**Breaking for already-authored metadata:** `ToastSchema.action` is RETIRED on both
6+
published faces (objectui#8338, ADR-0049 enforce-or-remove).
7+
8+
**What was wrong.** The two faces shared nothing, and one of them had no JSON
9+
inhabitant at all. `packages/types/src/feedback.ts` declared
10+
`action?: { label: string; onClick: () => void }` — both members REQUIRED and
11+
`onClick` a function, so a JSON document could omit the key but never author it —
12+
while `packages/types/src/zod/feedback.zod.ts` declared
13+
`z.union([SchemaNodeSchema, z.array(SchemaNodeSchema)])`, a node or a list of nodes.
14+
Disjoint accept sets, one of them empty: the same document got a green `safeParse`
15+
and a `tsc` refusal, and no spelling satisfied both. `renderers/feedback/toast.tsx`
16+
read NEITHER — it reads `variant`, `title`, `description`, `duration`,
17+
`buttonVariant`, `className` and `buttonLabel`, and the file has exactly one
18+
`ComponentRegistry.register` call, so the zero is a reading and not a failed scan.
19+
20+
This is objectui#6496's Direction 2, the half its `completed` close left behind:
21+
that card measured the key, prescribed enforce-or-remove, and was closed by PR #6542,
22+
which is Direction 1 only (declaring `buttonLabel` / `buttonVariant`). The sibling
23+
`onDismiss` was finished separately under objectui#6124 and sits four lines down as
24+
an ADR-0049 tombstone whose prose gives, word for word, the reason `action` should
25+
have gone with it. `action` survived only because that sweep was over TOP-LEVEL
26+
function-valued keys and this key's function is one level down.
27+
28+
**The accept set moves in one direction only — on the MIRROR.** Measured by ablating
29+
the tombstone back to the base tree's two declarations (`3bc187b7f`) and re-running
30+
the pins, then restoring and proving both files byte-identical to `HEAD`:
31+
32+
| `{ "type": "toast", "action": … }` | before | after |
33+
| --- | --- | --- |
34+
| `{ "type": "button", "label": "Undo" }` — a node | **accepted** | refused, `invalid_type` at `action` |
35+
| `[{ "type": "button" }]` — a list of nodes | **accepted** | refused, `invalid_type` at `action` |
36+
| `{ "label": "Undo", "onClick": … }` — the TS face's own shape, and `{}` | refused, `invalid_union` at `action` | refused, `invalid_type` at `action` |
37+
| `"Undo"`, `1`, `true`, `null`, `[]`, `["a", 1]` — bare primitives, and lists of them | **accepted** | refused, `invalid_type` at `action` |
38+
| `[[{ "type": "button" }]]` — a nested list | refused, `invalid_union` at `action` | refused, `invalid_type` at `action` |
39+
| the key omitted, or an explicit `undefined` | accepted | accepted |
40+
41+
⭐ The primitive row is the LARGER half of this narrowing and is the easy one to miss: `SchemaNodeSchema` is `z.union([BaseSchemaCore, z.string(), z.number(), z.boolean(), z.null(), z.undefined()])` (`zod/base.zod.ts:84`), so `action: "Undo"` parsed green under the old union exactly as a node object did. Re-derived by re-forming the old spelling around the SHIPPED `SchemaNodeSchema` rather than a hand-rebuilt one, with a non-vacuity control: the two envelopes must disagree on the node case, else the probe is comparing a schema with itself.
42+
43+
Nothing went from refused to accepted. The key stays DECLARED rather than deleted,
44+
because `BaseSchema` is `.passthrough()`: removing the member would KEEP an authored
45+
value unvalidated and silently inert instead of refusing it. `retirementTombstone()`
46+
writes ONE guidance string into BOTH author-facing channels — the parse-time issue
47+
message and `.describe()` — so they cannot drift.
48+
49+
**The compile-time face, which a TypeScript author meets first.** A `.tsx` author
50+
COULD write `action: { label, onClick }` against the old declaration and it compiled;
51+
it is now `action?: never`, so the same literal fails type-check. That is the only
52+
place this narrowing removes something that ever worked, and it worked only in the
53+
programmatic channel — no JSON document could ever hold it, and no renderer read it.
54+
55+
**Not `handlerKeyRefusal()`.** `action` is ⛔ not a handler key: it is a VALUE key
56+
whose NESTED member was a function. So it takes the ADR-0049 retirement helper
57+
(`invalid_type`, and `?: never` on the declaration) and not objectui#6124's
58+
named-refusal arm (`custom`, with a TS twin that stays callable for a runtime slot).
59+
The two helpers are pinned apart in `handler-keys-json-refusal-6124.test.ts`, whose
60+
census counts 45 runtime slots + 22 retired `on*` sites — a non-handler key has no
61+
seat in it.
62+
63+
**Migration: there is NONE, and the capability was never fulfilled.** ⛔ Not "not yet
64+
supported" and ⛔ not a pointer at a future shape. objectui#6250's close moved the
65+
seven toast demos off an in-toast action entirely; an in-toast action button remains
66+
a capability expansion with zero runtime and would need its own card. Raise the toast
67+
from the node itself (`title`, `description`, `variant`, `duration`) and label its
68+
trigger with `buttonLabel` / `buttonVariant`. Measured across this repository: of the
69+
7 authored `toast` nodes in tracked JSON, **0** carry `action` (control: the same walk
70+
finds all 7 nodes), and no source file authors one **on a `toast` node**. ⚠️ Stated that narrowly on purpose: `app-shell/src/chrome/notificationToast.tsx:76` and `chrome/toast-helpers.ts:71` DO write `action: { label, onClick }` — for **sonner's** runtime API (`import { toast } from 'sonner'`), a different interface this key never fed. A flat "no source authors one" would be literally false.
71+
72+
**⛔ `EmptySchema.action` is untouched** (objectui#7105 / PR #8330) — the same word on
73+
a sibling schema in the same file, with the deliberately opposite disposition, and its
74+
own comment block explains that it refuses the `{ label, onClick }` shape this key
75+
spelled. Two different interfaces, on purpose.
76+
77+
**The parity ledgers drain with it,** every figure re-derived by
78+
`zod-mirror-parity.test.ts`'s own AST and mirror instruments rather than stepped by
79+
hand: `KnownDrift` 42 entries / 64 keys → **41 / 63** (the entry's whole content, so
80+
the entry went too — the ledger's first loss by RETIRING a key rather than by moving
81+
either face toward the other), and `WiderThanDeclared` 23 / 36 / 47 arms, split
82+
6 / 30 / 0 / 11 → **22 / 35 / 45**, split **6 / 29 / 0 / 10**. The pair itself stays
83+
registered, so `EXPECTED_MIRROR_PAIRS` does not move.
84+
85+
Graded `minor`, not `patch`: a published accept set narrows. Not `major` per this
86+
repo's fixed-group convention — objectui's own breaking changes ship as `minor` and
87+
the group's major tracks `@objectstack` (AGENTS.md 版本号策略, enforced by
88+
`scripts/check-changeset-no-major.mjs`).
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
---
3+
4+
Pin the i18n call-site gate's factory abstention counters on `main`
5+
(objectui#8423). The factory half of `scripts/check-i18n-call-site-keys.mjs`
6+
prints `factoryRowsNoEnKey`, `factoryUnjudgedRows` and its matching/compared
7+
pair on every run and held none of them to anything, so `factoryRowsNoEnKey`
8+
moved 5 to 0 when objectui#7887 retired the `timeline.relative.*` rows and
9+
nothing failed. The main-line case now mirrors the three lines the hand-rolled
10+
half already pins, keeping the existing `factoryTables > 25` and
11+
`factoryComparedRows > 500` lower bounds so the zeros stay readings rather than
12+
"the scan found nothing". Test only; no package is released by this change.
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
---
4+
5+
Make `InspectorSelectField`'s `placeholder` reach the rendered trigger
6+
(objectui#8450). Every empty select in the metadata-admin designer drew a BLANK
7+
trigger instead of its hint.
8+
9+
The field bridges a caller's `''` through an internal sentinel so a "— None —"
10+
option can exist at all (Radix `<Select.Item value="">` throws). That bridge also
11+
guaranteed the value handed to Radix was never `''` or `undefined` — the only two
12+
values for which Radix renders `SelectValue`'s placeholder — and a controlled
13+
value matching no `SelectItem` renders as nothing. So the declared `'—'` default
14+
was unreachable at all 45 call sites, none of which passes a placeholder of its
15+
own.
16+
17+
The trigger now renders the placeholder itself, on the narrow state that means
18+
"nothing is selected": no value AND no option standing for none. Where the caller
19+
DOES offer a `''` row, that row is a selection and its label still wins,
20+
unchanged. A non-empty value matching no option also still renders blank —
21+
that is a stale value, not an empty one, and is out of this change's scope.
22+
23+
Measured effect on the designer: 13 of the 45 call sites can be empty without
24+
offering a "none" row, and those now show `'—'` where they showed nothing — the
25+
flow-node config selects, the app-nav item type, the curated page-block props,
26+
the report dataset/chart-axis pickers and the action target/variant/mode/component
27+
pickers. The other 32 render exactly as before.
Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
---
2+
"@object-ui/core": patch
3+
---
4+
5+
`ValueDataSource`: a stored value that is not a string now satisfies `not_contains`
6+
/ `$notContains` instead of failing the operator and its negation both.
7+
8+
Both arms were written as `typeof value === 'string' && !value.includes(target)` — a
9+
type test standing in for the predicate. A row whose column held the number `5`
10+
failed `contains '5'` (correct: a number cannot contain a substring) and also failed
11+
`not_contains '5'` (wrong: for that same reason it does not contain it), so the row
12+
appeared in **no** filter answer, and the opposite filter — the one thing a user has
13+
to debug a missing row with — was silent too. Measured on a mixed fixture,
14+
`not_contains '5'` returned 1 of 8 rows where it now returns 7.
15+
16+
The arms are now the exact complement of `contains` / `$contains`. The positive text
17+
operators are unchanged and keep their type gate: that is the other half of
18+
objectstack#14079 (maintainer ruling 2026-09-05, option A) — a non-string never
19+
satisfies a positive text operator and always satisfies `$notContains`, so
20+
complementarity holds on every face.
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
---
2+
---
3+
4+
Test-only: `packages/types/src/__tests__/zod-mirror-parity.test.ts`. It sits under
5+
`src/`, so the presence gate counts it, but nothing published moves: the package's
6+
build `tsconfig.json` excludes `**/__tests__/**` and its `files` list is `dist` only,
7+
so the file never reaches a consumer. Declared as releasing nothing.
8+
9+
- objectui#8458: the WIDER ledger's objectui#7760 movement sentence said "16 pairs",
10+
the one figure in that paragraph no `headerFigures` pin reached. Re-derived as 18
11+
(13 emptied + 5 reduced), defined, and pinned.
12+
- objectui#8248: objectui#8222's exclusion note called the seed decomposition a LIVE
13+
figure, which objectui#8243 made historical. The guidance clause is amended per the
14+
maintainer ruling (decision batch #71); the two measurement-record clauses are
15+
untouched, and the record-vs-guidance rule is now written once in the file header.
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
---
2+
'@object-ui/plugin-detail': minor
3+
---
4+
5+
**An empty array is no longer a cell value on the record page (objectui#8474).**
6+
7+
`hasCellValue` — THE definition of emptiness for `record:details` (objectui#8376,
8+
widened to the package by objectui#8394, extracted to `emptiness.ts` by
9+
objectui#8457) — opened with
10+
`if (value !== null && typeof value === 'object') return true;`. `typeof []` is
11+
`'object'`, so an **empty array was a value** and nothing further was asked of
12+
it.
13+
14+
The object half's reasoning is sound and stays: an object value is handed to a
15+
type-aware renderer that knows how to draw it. But every example it reasons
16+
about is a *populated* object, and for `[]` the renderer has nothing to draw —
17+
`SelectCellRenderer` tests `value == null || value === ''`, which `[]` passes,
18+
then maps it over zero entries. The result was the blank cell the em-dash exists
19+
to prevent, produced by the function that exists to prevent it, plus the full
20+
objectui#8376 triple: the row escaped `emptyCount`, `canCopy` offered to copy it,
21+
and because `shouldAutoHideEmpty` needs only `filledCount > 0` a section whose
22+
one non-null value was `[]` armed auto-hide by itself and buried every genuinely
23+
empty row around it.
24+
25+
**The change.** One arm inside the object branch:
26+
`if (Array.isArray(value) && value.length === 0) return false;`. All five readers
27+
of the shared authority get the corrected answer — `DetailSection`'s affordance,
28+
`emptyCount` and `canCopy`; `HeaderHighlight`'s highlight strip; `DetailView`'s
29+
summary chips; `HistoryTimeline`'s diff placeholder; `RecordMetaFooter`'s actor
30+
test.
31+
32+
**What did NOT move, and why it is a measurement.** `{}` is still a value: on
33+
`json`, `object` and `location` fields it draws the literal `{}` through
34+
`JsonCellRenderer`, so there is no blank cell to fix. The shape that would have
35+
swept it in — `Object.keys(value).length === 0` — is also true of a `Date`, a
36+
populated `Map`, a populated `Set` and a getter-backed class instance, which
37+
would be a false-empty on values that render.
38+
39+
**Declared cost.** A `json`-family field holding `[]` previously rendered the
40+
literal two-character text `[]`; it now draws the `No value` placeholder. That is
41+
intended and pinned.
42+
43+
`RelatedList.isValueEmpty` is untouched: it already drew this line, and the
44+
shared authority moved toward it — never the reverse.
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
---
2+
'@object-ui/fields': patch
3+
---
4+
5+
An empty array no longer makes a cell renderer fabricate a value (objectui#8490).
6+
7+
objectui#8481 fixed the shared read renderers whose output for `[]` was *blank*. These
8+
are the other half of that census: the renderers whose output for `[]` was not blank
9+
but **wrong** — a value the record does not hold. Measured by rendering on `e411c3e58`:
10+
11+
| field types | renderer | rendered for `[]` before |
12+
|---|---|---|
13+
| `boolean`, `toggle` | `BooleanCellRenderer` | a **checked**, disabled checkbox (`aria-checked` true); a completion field drew the green "Completed" indicator |
14+
| `number`, `slider`, `rating` | `NumberCellRenderer` | the digit `0` |
15+
| `currency` | `CurrencyCellRenderer` | the digit `0` |
16+
| `percent`, `progress` | `PercentCellRenderer` | a 0% progress bar with `aria-valuenow` of 0 |
17+
| `email` | `EmailCellRenderer` | a live anchor whose `href` was `mailto:` with no address, plus a copy button |
18+
| `url` | `UrlCellRenderer` | a live `_blank` anchor with an empty `href` |
19+
| `phone` | `PhoneCellRenderer` | a live anchor whose `href` was `tel:` with no number |
20+
| `color` | `ColorSwatchCellRenderer` | a bordered swatch box with no colour and an empty hex span |
21+
| `date` | `DateCellRenderer` | a hand-rolled em-dash with no accessible name (not the shared placeholder) |
22+
23+
All of them now render the shared `EmptyValue` affordance — the muted em-dash with a
24+
`No value` accessible name — exactly as they already did for `null`.
25+
26+
**The ruling is per renderer, not one predicate.** A boolean cell is not a text cell:
27+
`[]` holds no boolean, so the column holds *no value*, not `false` — a real `false` is
28+
still an unchecked box, and only `[]` moves (scalar truthiness coercions are untouched).
29+
The number family's `0` was `Number('')`, a coercion artefact rather than a stored zero,
30+
so its guard is now on the coerced *text* — which **deliberately also sweeps a stored
31+
`''`** (the same fabrication one input shape over); a real stored `0` still prints. The
32+
link family draws no anchor when there is nothing to link to; `color` draws no swatch
33+
without a colour string; `date` reaches the shared affordance instead of `formatDate`'s
34+
private dash.
35+
36+
**Deliberately narrow.** `{}` is untouched everywhere, `json` still draws the array
37+
literal and `file` still states `0 files` (the objectui#8481 fence), and whether a
38+
non-boolean *scalar* in a boolean column should surface as a coercion error is a separate
39+
question this change does not answer.
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
---
2+
'@object-ui/plugin-detail': patch
3+
'@object-ui/types': patch
4+
---
5+
6+
`record:details` — implement the `sections[].group` reference form, stop one
7+
section taking its siblings down, and resolve enumerated field labels through
8+
the object's declared `label` (objectui#8497).
9+
10+
**An authored `{ group: 'parties' }` used to blank the whole record page.**
11+
`@objectstack/spec` 17.3.0 declares the group-reference form in full
12+
(`RecordDetailsProps.sections[].group`, objectstack#13855, ADR-0085 §5), but
13+
this renderer read the key nowhere, and its own props description told authors
14+
so. That was not a no-op: a section carrying `group` carries no `fields`,
15+
`DetailView` mapped every section through `s.fields` unguarded, and `flatMap`
16+
kept the resulting `undefined` as an element whose `.name` the next line read.
17+
The throw arrived with `e99770841` (2026-05-01), months before the key it
18+
fires on was declared, so it was never a deliberate refusal — the declaration
19+
was right and the runtime had never honoured it. `group` is now resolved
20+
through `deriveFieldGroupDetailSections`, the same adapter the synthesized
21+
default record page uses, so the group-referenced body and an equivalent
22+
enumerated body render identically. A `group` naming no declared group renders
23+
nothing and says so on the console (`@objectstack/lint` reports it as
24+
`page-section-group-unknown`); it is no longer silent, and it no longer throws.
25+
26+
**A malformed section now degrades to that section.** Two layers, because one
27+
is not enough: the field-collecting reads are tolerant of a section with no
28+
`fields` array (the crash above fired in a `useMemo` above the section loop,
29+
where no boundary could reach it), and each section is additionally wrapped in
30+
the per-section error boundary, so a section that throws while rendering shows
31+
the failure in its own place instead of replacing the whole component.
32+
33+
**Enumerated field labels reach the object's declared `label`.** The ladder is
34+
`i18n bundle key -> the object's declared label -> the field name`. Rung 2 was
35+
missing, so an app with no translations configured showed raw snake_case field
36+
names on every detail page. An authored entry `label` still wins, and an app
37+
with a full bundle is unchanged.

0 commit comments

Comments
 (0)