Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 38 additions & 0 deletions .changeset/fieldschema-placeholder-declared.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
---
"@objectstack/spec": minor
---

feat(spec): `placeholder` becomes a declared `FieldSchema` key — the producer moves to meet four shipped objectui render surfaces (#9019, maintainer Option C ruling on objectui#4676)

<!-- adr-0087: not-required (no-migration-prescription) Accept-set EXPANSION on
an existing authorable surface: one optional string key added to FieldSchema,
plus the removal of the FIELD_KEY_GUIDANCE retirement entry that used to refuse
it by name. Nothing authorable is renamed, retired or tombstoned — metadata
that parsed yesterday parses identically today, and metadata that was 422'd
yesterday (a field carrying `placeholder`) now parses and round-trips. -->

`FieldSchema` refused `placeholder` by name ("never a FieldSchema key. Author
hint text through `inlineHelpText` or `description`.") while four objectui
packages plus `apps/console` — plugin-form's auto-generated and sectioned
forms, plugin-detail's inline edit, app-shell's field-backed action params
(whose module header documents the inheritance as intended), and console's
FormPage — apply an object-field-level `placeholder` at render time, feeding
the `@object-ui/fields` widgets. That was the preview-renders/save-422s trap:
the designer preview rendered the key, `PUT /api/v1/meta/object/:name`
refused it.

Per the 2026-08-16 maintainer ruling (Option C on objectui#4676, measured in
its report comment 5301288148):

- `placeholder` is now a declared optional string key on `FieldSchema`, with
the semantics the renderers already implement: in-input placeholder text
(the HTML `placeholder` attribute), distinct from `inlineHelpText`
(always-visible help beside/under the input) and `description` (tooltip).
- The `FIELD_KEY_GUIDANCE` retirement entry steering authors away from the key
is removed — after this change that prose would contradict the contract.
- The Studio metadata forms (`object.form.ts` quick-add grid, `field.form.ts`
full editor) offer the key, and the liveness ledger carries a `live` verdict
with the measured cross-repo evidence.

The matching translation surface (`FieldTranslation.placeholder`) was already
declared, so a translated placeholder now has a declared base key to land on.
1 change: 1 addition & 0 deletions content/docs/references/data/field.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -114,6 +114,7 @@ const result = CurrencyConfigSchema.parse(data);
| **system** | `boolean` | optional | Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag. |
| **sortable** | `boolean` | optional (default: `true`) | Whether field is sortable in list views |
| **inlineHelpText** | `string` | optional | Help text displayed below the field in forms |
| **placeholder** | `string` | optional | Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation). |
| **autonumberFormat** | `string` | optional (default: `"{0000}"`) | Auto-number format: literal text + `{0000}` counter, `{YYYY}`/`{MM}`/`{DD}`/`{YYYYMMDD}` date tokens (business tz), and `{field_name}` interpolation. Counter resets per rendered prefix (e.g. AD`{YYYYMMDD}``{0000}` resets daily). Omitted on an `autonumber` field ⇒ the contract default `{0000}` (#6555). |
| **externalId** | `boolean` | optional (default: `false`) | Is external ID for upsert operations |
| **_lock** | `Enum<'none' \| 'no-overlay' \| 'no-delete' \| 'full'>` | optional | Item-level lock — controls overlay & delete (ADR-0010). |
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Default Value",
helpText: "Default value for new records (JSON literal)"
},
"fields.placeholder": {
label: "Placeholder",
helpText: "Hint text shown inside the empty input; disappears once a value is entered"
},
"fields.maxLength": {
label: "Max Length",
helpText: "Max characters"
Expand DownExpand Up@@ -358,6 +362,10 @@ export const enMetadataForms: NonNullable<TranslationData['metadataForms']> = {
label: "Default Value",
helpText: "Default value for new records"
},
placeholder: {
label: "Placeholder",
helpText: "Hint text shown inside the empty input (disappears once a value is entered); use inlineHelpText for always-visible help"
},
minLength: {
label: "Min Length",
helpText: "Minimum character length"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "Valor predeterminado",
helpText: "Valor predeterminado para registros nuevos (literal JSON)"
},
"fields.placeholder": {
label: "Placeholder",
helpText: "Hint text shown inside the empty input; disappears once a value is entered"
},
"fields.maxLength": {
label: "Longitud máxima",
helpText: "Máximo de caracteres"
Expand DownExpand Up@@ -358,6 +362,10 @@ export const esESMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "Valor predeterminado",
helpText: "Valor predeterminado para registros nuevos"
},
placeholder: {
label: "Placeholder",
helpText: "Hint text shown inside the empty input (disappears once a value is entered); use inlineHelpText for always-visible help"
},
minLength: {
label: "Longitud mínima",
helpText: "Longitud mínima de caracteres"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "既定値",
helpText: "新規レコードの既定値(JSON リテラル)"
},
"fields.placeholder": {
label: "Placeholder",
helpText: "Hint text shown inside the empty input; disappears once a value is entered"
},
"fields.maxLength": {
label: "最大長",
helpText: "最大文字数"
Expand DownExpand Up@@ -358,6 +362,10 @@ export const jaJPMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "既定値",
helpText: "新規レコードの既定値"
},
placeholder: {
label: "Placeholder",
helpText: "Hint text shown inside the empty input (disappears once a value is entered); use inlineHelpText for always-visible help"
},
minLength: {
label: "最小長",
helpText: "最小文字数"
Expand Down
Original file line numberDiff line numberDiff line change
Expand Up@@ -98,6 +98,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "默认值",
helpText: "新记录的默认值(JSON 字面量)"
},
"fields.placeholder": {
label: "Placeholder",
helpText: "Hint text shown inside the empty input; disappears once a value is entered"
},
"fields.maxLength": {
label: "最大长度",
helpText: "最大字符数"
Expand DownExpand Up@@ -358,6 +362,10 @@ export const zhCNMetadataForms: NonNullable<TranslationData['metadataForms']> =
label: "默认值",
helpText: "新建记录时的默认值"
},
placeholder: {
label: "Placeholder",
helpText: "Hint text shown inside the empty input (disappears once a value is entered); use inlineHelpText for always-visible help"
},
minLength: {
label: "最小长度",
helpText: "最少字符数"
Expand Down
1 change: 1 addition & 0 deletions packages/spec/authorable-surface/data.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -374,6 +374,7 @@
"data/Field:multiple",
"data/Field:name",
"data/Field:options",
"data/Field:placeholder",
"data/Field:precision",
"data/Field:readonly",
"data/Field:readonlyWhen",
Expand Down
7 changes: 7 additions & 0 deletions packages/spec/liveness/field.json
Original file line numberDiff line numberDiff line change
Expand Up@@ -156,6 +156,13 @@
"status": "live",
"note": "display help (renderer)."
},
"placeholder": {
"status": "live",
"verifiedAt": "2026-08-16",
"evidenceScope": "cross-repo",
"evidence": "objectui: packages/plugin-form ObjectForm.tsx:601 (auto form), sectionFields.ts:122 (fromObjectSchema), ModalForm.tsx:425, DrawerForm.tsx:350; objectui: packages/plugin-detail RecordDetailDrawer.tsx:253 (inline edit); objectui: packages/app-shell resolveActionParams.ts:512 (field-backed action params inherit placeholder — documented as intended in that module's header); objectui: apps/console FormPage.tsx:343 (override.placeholder ?? def.placeholder, rendered at :830); underneath, the @object-ui/fields widgets read field.placeholder off the metadata carrier — measured objectui @9ce096fb0 with sensitivity controls (objectui#4676 report comment 5301288148)",
"note": "In-input placeholder (HTML placeholder attribute). Declared 2026-08-16 (#9019, maintainer Option C ruling on objectui#4676): the consumers shipped first; the declaration was the missing half. Distinct from inlineHelpText (always-visible below-field help) and description (tooltip)."
},
"unique": {
"status": "live",
"evidence": "packages/drivers/driver-sql/src/sql-driver.ts:1853",
Expand Down
4 changes: 2 additions & 2 deletions packages/spec/liveness/state-counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -28,7 +28,7 @@ for both corollaries.
| Type | live | exp | dead | planned | classified |
|---|---|---|---|---|---|
| `object` | 50 | 0 | 0 | 1 | 51 |
| `field` | 67 | 0 | 0 | 2 | 69 |
| `field` | 68 | 0 | 0 | 2 | 70 |
| `flow` | 34 | 0 | 6 | 0 | 40 |
| `action` | 42 | 0 | 2 | 0 | 44 |
| `hook` | 18 | 0 | 2 | 0 | 20 |
Expand DownExpand Up@@ -57,4 +57,4 @@ for both corollaries.
| `api` | 25 | 0 | 0 | 2 | 27 |
| `capability` | 12 | 0 | 0 | 0 | 12 |
| `qa` | 4 | 0 | 5 | 0 | 9 |
| **total** | **775** | **6** | **55** | **9** | **845** |
| **total** | **776** | **6** | **55** | **9** | **846** |
8 changes: 7 additions & 1 deletion packages/spec/src/data/authoring-key-lint.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -107,7 +107,13 @@ export const FIELD_KEY_GUIDANCE: Readonly<
indexed: { why: 'never a FieldSchema key; a field-level index flag built no index (#2377). Declare the index in the object\'s `indexes[]`.' },
immutable: { why: 'never a FieldSchema key. Use the `readonlyWhen` predicate to lock a field after creation.' },
filterable: { why: 'never a FieldSchema key — every declared column is filterable. `sortable` and `searchable` are the real knobs.' },
placeholder: { why: 'never a FieldSchema key. Author hint text through `inlineHelpText` or `description`.' },
// NOTE: no entry for `placeholder`. It sat here as a retirement ("never a
// FieldSchema key") until the 2026-08-16 maintainer ruling on objectui#4676
// (#9019) declared it: four shipped objectui render surfaces apply an
// object-field-level `placeholder` at render time, so `FieldSchema` now
// declares the key and an entry here would be advice to delete a live key —
// the "no guidance entry names a key the schema now declares" test enforces
// the absence.
startingNumber: { why: 'never a FieldSchema key. An autonumber counter resets per rendered prefix, which `autonumberFormat` itself determines.' },
validation: { why: 'field-level predicates are not a FieldSchema key — author a `validation` metadata item on the object, which carries its own message.' },
errorMessage: { why: 'pairs with the `validation` key that never existed; a `validation` metadata item carries its own message.' },
Expand Down
1 change: 1 addition & 0 deletions packages/spec/src/data/field.form.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -33,6 +33,7 @@ export const fieldForm = defineForm({
description: 'Field-type specific settings (visible blocks depend on the chosen type).',
fields: [
{ field: 'defaultValue', helpText: 'Default value for new records' },
{ field: 'placeholder', helpText: 'Hint text shown inside the empty input (disappears once a value is entered); use inlineHelpText for always-visible help' },
// Text field options
{ field: 'minLength', visibleWhen: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Minimum character length' },
{ field: 'maxLength', visibleWhen: "data.type == 'text' || data.type == 'textarea' || data.type == 'email'", helpText: 'Maximum character length' },
Expand Down
73 changes: 73 additions & 0 deletions packages/spec/src/data/field.test.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1386,3 +1386,76 @@ describe('ADR-0113 — required is a write contract; storage.notNull is the colu
expect(() => FieldSchema.parse({ type: 'text', storage: { collation: 'C' } })).toThrow();
});
});

describe('FieldSchema — `placeholder` is a DECLARED key (#9019, ruled Option C on objectui#4676)', () => {
// The reverse of the pre-#9019 posture: `placeholder` used to be refused by
// name via FIELD_KEY_GUIDANCE ("never a FieldSchema key. Author hint text
// through `inlineHelpText` or `description`."). The 2026-08-16 maintainer
// ruling flipped the producer: four shipped objectui render surfaces apply
// an object-field-level placeholder at render time (plugin-form,
// plugin-detail, app-shell resolveActionParams, apps/console FormPage), so
// the declaration was the missing half. These are the accept-side pins.

it('parses and round-trips on a minimal field', () => {
const f = FieldSchema.parse({ name: 'nickname', label: 'Nickname', type: 'text', placeholder: 'Enter a nickname' });
expect(f.placeholder).toBe('Enter a nickname');
});

it('coexists with BOTH hint siblings — three distinct surfaces, none aliased onto another', () => {
const f = FieldSchema.parse({
type: 'text',
placeholder: 'in-input hint',
inlineHelpText: 'always-visible help under the input',
description: 'tooltip/developer documentation',
});
expect(f.placeholder).toBe('in-input hint');
expect(f.inlineHelpText).toBe('always-visible help under the input');
expect(f.description).toBe('tooltip/developer documentation');
});

it('is optional — absence stays absent (no default materializes)', () => {
const f = FieldSchema.parse({ type: 'text' });
expect('placeholder' in f).toBe(false);
});

it('rejects a non-string value', () => {
expect(() => FieldSchema.parse({ type: 'text', placeholder: 123 })).toThrow();
expect(() => FieldSchema.parse({ type: 'text', placeholder: { en: 'x' } })).toThrow();
});

it('is not type-restricted at the schema level (flat on FieldSchema, like scale/min)', () => {
expect(() => FieldSchema.parse({ type: 'textarea', placeholder: 'x' })).not.toThrow();
expect(() => FieldSchema.parse({ type: 'number', placeholder: 'x' })).not.toThrow();
});

it('does not disturb FieldSchema unknown-key strictness (#4001)', () => {
expect(() => FieldSchema.parse({
type: 'text',
placeholder: 'x',
totallyBogusKey: true,
} as unknown as Field)).toThrow(/Unrecognized key/);
});

it('the `.describe()` prose carries the three-way distinction (feeds the generated reference page)', () => {
const js = z.toJSONSchema(FieldSchema as unknown as z.ZodType, {
unrepresentable: 'any',
io: 'input',
}) as { properties?: Record<string, { type?: string; description?: string }> };
const prop = js.properties?.placeholder;
expect(prop).toBeDefined();
expect(prop!.type).toBe('string');
expect(prop!.description).toMatch(/inlineHelpText/);
expect(prop!.description).toMatch(/description/);
});

it('parses inside an object document — the PUT /meta authoring shape that used to 422', () => {
const obj = ObjectSchema.parse({
name: 'crm_lead',
label: 'Lead',
fields: {
nickname: { type: 'text', label: 'Nickname', placeholder: 'e.g. Jay' },
},
});
expect((obj.fields as Record<string, { placeholder?: string }>).nickname.placeholder).toBe('e.g. Jay');
});
});
20 changes: 20 additions & 0 deletions packages/spec/src/data/field.zod.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -1106,6 +1106,26 @@ export const FieldSchema = lazySchema(() => strictObject({
system: z.boolean().optional().describe('Auto-injected system/audit field (e.g. created_at, updated_by, organization_id). Tools that surface system fields separately from author-declared business fields should branch on this flag.'),
sortable: z.boolean().optional().default(true).describe('Whether field is sortable in list views'),
inlineHelpText: z.string().optional().describe('Help text displayed below the field in forms'),
/**
* In-input placeholder text — the HTML `placeholder` attribute on the empty
* control, gone the moment a value is typed. Declared 2026-08-16 (#9019,
* maintainer ruling Option C on objectui#4676): the consumer side shipped
* first — objectui applies an object-field-level `placeholder` at render time
* in four packages plus `apps/console` (plugin-form's auto-generated and
* sectioned forms, plugin-detail's inline edit, app-shell's field-backed
* action params — whose module header documents the inheritance as intended —
* and console's FormPage, all feeding the `@object-ui/fields` widgets), while
* this schema refused the key by name. That was the #7176 doctrine failed
* from the producer side: measured pull, missing declaration — and the
* classic preview-renders/save-422s trap for AI authors. The matching
* translation surface (`FieldTranslation.placeholder`) was already declared.
*
* The three hint surfaces are distinct and the distinction is contract:
* `placeholder` renders INSIDE the empty input; `inlineHelpText` renders
* beside/under the input and stays visible; `description` is the
* tooltip/developer documentation.
*/
placeholder: z.string().optional().describe('Placeholder text rendered inside the empty input (the HTML placeholder attribute); disappears once a value is entered. Distinct from `inlineHelpText` (always-visible help rendered beside/under the input) and `description` (tooltip/developer documentation).'),
/**
* Auto-number display format. Literal text interleaved with `{...}` tokens:
*
Expand Down
6 changes: 4 additions & 2 deletions packages/spec/src/data/object.form.ts
Original file line numberDiff line numberDiff line change
Expand Up@@ -113,8 +113,10 @@ export const objectForm = defineForm({
// `filterable` removed: never a FieldSchema key (only `sortable` and
// `searchable` exist); every declared column is filterable.
{ field: 'defaultValue', type: 'text', helpText: 'Default value for new records (JSON literal)' },
// `placeholder` removed: never a FieldSchema key. Author hint text
// through `inlineHelpText` / `description`.
// `placeholder` declared 2026-08-16 (#9019, ruled on objectui#4676):
// in-input hint text, distinct from `inlineHelpText` (always-visible
// help below the field) and `description` (tooltip).
{ field: 'placeholder', type: 'text', helpText: 'Hint text shown inside the empty input; disappears once a value is entered' },

// Text constraints
{ field: 'maxLength', type: 'number', helpText: 'Max characters', visibleWhen: "data.type in ['text','textarea','email','url','phone','password','markdown','html','richtext']" },
Expand Down
Loading