Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
5109e3a
refactor(plugin-kanban,plugin-timeline): type the two runtime keys th…
claude Oct 4, 2026
0a0ef9a
test(types,plugin-gantt,plugin-map): the mechanical rows that stop co…
claude Oct 4, 2026
e954bef
feat(types): `BaseSchema.visibleWhen` is the spec's evaluated-slot in…
claude Oct 4, 2026
b66492b
feat(types)!: `BaseSchema` loses its index signature; the compile-fai…
claude Oct 4, 2026
f1b7de8
docs: the blocks and passages the index-signature removal makes false
claude Oct 4, 2026
913dee4
docs(agents): §5 #0.1 says the TypeScript authoring face is part of t…
claude Oct 4, 2026
e0a9af6
chore(changeset): objectui#8347
claude Oct 4, 2026
0334c43
chore(changeset): objectui#8347 declares `Clause-②: yes (narrowing)`;…
claude Oct 4, 2026
032b9a2
docs(types): rewrite the comments objectui#8347 makes false (index si…
claude Oct 4, 2026
774e113
Merge origin/main (b508ac50) into claude/issue-8347-baseschema-index-…
claude Oct 4, 2026
50d3142
docs(skills): the BaseSchema teaching copies state the closed TypeScr…
claude Oct 4, 2026
2c8709b
test(types): restore the member deletion guards objectui#8347 silence…
claude Oct 4, 2026
de88092
test(types): the deletion-guard docblocks name the co-guards the abla…
claude Oct 4, 2026
09165ac
test(types): the two `any[]` rows in the gantt query-key guard say wh…
claude Oct 4, 2026
89c2138
docs(guide),test(types): the integration guide installs the adapter t…
claude Oct 4, 2026
7c82626
Merge branch 'main' into claude/issue-8347-baseschema-index-signature
os-zhuang Oct 4, 2026
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
2 changes: 2 additions & 0 deletions .changeset/11564-flex-bag-children-list.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,3 +16,5 @@ The type is the flat `FlexSchema` mirror's own `children` member, by reference,
What does NOT move: every zod face and every runtime path. `FlexBlockSchema` keeps `z.array(z.unknown())` for the list, because `@objectstack/spec`'s page walk already judges each entry there, once, at its real path, on the tolerant and the strict face (objectui#11223). The one entry kind where the faces now differ is a nested list, which the walk passes through unvisited and the TypeScript face refuses; `@object-ui/types`' mirror-parity ledger records the divergence.

**Migration.** Give each list entry its declared node type (or `DeclaredNode`), declare a custom type in `CustomNodeRegistry`, and flatten a nested list into the one list.

⚠️ **Dated note, 2026-10-04 — `BaseSchema` loses its index signature — objectui#8347.** At this change, "Node types that extend `BaseSchema` keep its index signature until objectui#8347 removes it, so a misspelled key on one of them still compiles" held. Later in this same release objectui#8347 removed that signature, so a misspelled key on a node type that extends `BaseSchema` no longer compiles either, in the list exactly as in a single child. `.changeset/8347-baseschema-closed-face.md` states what ships. The rest of this entry is kept as the reading of this change.
18 changes: 18 additions & 0 deletions .changeset/8347-baseschema-closed-face.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
---
'@object-ui/types': minor
'@object-ui/plugin-kanban': patch
'@object-ui/plugin-timeline': minor
---

**`BaseSchema` no longer declares `[key: string]: any`** (objectui#8347, executing the objectui#7927 ruling: the TypeScript face is a contract). Every node type extends `BaseSchema`, so a node literal annotated with its node type now refuses a key that no declaration names, a misspelled key included, where it used to type it `any`. The correct spelling compiles as before.

**Clause-②: yes (narrowing)**, shipped as `minor` per this repository's version policy. The removal narrows the TypeScript authoring face of every node type; the `visibleWhen` change below widens both faces to the envelope the spec's own parse writes.

- **What does not move.** The zod faces keep their accept sets for every key but `visibleWhen`: the tolerant mirror is still `.passthrough()`, so `safeValidateSchema` keeps an undeclared key, and the derived strict face refuses it as before. `ComponentRendererProps`, the renderer props type, keeps its own index signature. Nothing a renderer draws changes.
- **The bound.** TypeScript runs its excess-property check only on a fresh object literal. A value that reached its annotation through a variable of a wider type is not re-checked.
- **`PartialSchema<T>` works as written.** With the signature gone, `keyof T` is the literal member union again, so the alias keeps `T`'s declared members, optional, with `type` required. While the signature stood it declared `type` alone (objectui#6397).
- **`BaseSchema.visibleWhen` is the spec's `EvaluatedExpressionInput`**, by reference: a predicate string, or the `{ dialect, source }` envelope. The zod twin takes `EvaluatedExpressionInputSchema`'s verdict without its transform, so a string parses to itself. A dialect-less envelope, an unknown dialect and a blank predicate are refused, as the spec refuses them. Both faces read `string` before, which refused the envelope a spec parse writes into this key.
- **`@object-ui/plugin-kanban`.** `ObjectKanban` reads the `sort` the element data-source gate writes through a read type private to the package. `ObjectKanbanSchema` still declares no `sort` (objectui#8174). Nothing drawn changes.
- **`@object-ui/plugin-timeline`.** `TimelineRenderSchema`, the `schema` prop type of the exported `TimelineRenderer`, gains one optional member: the `onItemClick` slot `ObjectTimeline` composes. That is a one-member optional widening of an exported prop type. `TimelineSchema`, the authoring face, still declares no `onItemClick`. Nothing drawn changes.

**Migration.** Where a literal stops compiling, the key is misspelled (fix it) or not declared on that node type (declare it on the type that reads it, by reference to the `@objectstack/spec` row, or remove it). Do not cast past the error. `props`, the legacy alias of `properties`, is not declared on the TypeScript face; the renderer still reads it, so write `properties`.
5 changes: 3 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -74,12 +74,12 @@ Every node in the UI tree follows this shape (`@object-ui/types`):
interface BaseSchema {
type: string; // registry key: 'input', 'grid', 'card'
id?: string; // DOM accessibility / event targeting
props?: Record<string, any>; // element:* config envelope, not a general bag
bind?: string; // data binding path: 'user.address.city'
className?: string; // Tailwind overrides
hidden?: boolean | ExpressionWire; // expression or boolean: "${data.role != 'admin'}"
disabled?: boolean | ExpressionWire; // same wire as hidden
children?: SchemaNode | SchemaNode[]; // layout slots; primitives admitted too
// no index signature: a key no member declares is a compile error (#0.1)
}
```

Expand All @@ -90,14 +90,15 @@ interface BaseSchema {
- **#-1 — English-only codebase.** This is an international OSS project. All user-facing text (component labels, buttons, titles, errors), code comments, docs (`README.md`, `docs/*.md`), and console/log messages MUST be English. No Chinese or other non-English in those. *(This rule governs the **codebase**; this instruction file may use Chinese in operational sections.)*
- **#0 — Strict adherence to `@objectstack/spec`.** All schemas/JSON structures/types MUST follow `@objectstack/spec`. Don't invent schema properties — if the spec says `columns`, don't use `fields`. Check the spec before writing any `interface`/`type`.
- **#0.1 — Fix the metadata, not the renderer (contract-first).** Corollary to #0. This is a metadata-driven system: `@objectstack/spec` is the contract between producers and this renderer. When a piece of metadata "doesn't render," ask **first**: *is it spec-compliant? is this the long-term-correct direction?* If the metadata is off-spec, fix it at the **producer** (and have it rejected at authoring/publish) — do **not** add a lenient fallback/alias in the renderer (reading both `columns` and `fields`, coercing a malformed shape, `??`-defaulting around bad input) to make non-compliant metadata "work." A tolerant fallback fossilizes the wrong convention into a second de-facto contract, dilutes the spec, and hides the producer's bug — one strict contract beats N dialects. We own both ends, so Postel's "be liberal in what you accept" does **not** apply (that's for untrusted boundaries). Change the **spec** only when it is genuinely wrong — deliberately, in `@objectstack/spec`, never by accreting renderer-side fallbacks.
- **The TypeScript authoring face is part of the contract (objectui#7927 ruling, executed by objectui#8347).** `BaseSchema` declares no index signature, so a node literal typed with its node type refuses, at the authoring site, a key no declaration names — a misspelling included. When a key you need is refused, this rule applies unchanged: declare it on the node type that reads it, by reference to the spec row, or fix the producer; a key only the host writes at runtime is typed on a package-local hand-off type where its producer and consumer live, never on the authoring face (objectui#6356). ⛔ Never make it compile with `as any`, a cast to `Record<string, unknown>`, a re-added index signature or an open `type: string` arm. Tolerance stays where it is deliberate: renderer props (`ComponentRendererProps` keeps its signature) and the tolerant zod face (`.passthrough()`); the derived strict zod face, `StrictAnyComponentSchema`, refuses an unknown key in metadata that arrives as data. ⚠️ The bound, said per #9: TypeScript excess-checks only a FRESH object literal, so a value that reached its annotation through a wider variable is not re-checked. `packages/types/src/__tests__/base-schema-closed-face-8347.test.ts` pins both directions and that bound.
- **#1 — Protocol-agnostic.** Never hardcode `objectql.find()`. Use the DataSource interface; inject `dataSource` via `<SchemaRendererProvider dataSource={...} />`.
- **#2 — Docs-driven.** For every feature/refactor, update package `README.md` **and** `content/docs/guide/*.md`. Not done until docs reflect the code.
- **#3 — "Shadcn-native" aesthetics.** We are "serializable Shadcn". Follow Shadcn's DOM structure (`CardHeader`/`CardTitle`/`CardContent`). Always expose `className` in schema props so users can override via JSON.
- **#4 — Action system (objectui#7898, objectui#6497, objectui#11183).** Actions are **data, not functions**, and a control that RUNS something is its own NODE TYPE — `action:button` — never a handler key or an event bag on an ordinary node. The block's props live in the node's `properties` bag, which is the spec's `ComponentPropsMap` row for it: `actionType` names the executor the action runner dispatches to, and the row's other keys carry that executor's arguments. ⛔ Never write them flat on the node: the spec's `PageComponentSchema` is `.strict()` (ADR-0089 D3a) and refuses a node-level `actionType` / `target` as mis-layered, and objectui's strict authoring face refuses the same two keys. The runtime reads both spellings through the `SchemaRenderer` `properties` hoist, so the bag costs nothing at render:
```json
{ "type": "action:button", "properties": { "label": "Open details", "actionType": "url", "target": "/users/ada" } }
```
⛔ Never author an `events` bag: `BaseSchema` declares no `events` member and no renderer reads `schema.events` — the node is `.passthrough()`, so one authored there is kept, judged by nothing and run by nothing. `ButtonSchema.onClick` is a runtime slot for a host-supplied function and is refused by name for the same reason.
⛔ Never author an `events` bag: `BaseSchema` declares no `events` member and no renderer reads `schema.events` — a typed literal refuses it (#0.1), but the tolerant zod face is `.passthrough()`, so one that arrives as data is kept, judged by nothing and run by nothing. `ButtonSchema.onClick` is a runtime slot for a host-supplied function and is refused by name for the same reason.
- **#5 — Layout as components.** Treat `Grid`/`Stack`/`Container` as first-class. Layout schemas declare responsive columns on the node as `columns` — a number, or a breakpoint object (`columns: { xs: 1, md: 2, lg: 4 }`); never `cols`, which nothing reads (objectui#4001).
- **#6 — Type safety over magic.** No `any` — use strict generics. Map `"type": "button"` → React component via a central `ComponentRegistry`. **No `eval()` / runtime dynamic imports** to load components (security).
- **#7 — No-Touch zones (Shadcn purity).** `packages/components/src/ui/**/*.tsx` are upstream 3rd-party files overwritten by sync scripts — **never edit their logic/styles**. To change `Button`/`Dialog` behavior: create/edit a wrapper in `packages/components/src/custom/`, import the primitive from `@/ui/...`, and wrap it.
Expand Down
4 changes: 2 additions & 2 deletions content/docs/api/schema-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -96,7 +96,7 @@ One row per declared member, in declaration order, so the list can be checked ag
| `body` | *retired* | ⛔ Refused by name (objectui#6771). `body` was a second child-list spelling `BaseSchema` declared beside `children`; it is now `never` on the TypeScript face and an alias refusal on the Zod mirror, and the refusal names `children`. |
| `children` | `SchemaNode \| SchemaNode[]` | Child components rendered inside this component — the child-list key, and since objectui#6771 the only one. Whether a given node type renders a child list at all is still per component; see the note below. |
| `visible` | `boolean \| string \| { dialect?: string; source: string }` | Visibility control. Accepts a boolean, a predicate expression string, **or** the CEL envelope object (`{ dialect: 'cel', source }` — what `objectstack build` emits for every authored predicate) — the renderer evaluates this key rather than reading it as a boolean. The string-or-envelope half is `ExpressionWire`, the one wire type `visibleWhen` on form fields already carries. |
| `visibleWhen` | `string` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleWhen` | `string \| { dialect, source }` | Canonical conditional-visibility predicate (ADR-0089); the element is shown when it evaluates truthy. Typed as `@objectstack/spec`'s `EvaluatedExpressionInput`: a predicate string, or the envelope the spec's parse writes (`dialect` is `cel`, `cron` or `template`, and `source` is not blank). Evaluated **before** `visible` and `visibleOn`, and outranks both. |
| `visibleOn` | `string` | Expression for conditional visibility. **Deprecated** (ADR-0089) — use `visibleWhen`. |
| `hidden` | `boolean \| string \| { dialect?: string; source: string }` | Inverse of `visible` — the node is not rendered. Accepts a boolean, a predicate expression string **or** the CEL envelope object (`ExpressionWire`), which the renderer evaluates rather than reading as a boolean; `hiddenOn` remains the sibling spelling. |
| `hiddenOn` | `string` | Expression for conditional hiding. |
Expand All @@ -109,7 +109,7 @@ Two things the table cannot show in a cell:

- **A concrete schema may narrow an inherited member, and its own declaration wins.** Many component schemas restate `label`, `description` or `disabled` more narrowly than `BaseSchema` declares them, so the unions above are what a node gets when its own schema does not restate the key. Check the component's own property table before writing a predicate string or a locale map into an inherited slot.
- **⚠️ `body` and `children` WERE two channels, not one key with two spellings — and the second one is retired (objectui#6771).** Each renderer read one, the other, both, or neither, and `SchemaRenderer` strips both out of the props bag it spreads — so writing the channel a renderer did not read rendered an EMPTY element, with no error at authoring time, none at validation time and none at render time. That is the defect objectui#8284 named after seven cards had repaired one page of it each, and it was closed per component by measurement: each schema narrows to the channel its renderer actually reads and **tombstones the other as `never`**, refused by name on both published faces (objectui#9254 for the components that read exactly one channel, objectui#9256 for the ones that read neither). objectui#6771 then closed the class at its source: `children` is the one child-list spelling and `body` is refused on `BaseSchema` itself. ⇒ **whether a node type accepts a child list at all is still per component** — check the component's own section, where a node that renders no children tombstones `children` too.
- **This list is exhaustive for *declared* members, not for *accepted* keys.** `BaseSchema` carries an index signature (`[key: string]: any`) and its Zod mirror is `.passthrough()`, so an undeclared key — a misspelling included — is still accepted by both halves. Absence from this table does not mean a key is rejected.
- **This list is exhaustive for *declared* members; the two faces differ on *accepted* keys.** `BaseSchema` carries no index signature (objectui#8347), so on the TypeScript face an undeclared key, a misspelling included, is a compile error in a typed literal. Its tolerant Zod mirror is still `.passthrough()`, so the rendering face (and `safeValidateSchema`) keeps an undeclared key; the derived strict face, `StrictAnyComponentSchema`, refuses it. Absence from this table does not mean the tolerant face rejects a key.

---

Expand Down
2 changes: 1 addition & 1 deletion content/docs/components/data-display/list.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -42,7 +42,7 @@ required, so the bind-only list above was refused at `items`. The TypeScript fac
<Callout type="warn">
`wrapperClass` is now typed. The renderer has read it all along — it lands on the
wrapper element around the list — but it went undeclared until objectui#7722,
surviving on `BaseSchema`'s index signature and the zod mirror's `.passthrough()`.
surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's `.passthrough()`.
Declared as `string` on both faces, a **non-string** `wrapperClass` that used to
parse green is now refused by name — on `ListSchema` and through
`safeValidateSchema` alike. Pass the Tailwind class string; a number or an object
Expand Down
2 changes: 1 addition & 1 deletion content/docs/components/form/date-picker.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ interface DatePickerSchema {
<Callout type="warn">
`wrapperClass` is now typed. The renderer has read it all along — it lands on the
wrapper element around the date picker and its label — but it went undeclared until
objectui#7722, surviving on `BaseSchema`'s index signature and the zod mirror's
objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's
`.passthrough()`. Declared as `string` on both faces, a **non-string**
`wrapperClass` that used to parse green is now refused by name — on
`DatePickerSchema` and through `safeValidateSchema` alike. Pass the Tailwind class
Expand Down
2 changes: 1 addition & 1 deletion content/docs/components/form/select.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ interface SelectSchema {
<Callout type="warn">
`wrapperClass` is now typed. The renderer has read it all along — it lands on the
wrapper element around the select and its label — but it went undeclared until
objectui#7722, surviving on `BaseSchema`'s index signature and the zod mirror's
objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's
`.passthrough()`. Declared as `string` on both faces, a **non-string**
`wrapperClass` that used to parse green is now refused by name — on `SelectSchema`
and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or
Expand Down
2 changes: 1 addition & 1 deletion content/docs/components/form/switch.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ interface SwitchSchema {
<Callout type="warn">
`wrapperClass` is now typed. The renderer has read it all along — it lands on the
wrapper element around the switch and its label — but it went undeclared until
objectui#7722, surviving on `BaseSchema`'s index signature and the zod mirror's
objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's
`.passthrough()`. Declared as `string` on both faces, a **non-string**
`wrapperClass` that used to parse green is now refused by name — on `SwitchSchema`
and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or
Expand Down
2 changes: 1 addition & 1 deletion content/docs/components/form/textarea.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ interface TextareaSchema {
<Callout type="warn">
`wrapperClass` is now typed. The renderer has read it all along — it lands on the
wrapper element around the textarea and its label — but it went undeclared until
objectui#7722, surviving on `BaseSchema`'s index signature and the zod mirror's
objectui#7722, surviving on what was then `BaseSchema`'s index signature (objectui#8347 removed it) and the zod mirror's
`.passthrough()`. Declared as `string` on both faces, a **non-string**
`wrapperClass` that used to parse green is now refused by name — on `TextareaSchema`
and through `safeValidateSchema` alike. Pass the Tailwind class string; a number or
Expand Down
Loading
Loading