From c730a8c5987bc77edbe1091c952f473897e891e0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:27:32 +0000 Subject: [PATCH 1/5] feat(spec): offer object.imageField in the object form beside nameField The record picture pointer is live, so the reconciliation ledger's declared-not-enforced omit row for it is stale. The row goes, and the object form offers the key as a plain text row beside nameField, the same face nameField has. The parse (refuseNonPictureImageField) stays the one judge of the value. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- packages/spec/src/data/object.form.ts | 10 ++++ .../metadata-form-declared-rows.pin.test.ts | 50 +++++++++++++++++++ .../metadata-form-zod-reconciliation.test.ts | 7 --- 3 files changed, 60 insertions(+), 7 deletions(-) diff --git a/packages/spec/src/data/object.form.ts b/packages/spec/src/data/object.form.ts index ab711f2d8f8..d867e358e5e 100644 --- a/packages/spec/src/data/object.form.ts +++ b/packages/spec/src/data/object.form.ts @@ -47,6 +47,16 @@ export const objectForm = defineForm({ // takes its candidates from a `dependsOn` source row, which a top-level // object row has nothing to point at). { field: 'nameField', type: 'text', colSpan: 1, helpText: 'Field whose value titles each record (e.g. "name", "subject"). ADR-0079 canonical pointer — read by record display, ObjectQL search and related-record previews.' }, + // #21765 (director ruling 5989738766, letter A) — `imageField`, the + // record's picture (#21182 ruling A), is the sibling pointer of + // `nameField` and gets the same face, for the same reason: it names one + // of THIS object's own fields and the registry has no own-field picker. + // No validator here either. The parse (`refuseNonPictureImageField` in + // `object.zod.ts`) is the one judge of the value, and its refusal at + // save is what an author sees for a name that is not an `image` / + // `avatar` field of this object. A cleared input writes no key, which + // the parse reads as no picture. + { field: 'imageField', type: 'text', colSpan: 1, helpText: 'Field whose value is each record\'s picture, drawn beside the title in the record page header. Must name a field of this object whose type is image or avatar; any other name is refused when the object is saved. Empty: no record picture, and no placeholder is drawn.' }, { field: 'isSystem', type: 'boolean', colSpan: 1, helpText: 'System object (protected from deletion; defaults sharing to public)' }, // #20349 — the object's two own field-name LISTS, beside the `nameField` // pointer. Free text, the `view.form.ts` `searchableFields` row's face: diff --git a/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts b/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts index 8ffa6100a8b..45c8b1e1d75 100644 --- a/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts +++ b/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts @@ -48,6 +48,7 @@ type FormEntry = { field?: string; widget?: string; type?: string; + colSpan?: number; language?: string; helpText?: string; visibleWhen?: string | { dialect?: string; source?: string }; @@ -170,6 +171,55 @@ describe('declared keys that now have a form row (#19085)', () => { }); }); +/** + * **`object.imageField` is offered with `nameField`'s face, beside it** (#21765, + * director ruling 5989738766, letter A). + * + * The record's picture pointer is `nameField`'s sibling: both name one of the + * object's own fields, and the registry has no own-field picker. So the ruling + * gives it the same plain `text` row, with no widget and no validator of its + * own — the parse (`refuseNonPictureImageField`) stays the one judge of the + * value. The reconciliation gate only proves the key is offered somewhere on + * the form; it does not read the row's control or where it sits, so this pin + * keeps both. + */ +describe('object.imageField is a plain text row beside nameField (#21765)', () => { + /** The section holding `key` on the object form, and the key's index in it. */ + const locate = (key: string) => { + const sections = ((METADATA_FORM_REGISTRY.object as any)?.sections ?? []) as Array<{ + name?: string; + fields?: FormEntry[]; + }>; + for (const section of sections) { + const index = (section.fields ?? []).findIndex((r) => r?.field === key); + if (index >= 0) return { section: section.name, index, rows: section.fields! }; + } + return undefined; + }; + + it('CONTROL: the locator finds nameField and reports a known absence', () => { + expect(locate('nameField'), 'lit control: the ADR-0079 title pointer row').toBeDefined(); + expect(locate('noSuchKeyAtAll'), 'dark control: a key the object form does not offer').toBeUndefined(); + }); + + it('is offered once, as a `text` row with no widget', () => { + const [row, ...extra] = rowFor('object', 'imageField'); + expect(row, '`imageField` is declared by ObjectSchema and live; the object form must offer it').toBeDefined(); + expect(extra, 'one row only').toEqual([]); + expect(row.type).toBe('text'); + expect(row.widget, 'no picker: the parse is the one judge of the value').toBeUndefined(); + expect(row.helpText?.length ?? 0).toBeGreaterThan(20); + }); + + it('sits directly after nameField, in the same section and at the same width', () => { + const name = locate('nameField')!; + const image = locate('imageField'); + expect(image?.section).toBe(name.section); + expect(image?.index).toBe(name.index + 1); + expect(image!.rows[image!.index].colSpan).toBe(name.rows[name.index].colSpan); + }); +}); + /** * **A `type: 'code'` row's `language` must name the slot it actually edits** * (#20439). diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 26f47a279c3..2a7c49dca95 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -391,13 +391,6 @@ const LEDGER: ReadonlyArray = [ key: 'externalSharingModel', why: 'declared, not enforced yet — liveness verdict `planned` (ADR-0090 D11: validated at authoring time only; the audience-aware evaluator branch that would honour it is scheduled, not built). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate', }, - { - kind: 'omit', - type: 'object', - path: ROOT_PATH, - key: 'imageField', - why: 'declared, not enforced yet — liveness verdict `planned` (the record picture: the parse refuses any name but a declared `image` / `avatar` field, and the record chrome that is to draw it in the record page header does not read it yet). No offer until it is enforced; whether to offer it then is a ruling for the enforcement, not for this gate', - }, { kind: 'omit', type: 'field', From 9e693a74930a45819c7a73dfb8502fa1c8af3adf Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:39:27 +0000 Subject: [PATCH 2/5] chore(platform-objects): regenerate the metadata-form catalogs for the imageField row Regenerated with `node scripts/check-i18n-bundles.mjs --write`; the zh-CN, ja-JP and es-ES label and helpText leaves are authored, and a second extract dropped their source-hash provenance entries, so the source-hashes companions are byte-unchanged. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../src/apps/translations/en.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/es-ES.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/ja-JP.metadata-forms.generated.ts | 4 ++++ .../src/apps/translations/zh-CN.metadata-forms.generated.ts | 4 ++++ 4 files changed, 16 insertions(+) diff --git a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts index aa23421e0ac..e2f0a0624cf 100644 --- a/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts @@ -60,6 +60,10 @@ export const enMetadataForms: NonNullable = { label: "Name Field", helpText: "Field whose value titles each record (e.g. \"name\", \"subject\"). ADR-0079 canonical pointer — read by record display, ObjectQL search and related-record previews." }, + imageField: { + label: "Image Field", + helpText: "Field whose value is each record's picture, drawn beside the title in the record page header. Must name a field of this object whose type is image or avatar; any other name is refused when the object is saved. Empty: no record picture, and no placeholder is drawn." + }, isSystem: { label: "Is System", helpText: "System object (protected from deletion; defaults sharing to public)" diff --git a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts index 7ebd1c809f8..67e74385703 100644 --- a/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts @@ -60,6 +60,10 @@ export const esESMetadataForms: NonNullable = label: "Campo de título", helpText: "Campo cuyo valor titula cada registro (p. ej. \"name\", \"subject\"). Puntero canónico de ADR-0079: lo leen la presentación del registro, la búsqueda de ObjectQL y las vistas previas de registros relacionados." }, + imageField: { + label: "Campo de imagen", + helpText: "Campo cuyo valor es la imagen de cada registro, mostrada junto al título en el encabezado de la página del registro. Debe nombrar un campo de este objeto de tipo image o avatar; cualquier otro nombre se rechaza al guardar el objeto. Vacío: sin imagen del registro y sin marcador de posición." + }, isSystem: { label: "Integrado del sistema", helpText: "Objeto de sistema (protegido contra eliminación; el uso compartido predeterminado es público)" diff --git a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts index e831e1dc02b..72b0302d00d 100644 --- a/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts @@ -60,6 +60,10 @@ export const jaJPMetadataForms: NonNullable = label: "レコードタイトル項目", helpText: "各レコードのタイトルに使う項目(例: \"name\"、\"subject\")。ADR-0079 の正規ポインタで、レコード表示・ObjectQL 検索・関連レコードのプレビューが参照します。" }, + imageField: { + label: "レコード画像項目", + helpText: "各レコードの画像に使う項目で、レコードページのヘッダーでタイトルの横に表示されます。このオブジェクトの image 型または avatar 型の項目を指定してください。それ以外の名前はオブジェクトの保存時に拒否されます。空欄の場合、レコード画像は表示されず、プレースホルダーも描画されません。" + }, isSystem: { label: "システム組み込み", helpText: "システムオブジェクト(削除から保護。共有の既定は公開)" diff --git a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts index fe8d75314d2..c5d45ceb16e 100644 --- a/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts +++ b/packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts @@ -60,6 +60,10 @@ export const zhCNMetadataForms: NonNullable = label: "记录标题字段", helpText: "用作每条记录标题的字段(例如 \"name\"、\"subject\")。ADR-0079 规定的规范指针——记录展示、ObjectQL 搜索和关联记录预览都读它。" }, + imageField: { + label: "记录图片字段", + helpText: "用作每条记录图片的字段,显示在记录页头的标题旁。必须是本对象中类型为 image 或 avatar 的字段,其他名称会在保存对象时被拒绝。留空则不显示记录图片,也不画占位图。" + }, isSystem: { label: "系统内置", helpText: "系统对象(受保护,不可删除;共享默认为公开)" From 4dcfc2109ba452a16a4619720938b00438a23ac0 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:40:44 +0000 Subject: [PATCH 3/5] test(platform-objects): re-take the two catalog counts the imageField row moves The object form's open-section leaf population reads 114 -> 116 and the catalog-wide translated-label control 659 -> 660, both measured, with no echo introduced. Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../object-collapsed-sections-echo-decisions.test.ts | 5 ++++- .../object-lifecycle-panel-echo-decisions.test.ts | 4 +++- 2 files changed, 7 insertions(+), 2 deletions(-) diff --git a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts index 2b7ec066b77..ec131f454fc 100644 --- a/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts @@ -938,7 +938,10 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape', // 114 since #19332 flight G2a gave `basics` the `fieldGroups` repeater — the // row and its six declared sub-rows, fourteen leaves, their forty-two // translated leaves authored by that flight, so `openEchoes` stays at zero. - expect(OPEN_LEAVES.length).toBe(114); + // 116 since #21765 gave `basics` the `imageField` row beside `nameField` — + // the record-picture pointer, now live — whose label and help text are + // authored in all three locales by that card, so `openEchoes` stays at zero. + expect(OPEN_LEAVES.length).toBe(116); expect(PANEL_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(false); expect(OPEN_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(true); for (const path of ['name', 'label', 'fields', 'fields.valueDomain', 'fields.deleteBehavior', 'fields.expression']) { diff --git a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts index 5d6c459e1e8..9913c5f2502 100644 --- a/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts +++ b/packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts @@ -1167,7 +1167,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => { // 659 since #21320: the agent form's `lifecycle` row left with its key // (the conversation state machine was retired — nothing ever read it), // taking its label — authored in all three locales — out of the catalog. - expect(translated.length, `${locale} positive control`).toBe(659); + // 660 since #21765: the object form offers `imageField` beside + // `nameField` — one new row label, authored in all three locales. + expect(translated.length, `${locale} positive control`).toBe(660); } // ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the // all-three predicate returns 0 while the per-locale one returns 1, so the From 0ea1799d47baa1ebb2587c8c7e9807103004fe3c Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 07:42:29 +0000 Subject: [PATCH 4/5] chore(changeset): patch spec and platform-objects for the imageField form row Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .changeset/21765-object-image-field-form-row.md | 14 ++++++++++++++ 1 file changed, 14 insertions(+) create mode 100644 .changeset/21765-object-image-field-form-row.md diff --git a/.changeset/21765-object-image-field-form-row.md b/.changeset/21765-object-image-field-form-row.md new file mode 100644 index 00000000000..a3f2f50331d --- /dev/null +++ b/.changeset/21765-object-image-field-form-row.md @@ -0,0 +1,14 @@ +--- +'@objectstack/spec': patch +'@objectstack/platform-objects': patch +--- + +Studio's object form offers `imageField`, the record's picture, as a text row beside `nameField` + +Clause-②: no + +The object form in the metadata form registry now has an `imageField` row, a plain text input placed beside `nameField`. Until now the only way to set the record picture from Studio was the Source tab's raw JSON. The help text says what the parse accepts: a field of this object whose type is `image` or `avatar`. Left empty, the object has no record picture and no placeholder is drawn. The row brings no picker and no validator of its own. A name that is not an `image` / `avatar` field of the object is refused when the object is saved, by the same parse rule as before. + +`@objectstack/platform-objects` ships the row's label and help text in its metadata-form translation catalogs, translated for `zh-CN`, `ja-JP` and `es-ES`. + +No schema key, accept set, refusal, error code or status changes, and you have nothing to re-author. From 9f6b17787539e545969e24b2871a2e1af72a9988 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 5 Oct 2026 08:05:33 +0000 Subject: [PATCH 5/5] test(spec): state the imageField pin's decision in its title, with no tracker id Claude-Session: https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ Co-authored-by: Claude --- .../spec/src/system/metadata-form-declared-rows.pin.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts b/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts index 45c8b1e1d75..05e57111a76 100644 --- a/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts +++ b/packages/spec/src/system/metadata-form-declared-rows.pin.test.ts @@ -183,7 +183,7 @@ describe('declared keys that now have a form row (#19085)', () => { * the form; it does not read the row's control or where it sits, so this pin * keeps both. */ -describe('object.imageField is a plain text row beside nameField (#21765)', () => { +describe('object.imageField is offered as a plain text row beside nameField, with the parse as the one judge of its value', () => { /** The section holding `key` on the object form, and the key's index in it. */ const locate = (key: string) => { const sections = ((METADATA_FORM_REGISTRY.object as any)?.sections ?? []) as Array<{