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
57 changes: 57 additions & 0 deletions .changeset/component-props-strict-batch-a.md
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
---
"@objectstack/spec": minor
"@objectstack/lint": patch
---

feat(spec): close the 31 SDUI component-props shapes against unknown keys (#4001 batch A)

`ComponentPropsMap` — the declared shape of every `page:*`, `record:*`,
`element:*`, `nav:*` and `ai:*` node a page can carry — stripped unknown keys in
silence. All 31 object sites in `ui/component.zod.ts` are `strictObject` now.

**What actually changes for an author.** #5068 already reported these keys: it
dispatches `ComponentPropsMap` by the component's `type` and judges `properties`
at `os validate` / `os build` / `os lint`. It did that by walking a strip-mode
object and reconstructing what the parse would have dropped. Now the parse
rejects the key itself. Same rule id (`component-props-unknown-key`), same
warning tier — and three things a reconstruction could not give:

- **Curated prescriptions per surface.** A tab item's `key` is answered with
`value` (objectui's Studio designer publishes `key`; the renderer reads
`it.value` and falls back to `tab-<index>`, so an authored `key` yields tab
tokens that move when the item list changes). A header's `description` is
answered with `subtitle`, the rename its own ADR-0087 conversion performs —
the one path that had no diagnostic at all, as `conversions/walk.ts` records.
A container's `body` is answered with `children`. Anything spelled like a
component-level key (`visibleWhen`, `id`, `dataSource`, `className`, …) is told
it belongs one level up on the node, where the runtime actually reads it.
- **A rejection that holds for every caller**, not only inside the gate.
- **Union arms.** `RecordHighlightsField`'s object arm and
`record:related_list`'s sort entry are closed too. Zod 4 collapses arm
failures into one `invalid_union`, so `@objectstack/lint` now unpacks a lone
arm's `unrecognized_keys` back onto the unknown-key rule — and declines to,
deliberately, when two arms could both have been meant.

**Five props are newly DECLARED, not rejected**, on the rule this file has
applied three times before (#5611 / #5775 / #6276: the delivered, authorized
shape is the contract). Each is read by objectui through
`schema?.X ?? schema?.properties?.X` with its own comment inviting authors, and
closing the shape around them would have turned an invited affordance into a
rejection: `page:header` `maxVisible` / `mobileMaxVisible` (the inline-vs-overflow
action budget, desktop and mobile), `page:tabs.alwaysShowStrip` (keep a one-tab
strip visible), `record:details` `inlineEdit` / `showHeader`. All optional with
no schema default — the defaults are the renderer's, and declaring them would
turn an unset key into an authored one.

**Deliberately unchanged, and worth stating because a reader will assume
otherwise.** The carrier is still `z.record(z.string(), z.unknown())`: direction
B (a discriminated `properties`) stays declined, because `type` is an open union
and the example corpus alone authors 87 nodes across ten types this map does not
carry — those are still skipped, not rejected. The storage path still parses no
props (#4463). The gate is still warning-level.

**Zero refusals on shipped metadata.** Verified by parsing the three example
apps' build artifacts and the three published platform pages directly through
`ComponentPropsMap` — 244 registered props bags, no undeclared key, no new
refusal — because `objectstack validate` never parses through `PageSchema`
(#5000), so "the examples validate clean" would have been no evidence at all.
5 changes: 5 additions & 0 deletions content/docs/references/ui/component.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -249,6 +249,8 @@ const result = AIChatWindowProps.parse(data);
| **recordChrome** | `boolean` | ✅ | Render the record chrome — the title as a record chip with its follow star and copy-id button. Set false on a non-record page (dashboard, landing) to fall back to the bare heading layout. |
| **showStar** | `boolean` | ✅ | Show the follow (favourite) star beside the record title. Part of the record chrome — no effect when `recordChrome` is false. |
| **showCopyId** | `boolean` | ✅ | Show the copy-record-id button beside the record title. Part of the record chrome — no effect when `recordChrome` is false. |
| **maxVisible** | `integer` | optional | How many header actions render as inline buttons before the rest fold into the overflow menu (renderer default 3). |
| **mobileMaxVisible** | `integer` | optional | The `maxVisible` budget on mobile viewports (renderer default 1). |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand All@@ -263,6 +265,7 @@ const result = AIChatWindowProps.parse(data);
| **tabStyle** | `Enum<'line' \| 'card' \| 'pill'>` | optional | Tab-strip visual style: 'line' underlines the active tab, 'card' frames each tab, 'pill' renders rounded pills |
| **type** | `never` | optional | [REMOVED] `page:tabs` property `type` was removed in @objectstack/spec 17.0.0 (#6776, ADR-0087 D2) — a props key named `type` collides with the page component's own dispatch key, so it is unauthorable in the flat and JSX carriers and was never validated in them. Rename the key to `tabStyle`; the value (`line` \| `card` \| `pill`) is unchanged. Run `os migrate meta --from 16` to rewrite existing sources automatically. |
| **position** | `Enum<'top' \| 'left'>` | optional | |
| **alwaysShowStrip** | `boolean` | optional | Render the tab strip even when only one tab is visible (renderer default: a one-tab strip is hidden). |
| **items** | `{ label: string \| Record<string, string>; icon?: string; visibleWhen?: string \| object; value?: string; … }[]` | ✅ | |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |

Expand DownExpand Up@@ -318,6 +321,8 @@ const result = AIChatWindowProps.parse(data);
| **sections** | `{ name?: string; label?: string \| Record<string, string>; columns?: integer; fields: string[] }[]` | optional | Field groups rendered as the detail body, in order. Object form: `{ name?, label?, columns?, fields }`. |
| **fields** | `string[]` | optional | Explicit field list to display (optional, overrides highlightFields) |
| **hideFields** | `string[]` | optional | Field names to omit from the body — applied to `fields` and to every section's `fields` (used to dedupe fields already shown in `record:highlights` or as the page title) |
| **inlineEdit** | `boolean` | optional | Allow inline field editing in the detail body (renderer default: on, where the object itself is editable — set `false` to force it off). |
| **showHeader** | `boolean` | optional | Render the detail body's own heading (renderer default: off). |
| **aria** | `{ ariaLabel?: string \| Record<string, string>; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes |


Expand Down
9 changes: 8 additions & 1 deletion content/docs/ui/pages.mdx
Original file line numberDiff line numberDiff line change
Expand Up@@ -196,7 +196,14 @@ const accountRecordPage = {
id: 'header',
properties: {
fields: ['name', 'type', 'industry', 'owner'],
actions: ['edit', 'delete', 'clone'],
},
},
// Record actions are their own component — `record:highlights` renders
// field chips and nothing else.
{
type: 'record:quick_actions',
properties: {
actionNames: ['edit', 'delete', 'clone'],
},
},
],
Expand Down
17 changes: 8 additions & 9 deletions docs/audits/2026-07-unknown-key-strictness-ledger.counts.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -22,14 +22,14 @@ regenerate.
|---|---|
| Triaged directories | 5 |
| Object sites in them | 438 |
| Still-open (strip) sites | 175 |
| Files carrying at least one | 26 |
| Still-open (strip) sites | 144 |
| Files carrying at least one | 25 |

Remaining strip sites by class:

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 35 |
| authorable — the ruling's forced scope | 4 |
| unresolved — needs a per-schema verdict | 34 |
| wire / open — out of forced scope | 104 |
| no door — no carrier, ADR-0049 territory | 1 |
Expand All@@ -44,12 +44,12 @@ The `strict` column is the one the campaign schedules against; it counts both th

| Dir | Sites | strict | passthrough | catchall | strip |
|---|---|---|---|---|---|
| `ui/` | 161 | 119 | 5 | 0 | 37 |
| `ui/` | 161 | 150 | 5 | 0 | 6 |
| `data/` | 165 | 62 | 1 | 0 | 102 |
| `automation/` | 65 | 42 | 0 | 0 | 23 |
| `security/` | 20 | 7 | 0 | 0 | 13 |
| `studio/` | 27 | 27 | 0 | 0 | 0 |
| **total** | **438** | **257** | **6** | **0** | **175** |
| **total** | **438** | **288** | **6** | **0** | **144** |

## File-level triage — site counts

Expand DownExpand Up@@ -157,20 +157,19 @@ over it is here.

### `ui/` — open

**37 strip of 161**, in 5 file(s).
**6 strip of 161**, in 4 file(s).

| File | Strip | Sites |
|---|---|---|
| `action-params.zod.ts` | 1 | 1 |
| `app.zod.ts` | 1 | 18 |
| `component.zod.ts` | 31 | 31 |
| `view.zod.ts` | 3 | 54 |
| `widget.zod.ts` | 1 | 1 |
| **total** | **37** | **161** |
| **total** | **6** | **161** |

| Bucket | Sites |
|---|---|
| authorable — the ruling's forced scope | 32 |
| authorable — the ruling's forced scope | 1 |
| unresolved — needs a per-schema verdict | 0 |
| wire / open — out of forced scope | 3 |
| no door — no carrier, ADR-0049 territory | 1 |
Expand Down
Loading
Loading