Skip to content

Commit e808890

Browse files
claude[bot]os-zhuangclaude
authored
fix(spec): withdraw the offered-but-refused icon input from the object.form options repeater (#14326)
* fix(spec): withdraw the `icon` input from the object.form options repeater The fields repeater's `options` sub-form offered an `icon` text input ("Lucide icon name") that `SelectOptionSchema` refuses at publish — the shape is strict and has never declared `icon`, so an author who used the input learned about it at the 422. Offer and door disagreed. Resolved under ADR-0049 enforce-or-remove on the remove route: the offer is withdrawn, the door is unchanged (`icon` was refused before this change and is refused after it — no accept/reject behaviour moves). The route rests on a premise measured for the FIELD-option surface rather than inherited from #5016's ACTION-param reading: objectui declares `icon` on `SelectOptionMetadata`, but no field-option render path reads it. Measured on objectui 67dadd60 with a live positive control — `packages/fields/src/index.tsx` reads `option?.label` and `option?.color` off the same `SelectOptionMetadata[]` and never `option?.icon`. The #13669 pin moves in the same stroke instead of being deleted: its `icon` control kept asserting only the door, which is how the offer-side gap stayed green. It now asserts both halves, with the subject change written out above the new section. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 * chore(spec): changeset for the withdrawn option `icon` input, and a truer pin title The "leaves the OBJECT-level `icon` row alone" case asserts there is exactly ONE `icon` input in the whole object form, so its title now says that: it is a count, and it therefore also reds if the withdrawn row comes back. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 * docs(spec): anchor the objectui reader measurement to the pin, in the gate's spelling `check:objectui-pin-citations` only sees a citation written as one of its two spellings, and an unrecognised one is silent — so the read-point record now uses the HISTORICAL form (`.objectui-sha` pin `<sha>`), which is what it is: where the measurement was taken, not a claim about today's pin. Both citations are now in the gate's population (13 historical -> 15). The measurement was also repeated at the pin this repo actually ships (d8ec8d6d4) as well as objectui `origin/main` (67dadd602) — same answer, same positive control, both trees. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 * fix(i18n): regenerate platform-objects bundles after option icon row removal Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01GDA48PuRFrHyRfdkBz8m21 --------- Co-authored-by: os-zhuang <jack@objectstack.ai> Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 1dcb995 commit e808890

7 files changed

Lines changed: 207 additions & 23 deletions

File tree

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,43 @@
1+
---
2+
"@objectstack/spec": patch
3+
---
4+
5+
fix(spec): withdraw the `icon` input from the `object.form` options repeater (#13671)
6+
7+
The field designer's `options` sub-form offered five inputs per select option,
8+
and one of them named a key the publish door refuses. `SelectOptionSchema` is
9+
strict and has never declared `icon`, so a Lucide name typed into that input
10+
came back as an `unrecognized_keys` refusal at publish — the author found out
11+
at the 422. Offer and door disagreed: the same class #11410 (a form offering a
12+
`deleteBehavior` the schema refuses) and #12868 (the form-face option
13+
narrowing) retired elsewhere, one key over.
14+
15+
Resolved under **ADR-0049 enforce-or-remove on the remove route**: the offer is
16+
withdrawn so that declared = offered converges downward. ⚠️ **No accept/reject
17+
behaviour moves**`icon` on a select option was refused before this change
18+
and is refused after it. Metadata that parses today parses identically; the
19+
only thing that changes is what the Studio object designer teaches an author to
20+
write. The repeater now offers `label`, `value`, `color` and `description`
21+
exactly `SelectOptionSchema`'s authorable keys minus `visibleWhen`, which is a
22+
CEL predicate rather than a repeater text input.
23+
24+
**Why remove rather than declare.** The route rests on a premise measured for
25+
the FIELD-option surface rather than inherited from #5016, which measured the
26+
ACTION-param path — objectui's `SelectOptionMetadata` does declare `icon`, so
27+
the two faces had to be measured apart. Measured twice with a live positive
28+
control, same answer both times: at the objectui pin this repo ships
29+
(`.objectui-sha` = `d8ec8d6d4f011b11c8eb1e6dbd364ef206711391`) and again at
30+
that repo's `origin/main` (`67dadd602a3a891666ea1513c5de677140784b6a`). The
31+
select/multiselect cell renderer (`packages/fields/src/index.tsx`) reads
32+
`option?.label` and `option?.color` off a `SelectOptionMetadata[]` and never
33+
`option?.icon`, and no field-option render path in either tree reads the key at
34+
all. Declaring `icon` instead would widen
35+
the accepted set, which needs a maintainer ruling and is deliberately not taken
36+
here.
37+
38+
The #13669 pin (`field-rows-option-description.test.ts`) moves in the same
39+
stroke rather than being deleted: its `icon` case asserted only that the door
40+
refuses the key, which is how the offer-side half stayed green across a live
41+
disagreement. It now asserts both halves — the door still refuses `icon`, and
42+
the form no longer offers it — plus the general invariant that every input the
43+
repeater offers names a key the door accepts.

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,6 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
153153
"fields.options.color": {
154154
label: "Color"
155155
},
156-
"fields.options.icon": {
157-
label: "Icon",
158-
helpText: "Lucide icon name"
159-
},
160156
"fields.options.description": {
161157
label: "Description"
162158
},

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,6 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
153153
"fields.options.color": {
154154
label: "Color de opción"
155155
},
156-
"fields.options.icon": {
157-
label: "Icono",
158-
helpText: "Nombre de icono Lucide"
159-
},
160156
"fields.options.description": {
161157
label: "Descripción"
162158
},

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,6 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
153153
"fields.options.color": {
154154
label: "色"
155155
},
156-
"fields.options.icon": {
157-
label: "アイコン",
158-
helpText: "Lucide アイコン名"
159-
},
160156
"fields.options.description": {
161157
label: "説明"
162158
},

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

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -153,10 +153,6 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
153153
"fields.options.color": {
154154
label: "颜色"
155155
},
156-
"fields.options.icon": {
157-
label: "图标",
158-
helpText: "Lucide 图标名称"
159-
},
160156
"fields.options.description": {
161157
label: "描述"
162158
},

packages/spec/src/data/field-rows-option-description.test.ts

Lines changed: 137 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -27,11 +27,20 @@
2727
* The ruling's capability expansion STOPS at these keys: the four inert
2828
* rich-text editor keys (`toolbar`/`preview`/`minHeight`/`maxHeight`) stay
2929
* undeclared — the control pins below hold that door shut.
30+
*
31+
* [#13671] Section 3 was added later and is about the OTHER half of the same
32+
* offer-vs-door pair this file already documents. The `icon` control in
33+
* section 2 used to assert one thing — the door refuses `icon` — while
34+
* `object.form.ts` went on OFFERING an `icon` input, so the file was green
35+
* across a live disagreement. Section 3 pins the offer side; the file keeps
36+
* its name because the two subjects are one story (`options[]` keys the
37+
* authoring form and the publish door must agree about).
3038
*/
3139

3240
import { describe, it, expect } from 'vitest';
3341

3442
import { FieldSchema, SelectOptionSchema } from './field.zod';
43+
import { objectForm } from './object.form';
3544
import { FormSelectOptionSchema } from '../ui/view.zod';
3645

3746
// =========================================================================
@@ -187,12 +196,14 @@ describe('SelectOptionSchema accepts `description` (objectui#6153, inherited rul
187196
});
188197

189198
it('the neighbouring undeclared option keys are still refused — `icon` (control) and `dependsOn` (explicitly not licensed)', () => {
190-
// `icon`: offered by the object.form options repeater and declared by
191-
// objectui's SelectOptionMetadata, but #5016's option C left it out of
192-
// this shape — it stays an unrecognized_keys refusal until someone rules
193-
// otherwise. `dependsOn`: the inherited #6153 ruling resolves it
194-
// objectui-side (the widget reads the canonical field-level `depends_on`);
195-
// declaring a camelCase twin here is explicitly not licensed.
199+
// `icon`: declared by objectui's `SelectOptionMetadata` but left out of
200+
// this shape by #5016's option C, and #13671 re-measured that reading for
201+
// the FIELD-option surface and kept it (section 3). It stays an
202+
// unrecognized_keys refusal until someone rules otherwise — what #13671
203+
// changed is the OFFER, not this door. `dependsOn`: the inherited #6153
204+
// ruling resolves it objectui-side (the widget reads the canonical
205+
// field-level `depends_on`); declaring a camelCase twin here is
206+
// explicitly not licensed.
196207
for (const [key, value] of [['icon', 'circle-dot'], ['dependsOn', 'country']] as const) {
197208
const result = SelectOptionSchema.safeParse({ label: 'Open', value: 'open', [key]: value });
198209
expect(result.success, `\`${key}\` unexpectedly parsed — an unruled accepted-set expansion`).toBe(false);
@@ -203,3 +214,123 @@ describe('SelectOptionSchema accepts `description` (objectui#6153, inherited rul
203214
}
204215
});
205216
});
217+
218+
// =========================================================================
219+
// 3. `options[].icon` — the OTHER half of the same offer-vs-door pair
220+
// =========================================================================
221+
222+
/**
223+
* ⚠️ THIS PIN CHANGED SUBJECT (#13671) — read this before "restoring" it.
224+
*
225+
* The `icon` case in section 2 used to stand alone and assert ONE thing: the
226+
* publish door refuses `icon`. That was true and is still true — but while it
227+
* held, `object.form.ts`'s options repeater went on OFFERING an `icon` text
228+
* input labelled "Lucide icon name", so a Studio author was taught a key the
229+
* door rejects and found out at the 422. The pin was green across a live
230+
* disagreement because it only ever looked at one side of it.
231+
*
232+
* #13671 closed the disagreement under ADR-0049 enforce-or-remove, on the
233+
* route triage ruled: **the offer was withdrawn, the door was NOT widened.**
234+
* So the pin now has two halves and needs both — section 2's `it` for the
235+
* door, this section for the offer. ⛔ Neither half alone is the pin: a green
236+
* "the door refuses `icon`" says nothing about what the form offers, which is
237+
* exactly how the gap survived.
238+
*
239+
* ## Why remove and not declare
240+
*
241+
* The premise was measured for THIS surface — a FIELD option — rather than
242+
* inherited from #5016, which measured the ACTION-param path (objectui's
243+
* `SelectOptionMetadata` does declare `icon`, so the two faces had to be
244+
* measured apart). Measured at the `.objectui-sha` pin
245+
* `d8ec8d6d4f011b11c8eb1e6dbd364ef206711391` — the console this repo ships —
246+
* and again on that repo's `origin/main`, same answer both times, with a live
247+
* positive control: the select/multiselect cell renderer
248+
* (`packages/fields/src/index.tsx`, the `renderOne` badge/dot branch) reads
249+
* `option?.label` and `option?.color` off a `SelectOptionMetadata[]` and never
250+
* `option?.icon`, and no field-option render path in that tree reads the key at
251+
* all — the single `opt.icon` read there belongs to the config-panel
252+
* `ConfigField` vocabulary, whose `icon` is a `React.ReactNode` an authored
253+
* field option cannot reach. Declaring `icon` would be an accepted-set
254+
* expansion needing a maintainer ruling; if that ruling ever comes, this
255+
* section INVERTS (offer restored, door widened, both halves moving together)
256+
* rather than being deleted.
257+
*/
258+
describe('#13671 — the object.form options repeater offers only keys the door accepts', () => {
259+
type FormSpec = Record<string, unknown>;
260+
261+
/**
262+
* Every form-field spec matching `match`, at any depth. Same traversal as
263+
* `form-delete-behavior-options.test.ts` (#11410) — sections and `fields`
264+
* arrays, which is how a repeater's sub-form hangs off its parent.
265+
*/
266+
function findSpecs(node: unknown, match: (s: FormSpec) => boolean, out: FormSpec[] = []): FormSpec[] {
267+
if (!node || typeof node !== 'object') return out;
268+
if (Array.isArray(node)) {
269+
for (const n of node) findSpecs(n, match, out);
270+
return out;
271+
}
272+
const rec = node as FormSpec;
273+
if (match(rec)) out.push(rec);
274+
for (const child of ['sections', 'fields'] as const) {
275+
if (rec[child]) findSpecs(rec[child], match, out);
276+
}
277+
return out;
278+
}
279+
280+
/** The one `options` repeater, asserted unique so a second one cannot hide. */
281+
function optionsRepeater(): FormSpec {
282+
const hits = findSpecs(objectForm, (s) => s.field === 'options' && s.type === 'repeater');
283+
expect(hits, 'the object form no longer declares exactly one `options` repeater').toHaveLength(1);
284+
return hits[0];
285+
}
286+
287+
/** The input keys that repeater offers per option. */
288+
function offeredOptionKeys(): string[] {
289+
const sub = optionsRepeater().fields;
290+
// An ABSENT list is not a narrower offer — it is the renderer's DERIVED
291+
// source (the #11410 lesson): with no `fields` the metadata-admin form
292+
// falls through to the JSON Schema, so "no list" would silently re-offer
293+
// whatever the schema exposes. The list must be explicit.
294+
expect(Array.isArray(sub) && sub.length > 0, 'the options repeater declares no explicit sub-form field list').toBe(true);
295+
return (sub as FormSpec[]).map((f) => String(f.field));
296+
}
297+
298+
it('does not offer `icon` — the withdrawn input (THE DEFECT)', () => {
299+
expect(offeredOptionKeys()).not.toContain('icon');
300+
});
301+
302+
it('still offers the four inputs that survived, in order', () => {
303+
// The positive control for the assertion above: an empty or mangled list
304+
// would satisfy `not.toContain('icon')` while having removed the whole
305+
// repeater. `description` is here because PR #13669 declared it — ⛔ do
306+
// not drop it while editing this list.
307+
expect(offeredOptionKeys()).toEqual(['label', 'value', 'color', 'description']);
308+
});
309+
310+
it('offers no input naming a key the publish door refuses — the general invariant', () => {
311+
// The class, not the instance: whatever this repeater offers tomorrow, the
312+
// door must accept it. Asserted through the door's own behaviour rather
313+
// than a hand-copied key list, so it cannot drift from the schema.
314+
for (const key of offeredOptionKeys()) {
315+
const result = SelectOptionSchema.safeParse({ label: 'Open', value: 'open', [key]: 'probe' });
316+
const unrecognized = result.success
317+
? undefined
318+
: result.error.issues.find((i) => i.code === 'unrecognized_keys' && JSON.stringify(i).includes(key));
319+
expect(
320+
unrecognized,
321+
`the options repeater offers an input for \`${key}\`, which \`SelectOptionSchema\` refuses at publish `
322+
+ '— offer and door disagree again (ADR-0049 enforce-or-remove)',
323+
).toBeUndefined();
324+
}
325+
});
326+
327+
it('declares exactly one `icon` input in total — the OBJECT-level row, a different subject', () => {
328+
// The object form declares two `icon` inputs in total before #13671 and
329+
// one after. The survivor is the object's own Lucide icon in Basics, which
330+
// `ObjectSchema` declares and Studio renders; an over-broad deletion that
331+
// took it out would otherwise look like a pass above.
332+
const iconRows = findSpecs(objectForm, (s) => s.field === 'icon');
333+
expect(iconRows).toHaveLength(1);
334+
expect(String(iconRows[0].helpText ?? '')).toContain('Lucide icon name');
335+
});
336+
});

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

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,33 @@ export const objectForm = defineForm({
147147
{ field: 'scale', type: 'number', helpText: 'Decimal places', visibleWhen: "data.type in ['number','currency','percent']" },
148148

149149
// Selection options
150+
//
151+
// The offered inputs are exactly `SelectOptionSchema`'s authorable
152+
// keys, minus `visibleWhen` (a CEL predicate, not a repeater text
153+
// input). An `icon` input used to sit between `color` and
154+
// `description` and was withdrawn under ADR-0049 enforce-or-remove:
155+
// the option shape is strict and has never declared `icon`, so a
156+
// Lucide name typed there was an `unrecognized_keys` refusal at
157+
// publish — the author found out at the 422, the same
158+
// offer-vs-door class #11410 and #12868 retired elsewhere.
159+
//
160+
// Remove rather than declare, on a premise measured for THIS
161+
// surface rather than inherited from #5016's action-param reading:
162+
// objectui declares `icon` on `SelectOptionMetadata`, but no
163+
// field-option render path READS it. Measured at the
164+
// `.objectui-sha` pin `d8ec8d6d4f011b11c8eb1e6dbd364ef206711391`
165+
// (the console this repo ships) and again on that repo's
166+
// `origin/main`, same answer both times, with a live positive
167+
// control: the select/multiselect cell renderer
168+
// (`packages/fields/src/index.tsx`, the `renderOne` badge/dot
169+
// branch) reads `option?.label` and `option?.color` off that very
170+
// `SelectOptionMetadata[]` and never `option?.icon`. The only
171+
// `opt.icon` read in that tree belongs to the config-panel
172+
// `ConfigField` vocabulary, whose `icon` is a `React.ReactNode` an
173+
// authored field option cannot reach. Declaring it instead would
174+
// be an accepted-set expansion, which needs a maintainer ruling.
175+
// `field-rows-option-description.test.ts` pins both halves — the
176+
// schema still refuses `icon`, and this list no longer offers it.
150177
{
151178
field: 'options',
152179
type: 'repeater',
@@ -156,7 +183,6 @@ export const objectForm = defineForm({
156183
{ field: 'label', type: 'text', required: true },
157184
{ field: 'value', type: 'text', required: true },
158185
{ field: 'color', type: 'color' },
159-
{ field: 'icon', type: 'text', helpText: 'Lucide icon name' },
160186
{ field: 'description', type: 'text' },
161187
],
162188
},

0 commit comments

Comments
 (0)