Skip to content

Commit 2df3d13

Browse files
feat(spec): offer object.imageField in Studio's object form beside nameField (#21854)
Fixes #21765 Clause-②: no Item 3 of the card, under the director seat's ruling A (`5989738766`): Studio's object form now offers `imageField`, the record's picture, as a plain `text` row beside `nameField`. Items 1 and 2 landed in PR #21824. With this PR, the card's last open item is done. ## What changed - **The form row** (`packages/spec/src/data/object.form.ts`): one `{ field: 'imageField', type: 'text', colSpan: 1, helpText }` row directly after `nameField`. It has the same face as `nameField`, for the same reason: the value names one of the object's own fields, and the registry has no own-field picker. The row adds no picker and no validator. `refuseNonPictureImageField` at parse stays the one judge, and its refusal at save is what an author sees. The helpText says what the parse accepts: a field of this object whose type is `image` or `avatar`. Left empty, there is no record picture and no placeholder. The metadata form's text input writes no key when cleared (objectui `SchemaForm.tsx` at the pin `0abd4f9f8769`: `onChange(e.target.value || undefined)`), so "empty" in the helpText means the key is unset, which the parse reads as no picture. - **The stale ledger row** (`metadata-form-zod-reconciliation.test.ts`): the `imageField` `omit` row in the declared-not-enforced group is deleted. No other row moves. - **The catalogs**: the four `packages/platform-objects/src/apps/translations/*.metadata-forms.generated.ts` files were regenerated with `node scripts/check-i18n-bundles.mjs --write`. The zh-CN / ja-JP / es-ES `label` and `helpText` leaves were then written in place, since translated-locale values are hand-written in those files (AGENTS.md, Documentation Guardrails). A second `--write` kept them and dropped the six provenance entries the first extract had added to the three `*.source-hashes.generated.ts` companions. Those companions are byte-unchanged in this diff. - **The pin** (`metadata-form-declared-rows.pin.test.ts`): three tests. A lit and dark control for the locator. The key is offered once, as a `text` row with no widget. It sits directly after `nameField`, in the same section, at the same `colSpan`. The reconciliation test proves only that the key is offered somewhere on the form; it reads neither the control nor the position, so this pin covers what it misses. No test title or string carries a tracker id. - **Two measured counts in `platform-objects` were re-taken**, both forced by the new row: - the object form's open-section leaf population: 114 → 116 (`object-collapsed-sections-echo-decisions.test.ts`); - the catalog-wide translated-label control: 659 → 660 per locale (`object-lifecycle-panel-echo-decisions.test.ts`). The `openEchoes` reading stays at zero. - **Changeset**: `@objectstack/spec` patch and `@objectstack/platform-objects` patch (below). ⛔ No Zod change, no liveness change, no new gate or ledger class. ## The reconciliation test: predicted, then measured Predicted before writing: red without the form row, green with it. Measured on the committed state, with `scripts/ablation-replace.mjs` in wrap mode and a `trap` restore proven by blob equal to HEAD: | state | reading | |---|---| | row deleted, form row present (this PR, `c730a8c598`) | 2 files, **87 passed (87)** | | row deleted, form row removed (anchor 1 → 0, blob `d867e358e5e5` → `a67cdaed2b22`) | **3 failed / 84 passed**: `object.(root): accepted by the Zod but unauthorable in the form … expected [ 'imageField' ] to deeply equal []`, plus the two new pin tests. Restored: blob `d867e358e5e5` == HEAD, `git diff HEAD` empty | | control: the stale row planted back beside the form row | **1 failed / 75 passed**: `object.(root).imageField: the form offers it now — drop the ledger entry`. Restored: blob `2a7c49dca958` == HEAD | The first attempt at the control leg was a no-op. The tool refused it before running anything, because the replacement text contained its own anchor. It was redone with a non-overlapping replacement, which is the reading above. ## What ships Each package was built and its `dist/` grepped, with a positive control: - `@objectstack/spec`: the new helpText is in 8 dist files; the control, `nameField`'s helpText, is in 8. - `@objectstack/platform-objects`: the new en helpText is in 6 dist files, the control in 6. The zh-CN, ja-JP and es-ES labels are in 6 each, matched on literal or unicode-escaped text. Both packages publish the change, so both get `patch`. `skip-changeset` does not apply. ## Verification (final head `9f6b177875`) - `pnpm --filter @objectstack/spec exec vitest run --project local --maxWorkers=2`: Test Files 614 passed | 1 skipped (615), Tests 18385 passed | 1 skipped | 1 todo. The skipped file is `scripts/root-entry-type-nameability.pin.test.ts`, which is gated by `OS_EXPECT_ROOT_NAMEABILITY`. - `pnpm --filter @objectstack/spec run typecheck`: exit 0. `tsc -p tsconfig.test.json --listFilesOnly` compiles `object.form.ts` and both changed spec tests. - `pnpm --filter @objectstack/platform-objects exec vitest run --maxWorkers=2`: 59 files, **949 passed**. Run before the two counts were re-taken, it gave exactly two failures: `expected 116 to be 114` and `zh-CN positive control: expected 660 to be 659`. Typecheck: exit 0. - `metadata-forms-vocabulary.test.ts`: 5 passed. `pnpm check:i18n`: exit 0, `platform-objects in sync (11 bundle(s))`. `pnpm check:i18n-coverage`: exit 0, `13 config(s), 621 baselined untranslated string(s), none new`. - `pnpm --filter @objectstack/spec run check:generated`: exit 0 (15 artifacts). - Gates: `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 87 commands from 10 paths against the merge base `8832655af`. All 87 were run on `9f6b177875`. `--ran` reconciliation: `87 derived famil(ies) accounted for — 87 run, 0 NOT-MEASURED`. Four spec gates first exited 3 (`PREREQUISITE NOT MET`: the dist predated a test-title edit). They exited 0 after `pnpm --filter @objectstack/spec build`, and the record keeps that last reading. - eslint `--no-inline-config --format json` on the 9 changed TS files: 9 files, 0 errors, 0 warnings. The population is `eslint.config.mjs:971` (`**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs}`). Linting is not type-aware (no `parserOptions.project`), so untouched files' verdicts cannot move. ## Acceptance notes - **Surface, stated.** The claim named `object.form.ts`, the reconciliation test, the four catalogs and a changeset. Three more files changed: - `metadata-form-declared-rows.pin.test.ts` holds the pin the dispatch asked for (item 4). - The two `platform-objects` echo-decision tests carry the counts the row moves. Those count lines are the ones PR #19673 moved when it added rows to the same section. A reviewer who reads them as outside the surface can say so. - No `platform-objects` source or object file moved, and no other catalog key changed. - The authored leaves live in the generated catalogs themselves, so no further file joins the surface for them. - `origin/main` gained one commit after the branch point (`2799155678`, test titles in `packages/spec/src/data/`). It touches none of this PR's paths. --- _Generated by [Claude Code](https://claude.ai/code/session_01T9u38rswFp5Rw8DswRUReJ)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 07e933b commit 2df3d13

10 files changed

Lines changed: 97 additions & 9 deletions
Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,14 @@
1+
---
2+
'@objectstack/spec': patch
3+
'@objectstack/platform-objects': patch
4+
---
5+
6+
Studio's object form offers `imageField`, the record's picture, as a text row beside `nameField`
7+
8+
Clause-②: no
9+
10+
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.
11+
12+
`@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`.
13+
14+
No schema key, accept set, refusal, error code or status changes, and you have nothing to re-author.

‎packages/platform-objects/src/apps/translations/en.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
6060
label: "Name Field",
6161
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."
6262
},
63+
imageField: {
64+
label: "Image Field",
65+
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."
66+
},
6367
isSystem: {
6468
label: "Is System",
6569
helpText: "System object (protected from deletion; defaults sharing to public)"

‎packages/platform-objects/src/apps/translations/es-ES.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
6060
label: "Campo de título",
6161
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."
6262
},
63+
imageField: {
64+
label: "Campo de imagen",
65+
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."
66+
},
6367
isSystem: {
6468
label: "Integrado del sistema",
6569
helpText: "Objeto de sistema (protegido contra eliminación; el uso compartido predeterminado es público)"

‎packages/platform-objects/src/apps/translations/ja-JP.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
6060
label: "レコードタイトル項目",
6161
helpText: "各レコードのタイトルに使う項目(例: \"name\"、\"subject\")。ADR-0079 の正規ポインタで、レコード表示・ObjectQL 検索・関連レコードのプレビューが参照します。"
6262
},
63+
imageField: {
64+
label: "レコード画像項目",
65+
helpText: "各レコードの画像に使う項目で、レコードページのヘッダーでタイトルの横に表示されます。このオブジェクトの image 型または avatar 型の項目を指定してください。それ以外の名前はオブジェクトの保存時に拒否されます。空欄の場合、レコード画像は表示されず、プレースホルダーも描画されません。"
66+
},
6367
isSystem: {
6468
label: "システム組み込み",
6569
helpText: "システムオブジェクト(削除から保護。共有の既定は公開)"

‎packages/platform-objects/src/apps/translations/object-collapsed-sections-echo-decisions.test.ts‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -938,7 +938,10 @@ describe('#19403 round 8 — the population, DERIVED from the form and a shape',
938938
// 114 since #19332 flight G2a gave `basics` the `fieldGroups` repeater — the
939939
// row and its six declared sub-rows, fourteen leaves, their forty-two
940940
// translated leaves authored by that flight, so `openEchoes` stays at zero.
941-
expect(OPEN_LEAVES.length).toBe(114);
941+
// 116 since #21765 gave `basics` the `imageField` row beside `nameField` —
942+
// the record-picture pointer, now live — whose label and help text are
943+
// authored in all three locales by that card, so `openEchoes` stays at zero.
944+
expect(OPEN_LEAVES.length).toBe(116);
942945
expect(PANEL_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(false);
943946
expect(OPEN_LEAVES.some((l) => l.path === 'fields.placeholder')).toBe(true);
944947
for (const path of ['name', 'label', 'fields', 'fields.valueDomain', 'fields.deleteBehavior', 'fields.expression']) {

‎packages/platform-objects/src/apps/translations/object-lifecycle-panel-echo-decisions.test.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1167,7 +1167,9 @@ describe('#19403 round 10 — the verdicts, on the live bundles', () => {
11671167
// 659 since #21320: the agent form's `lifecycle` row left with its key
11681168
// (the conversation state machine was retired — nothing ever read it),
11691169
// taking its label — authored in all three locales — out of the catalog.
1170-
expect(translated.length, `${locale} positive control`).toBe(659);
1170+
// 660 since #21765: the object form offers `imageField` beside
1171+
// `nameField` — one new row label, authored in all three locales.
1172+
expect(translated.length, `${locale} positive control`).toBe(660);
11711173
}
11721174
// ⭐ DARK — the blindness, executable. On a synthetic two-locale catalog the
11731175
// all-three predicate returns 0 while the per-locale one returns 1, so the

‎packages/platform-objects/src/apps/translations/zh-CN.metadata-forms.generated.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -60,6 +60,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
6060
label: "记录标题字段",
6161
helpText: "用作每条记录标题的字段(例如 \"name\"、\"subject\")。ADR-0079 规定的规范指针——记录展示、ObjectQL 搜索和关联记录预览都读它。"
6262
},
63+
imageField: {
64+
label: "记录图片字段",
65+
helpText: "用作每条记录图片的字段,显示在记录页头的标题旁。必须是本对象中类型为 image 或 avatar 的字段,其他名称会在保存对象时被拒绝。留空则不显示记录图片,也不画占位图。"
66+
},
6367
isSystem: {
6468
label: "系统内置",
6569
helpText: "系统对象(受保护,不可删除;共享默认为公开)"

‎packages/spec/src/data/object.form.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,16 @@ export const objectForm = defineForm({
4747
// takes its candidates from a `dependsOn` source row, which a top-level
4848
// object row has nothing to point at).
4949
{ 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.' },
50+
// #21765 (director ruling 5989738766, letter A) — `imageField`, the
51+
// record's picture (#21182 ruling A), is the sibling pointer of
52+
// `nameField` and gets the same face, for the same reason: it names one
53+
// of THIS object's own fields and the registry has no own-field picker.
54+
// No validator here either. The parse (`refuseNonPictureImageField` in
55+
// `object.zod.ts`) is the one judge of the value, and its refusal at
56+
// save is what an author sees for a name that is not an `image` /
57+
// `avatar` field of this object. A cleared input writes no key, which
58+
// the parse reads as no picture.
59+
{ 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.' },
5060
{ field: 'isSystem', type: 'boolean', colSpan: 1, helpText: 'System object (protected from deletion; defaults sharing to public)' },
5161
// #20349 — the object's two own field-name LISTS, beside the `nameField`
5262
// pointer. Free text, the `view.form.ts` `searchableFields` row's face:

‎packages/spec/src/system/metadata-form-declared-rows.pin.test.ts‎

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -48,6 +48,7 @@ type FormEntry = {
4848
field?: string;
4949
widget?: string;
5050
type?: string;
51+
colSpan?: number;
5152
language?: string;
5253
helpText?: string;
5354
visibleWhen?: string | { dialect?: string; source?: string };
@@ -170,6 +171,55 @@ describe('declared keys that now have a form row (#19085)', () => {
170171
});
171172
});
172173

174+
/**
175+
* **`object.imageField` is offered with `nameField`'s face, beside it** (#21765,
176+
* director ruling 5989738766, letter A).
177+
*
178+
* The record's picture pointer is `nameField`'s sibling: both name one of the
179+
* object's own fields, and the registry has no own-field picker. So the ruling
180+
* gives it the same plain `text` row, with no widget and no validator of its
181+
* own — the parse (`refuseNonPictureImageField`) stays the one judge of the
182+
* value. The reconciliation gate only proves the key is offered somewhere on
183+
* the form; it does not read the row's control or where it sits, so this pin
184+
* keeps both.
185+
*/
186+
describe('object.imageField is offered as a plain text row beside nameField, with the parse as the one judge of its value', () => {
187+
/** The section holding `key` on the object form, and the key's index in it. */
188+
const locate = (key: string) => {
189+
const sections = ((METADATA_FORM_REGISTRY.object as any)?.sections ?? []) as Array<{
190+
name?: string;
191+
fields?: FormEntry[];
192+
}>;
193+
for (const section of sections) {
194+
const index = (section.fields ?? []).findIndex((r) => r?.field === key);
195+
if (index >= 0) return { section: section.name, index, rows: section.fields! };
196+
}
197+
return undefined;
198+
};
199+
200+
it('CONTROL: the locator finds nameField and reports a known absence', () => {
201+
expect(locate('nameField'), 'lit control: the ADR-0079 title pointer row').toBeDefined();
202+
expect(locate('noSuchKeyAtAll'), 'dark control: a key the object form does not offer').toBeUndefined();
203+
});
204+
205+
it('is offered once, as a `text` row with no widget', () => {
206+
const [row, ...extra] = rowFor('object', 'imageField');
207+
expect(row, '`imageField` is declared by ObjectSchema and live; the object form must offer it').toBeDefined();
208+
expect(extra, 'one row only').toEqual([]);
209+
expect(row.type).toBe('text');
210+
expect(row.widget, 'no picker: the parse is the one judge of the value').toBeUndefined();
211+
expect(row.helpText?.length ?? 0).toBeGreaterThan(20);
212+
});
213+
214+
it('sits directly after nameField, in the same section and at the same width', () => {
215+
const name = locate('nameField')!;
216+
const image = locate('imageField');
217+
expect(image?.section).toBe(name.section);
218+
expect(image?.index).toBe(name.index + 1);
219+
expect(image!.rows[image!.index].colSpan).toBe(name.rows[name.index].colSpan);
220+
});
221+
});
222+
173223
/**
174224
* **A `type: 'code'` row's `language` must name the slot it actually edits**
175225
* (#20439).

‎packages/spec/src/system/metadata-form-zod-reconciliation.test.ts‎

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -391,13 +391,6 @@ const LEDGER: ReadonlyArray<OmitEntry | SubsetEntry> = [
391391
key: 'externalSharingModel',
392392
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',
393393
},
394-
{
395-
kind: 'omit',
396-
type: 'object',
397-
path: ROOT_PATH,
398-
key: 'imageField',
399-
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',
400-
},
401394
{
402395
kind: 'omit',
403396
type: 'field',

0 commit comments

Comments
 (0)