Skip to content
Merged
46 changes: 46 additions & 0 deletions .changeset/21464-component-props-objectui-held-typed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
---
'@objectstack/spec': minor
---

feat(spec)!: an `object-gantt` page block's `markers`, an `object-timeline` page block's `mapping`, and the top-level `fields` of the `object-form` and `object-master-detail-form` page blocks take the shape each block reads instead of any value (#21464)

Clause-②: yes (narrowing)

<!-- adr-0087: registered ui-object-gantt-markers-typed, ui-object-timeline-mapping-typed, ui-object-form-fields-names-typed -->

**BREAKING** — an accept-set narrowing on a published authoring surface, shipped as `minor` under the repo's launch-window convention for accept-set narrowings. What reads the rows: the component-props gate on `objectstack validate`, `objectstack build` and `objectstack lint`, which reports a refused value as an advisory `component-props-invalid` / `component-props-unknown-key` finding. A stored page still saves and loads, because a page component's `properties` is not parsed on the metadata save or load path.

**`@objectstack/spec`**

- **`object-gantt` `markers` takes `{ date, label?, color? }` entries.** Its entries were `z.unknown()`, because the marker contract lived only in objectui: a marker with no `date`, a numeric `date` or a misspelled member passed, and the chart drew no line, or drew it with no label and in the default colour. The spec now declares objectui's own authoring declaration of a marker — `date` an ISO date or date-time string, `label` the text drawn against the line, `color` any CSS colour — closed, and the row takes it. A marker `title`, `text` or `name` is pointed at `label`, and a `colour` at `color`.
- **`object-timeline` `mapping` takes `{ title?, date?, description?, variant? }`**, each a field name. It was `z.unknown()`, for the same reason: a bare field name, a non-string binding or a misspelled member (`titleField` inside `mapping`) passed, and the rail drew the default field. The spec now declares objectui's own declaration of the binding record, closed. `titleField`, `dateField` / `startDateField`, `descriptionField` and `variantField` written inside `mapping` are pointed at the member they meant.
- **`object-form` and `object-master-detail-form` `fields` take field names.** The top-level list was an array of `z.unknown()`, held while the form drew a `{ name }` entry its page-builder guide taught, with a `label`, `type` and `required` it silently dropped. objectui has since retired that entry from every authoring face (the form still draws a stored one by its name), so both rows take field-name strings, objectui's own declaration of the member. A `{ name: 'email' }` entry is refused with `write 'email'` and where a per-form override goes; a `{ field: 'email' }` entry — the `sections[].fields` vocabulary, which the form skips at the top level — is refused with the same name and that pointer.
- **Not narrowed, and still accepting any value:** the `object-metric` drill-down's `report`, `object-form` `customFields`, both forms' `sections`, `object-timeline` `items` and the members of `action:group` / `action:menu`. Each contract still lives in objectui and has more than one viable spec shape that no ruling decides yet; each is typed once one is chosen.
- **`ObjectGanttProps`, `ObjectTimelineProps`, `ObjectFormProps` and `ObjectMasterDetailFormProps`** carry these types on the four members instead of `unknown`. No new member carries a default, so each parsed value is the authored one.

## FROM → TO

| you wrote | write instead |
|:--|:--|
| `object-gantt` `markers: [{ date: 5 }]` | `markers: [{ date: '2026-07-01' }]` — an ISO date or date-time string |
| `object-gantt` `markers: [{ label: 'Freeze' }]` | give it a `date`: `[{ date: '2026-07-01', label: 'Freeze' }]` |
| `object-gantt` `markers: [{ date: '2026-07-01', title: 'Freeze', colour: 'red' }]` | `[{ date: '2026-07-01', label: 'Freeze', color: 'red' }]` |
| `object-timeline` `mapping: 'subject'` | `mapping: { title: 'subject' }` — name the member the field binds |
| `object-timeline` `mapping: { titleField: 'subject', variantField: 'status' }` | `mapping: { title: 'subject', variant: 'status' }` |
| `object-form` `fields: [{ name: 'email', label: 'Email', required: true }]` | `fields: ['email']`, with the label and `required` on the object field or on a `sections[].fields` entry |
| `object-form` `fields: [{ field: 'email' }]` | `fields: ['email']`, or move the entry into a section's `fields` |
| `object-master-detail-form` `fields: [{ name: 'note' }, 'status']` | `fields: ['note', 'status']` |

The one-line fix: write each member as the table above shows. No conversion is registered: a misspelled marker or mapping member has no rewrite that says which member the author meant, and a form already draws a stored `{ name }` entry by its name, while an override written beside it has nowhere to go but a section — the D3 entries `ui-object-gantt-markers-typed`, `ui-object-timeline-mapping-typed` and `ui-object-form-fields-names-typed` carry that judgment.

## Who is affected, measured

A writer is a value written on the block: a page-component node (an object literal naming the type, flat or in its `properties` bag, a literal annotated with the block's type, a direct parse through the row), the block's React component with the member as a prop or inside `schema={{…}}`, or the argument of a local test helper that mounts one (positional helper parameters resolved at every call site). Values resolve through same-file constants. Each static value was parsed through the row; a text search for each member key beside the block's name found the writers the walk does not reach, and each was read by hand.

- **objectstack** at `7d0781482d`, over `examples/`, `packages/`, `content/`, `skills/`, `apps/` and `docs/`: no `markers` and no `object-form` `fields`; one `mapping` (this package's own navigation test, `{ title, variant }`) and four `object-master-detail-form` `fields` (the showcase's project workspace, the objectui layout DSL page, and two test copies), all field names. All parse.
- **objectui** at the `.objectui-sha` pin `ab1879721595` and at `main` `94985a92ba` (every read point identical between the two), every value a test fixture, a document or a run-time hand-off:
- `markers`: 9 values, 8 parse. The refused one is objectui's own compile-time probe that a numeric `date` is refused (`gantt-declared-keys.test.ts`). Five more mount `GanttView`, the runtime chart, directly rather than the block, and are not writers of this member.
- `mapping`: 9 values (the timeline inputs test and the absent-date-axis refusal test), all parse.
- `fields`, both forms: 73 values at `main` — 56 parse, 11 are run-time hand-offs that are not static, and the 6 refused are fixtures probing the read: three `{ field }` entries asserting the form skips them with a warning, a `{ name }` entry asserting objectui's own mirror refuses it, and two `{ name }` entries asserting a stored one still draws. At the pin a seventh is refused: the page-builder guide's `{ name, label, type, required }` example, respelled to names on objectui `main`. (Fourteen more matches are object definitions or permission maps whose own `fields` key the walk read as the block's, and are not writers.)
- **hotcrm** at `4054ec2680` and **cloud** at `b2d7a7f6f8`: no writer of any of the four members.
- **Deployed metadata** was not measured.
25 changes: 21 additions & 4 deletions content/docs/references/ui/component.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -523,7 +523,7 @@ Sort field and direction pair
| **formType** | `Enum<'simple' \| 'tabbed' \| 'wizard' \| 'split' \| 'drawer' \| 'modal'>` | optional | Form presentation |
| **layout** | `Enum<'vertical' \| 'horizontal'>` | optional | Field layout — 'vertical' (the renderer default) or 'horizontal'. Multi-column is not a layout value: set `columns` |
| **columns** | `number` | optional | Number of field columns (multi-column forms), honoured under either `layout` |
| **fields** | `any[]` | optional | Limit/order the fields shown |
| **fields** | `string[]` | optional | Field names to draw, in order — bare names selecting from the object's fields and from `customFields`. A `{ name }` or `{ field }` object entry is refused: a per-form label or required override goes on a `sections[].fields` entry |
| **customFields** | `any` | optional | Custom field definitions merged into the generated set |
| **sections** | `any[]` | optional | Form sections (`{ label, description?, fields }` — wizard steps / tab panes) |
| **title** | `string \| Record<string, string>` | optional | Form title |
Expand Down Expand Up @@ -596,7 +596,7 @@ Sort field and direction pair
| **holidays** | `string[]` | optional | Additional non-working dates for the working calendar, ISO `yyyy-mm-dd` strings; folded into a Set for the duration math |
| **persistLayout** | `boolean` | optional | Opt OUT of layout and filter-chip persistence — only an explicit `false` disables it; the storage key is `objectName:viewName` |
| **viewName** | `string` | optional | Layout-persistence scope, the second half of the `objectName:viewName` storage key (renderer default `'default'`) |
| **markers** | `any[]` | optional | Extra vertical reference lines drawn like the Today marker (`{ date, label?, color? }`) |
| **markers** | `{ date: string; label?: string; color?: string }[]` | optional | Extra vertical reference lines drawn like the Today marker — each `{ date, label?, color? }`: `date` places the line (a date outside the drawn range draws none), `label` is drawn against it, `color` paints it |
| **criticalPath** | `boolean` | optional | Seed the critical-path highlight ON; the toolbar toggle stays available either way |
| **showBaselines** | `boolean` | optional | Render the planned-vs-actual baseline bars — ON unless an explicit `false` disables it |
| **readOnly** | `boolean` | optional | Disable every write path on this gantt and lock the record drawer |
Expand Down Expand Up @@ -696,6 +696,14 @@ Sort field and direction pair
| **size** | `Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>` | optional (default: `"auto"`) | Overlay size bucket for drawer/modal detail: 'auto' (default — renderer derives from field count + viewport; AI writes nothing) or a coarse override sm/md/lg/xl/full. Prefer this over the pixel `width`; page mode ignores it. |
| **width** | `string \| number` | optional | [DEPRECATED → size] Pixel/percent width of the drawer/modal (e.g. "600px"). A pixel width cannot be chosen at authoring time without knowing the client viewport — use the `size` bucket. |

### Nested Shape: `ObjectGanttProps.markers[number]`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **date** | `string` | ✅ | Where the line stands — an ISO date (`2026-07-01`, read as that day on the chart's own calendar) or date-time string; a date that does not parse, or falls outside the drawn range, draws no line |
| **label** | `string` | optional | Text drawn against the line |
| **color** | `string` | optional | Line colour, any CSS colour (renderer default: the theme's primary colour) |


---

Expand Down Expand Up @@ -1083,7 +1091,7 @@ Sort field and direction pair
| **mode** | `Enum<'create' \| 'edit'>` | optional | Form mode |
| **formType** | `Enum<'simple' \| 'tabbed'>` | optional | Parent form presentation — the two variants the renderer honours for the parent half |
| **sections** | `any[]` | optional | Parent form sections |
| **fields** | `any[]` | optional | Parent fields shown |
| **fields** | `string[]` | optional | Parent field names to draw, in order — bare names, as on `object-form`; a `{ name }` or `{ field }` object entry is refused |
| **details** | `{ childObject: string; relationshipField?: string; columns?: object[]; formFields?: string[]; … }[]` | optional | Detail collections — each a strict entry (`{ childObject, title?, addLabel?, columns?, relationshipField?, … }`) whose `columns` are the inline grid columns a relationship field's `inlineColumns` takes; the FK and columns auto-derive from child metadata when omitted |
| **title** | `string \| Record<string, string>` | optional | Form title |
| **submitText** | `string \| Record<string, string>` | optional | Submit button label |
Expand Down Expand Up @@ -1203,7 +1211,7 @@ View filter rule
| **minDate** | `string` | optional | Pin the gantt axis start (ISO `yyyy-mm-dd`) instead of deriving it from the rows; only a non-empty value is honoured |
| **maxDate** | `string` | optional | Pin the gantt axis end (ISO `yyyy-mm-dd`) instead of deriving it from the rows; only a non-empty value is honoured |
| **descriptionField** | `string` | optional | Field rendered as each entry's description (renderer default `description`). Declared FLAT because the `timeline` block has no member for it — it is the only spelling this binding has |
| **mapping** | `any` | optional | Record-to-entry field mapping (`{ title, date, description, variant }`) — the objectui-side binding record read BETWEEN the `timeline` block and the flat fallbacks. Its `variant` member (the field whose value picks each marker colour, renderer default `variant`) is the only spelling that binding has |
| **mapping** | `{ title?: string; date?: string; description?: string; variant?: string }` | optional | Record-to-entry field mapping `{ title?, date?, description?, variant? }`, each a field name — the binding record read BETWEEN the `timeline` block and the flat fallbacks. Its `variant` member (the field whose value picks each marker colour, renderer default `variant`) is the only spelling that binding has |
| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Entry-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`) |

### Nested Shape: `ObjectTimelineProps.timeline`
Expand Down Expand Up @@ -1236,6 +1244,15 @@ Sort field and direction pair
| **field** | `string` | ✅ | Field name to sort by |
| **order** | `Enum<'asc' \| 'desc'>` | ✅ | Sort direction |

### Nested Shape: `ObjectTimelineProps.mapping`

| Property | Type | Required | Description |
| :--- | :--- | :--- | :--- |
| **title** | `string` | optional | Field whose value is each entry's title — read after `timeline.titleField`, ahead of the flat `titleField` (renderer default `name`) |
| **date** | `string` | optional | Field whose value is each entry's date — read after `timeline.startDateField` / `timeline.dateField`, ahead of the flat spellings |
| **description** | `string` | optional | Field whose value is each entry's description — read ahead of `descriptionField` (renderer default `description`) |
| **variant** | `string` | optional | Field whose value picks each entry's marker colour (renderer default `variant`) — the only spelling this binding has |

### Nested Shape: `ObjectTimelineProps.navigation`

| Property | Type | Required | Description |
Expand Down
10 changes: 5 additions & 5 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts/ui.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 196 | 185 | 4 | 0 | 7 |
| `ui/` | 198 | 187 | 4 | 0 | 7 |

## `ui/` — sites

Expand All @@ -36,7 +36,7 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `app.zod.ts` | 19 |
| `bulk-action.zod.ts` | 4 |
| `chart.zod.ts` | 8 |
| `component.zod.ts` | 66 |
| `component.zod.ts` | 68 |
| `dashboard.zod.ts` | 11 |
| `dataset.zod.ts` | 4 |
| `i18n.zod.ts` | 1 |
Expand All @@ -46,23 +46,23 @@ classify and is not listed (it becomes reportable the day it grows its first sit
| `sharing.zod.ts` | 1 |
| `view.zod.ts` | 60 |
| `widget.zod.ts` | 1 |
| **total** | **196** |
| **total** | **198** |

## `ui/` — open

Per file, how many of its sites still silently discard unknown keys. The `Class`
column that decides the bucket split is hand-written in the ledger; the arithmetic
over it is here.

**7 strip of 196**, in 4 file(s).
**7 strip of 198**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 19 |
| `view.zod.ts` | 4 | 60 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **7** | **196** |
| **total** | **7** | **198** |

| Bucket | Sites |
|---|---|
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

import type { SemanticMigration } from '../../types.js';

// #21464 — the top-level `fields` of the `object-form` and
// `object-master-detail-form` page blocks was `z.array(z.unknown())`, held
// while the form drew a `{ name }` field entry its own page-builder guide
// taught. objectstack-ai/objectui#11550 retired that entry from every authoring
// face (the form still draws a STORED one, by its name, as tolerance), so both
// rows now take field names — objectui's own declaration of the member — and
// an object entry is refused with what to write instead. D3 only: page-
// component `properties` is not parsed on the metadata save or load path, so a
// stored page is never refused; and the authored census found no authored
// value to respell — the refused values are fixtures probing the stored read,
// the console warning and objectui's own refusal.
export const entry: SemanticMigration = {
id: 'ui-object-form-fields-names-typed',
surface: 'page `object-form` and `object-master-detail-form` components — `properties.fields` (whose '
+ 'entries used to accept any value)',
replacement: 'a list of bare field names, in the order the form draws them. Write a `{ name: \'email\' }` '
+ 'entry as `\'email\'` — the form only ever drew its name — and move a `label` or `required` override '
+ 'onto a `sections[].fields` entry (`type` is always the object field\'s); write a `{ field: \'email\' }` '
+ 'entry as `\'email\'`, or move it into a section\'s `fields`, the vocabulary it belongs to.',
reason: 'The form reads its top-level `fields` as the names of the fields to draw, in order, selecting '
+ 'from the object\'s fields and from `customFields`; the master-detail form hands its own to the parent '
+ 'form verbatim. objectui declares the member `string[]`, but the page-component rows declared it '
+ '`z.array(z.unknown())` while the form drew a `{ name }` entry by that name — the shape objectui\'s '
+ 'page-builder guide taught, with a `label`, `type` and `required` the form silently dropped. '
+ 'objectui has since retired that entry from every authoring face — the guide and its fixtures name the '
+ 'fields — keeping only a STORED one readable; so both rows now take field names, and refuse an object entry with what to write instead: a '
+ '`{ name }` entry is its bare name, and a `{ field }` entry — the `sections[].fields` vocabulary, which '
+ 'the form skips at the top level with a console warning — is its bare name or belongs in a section. It is '
+ 'read where every page component\'s props are: the component-props gate reports a refused value as an '
+ 'advisory `component-props-invalid` finding on `objectstack validate`, `objectstack build` and '
+ '`objectstack lint`, and a stored page still saves and loads, because a page component\'s `properties` is '
+ 'not parsed on the metadata save or load path. No conversion is registered: nothing on the load path '
+ 'refuses the shape, the form already draws a stored `{ name }` entry by its name, and an override written '
+ 'beside it has no rewrite that keeps it — moving it onto a section is the judgment this entry leaves to '
+ 'the upgrader. Deployed metadata NOT MEASURED.',
acceptanceCriteria: 'Every `object-form` and `object-master-detail-form` node validates: `objectstack '
+ 'validate` reports no `component-props-invalid` finding under `properties.fields`. Each form draws the '
+ 'fields its list names, in that order, with any per-form label or required override taken from its '
+ 'section entry.',
};
Loading
Loading