From c7bb62ae6f682af4b3bc591c87cfbec85fa674c9 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:35:50 +0000 Subject: [PATCH 1/5] feat(spec)!: object-map / object-gantt / object-tree type navigation by reference, and one pin enumerates every z.unknown() member of ComponentPropsMap (#21464) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- ...omponent-props-unknown-members.pin.test.ts | 422 ++++++++++++++++++ packages/spec/src/ui/component.zod.ts | 59 ++- 2 files changed, 472 insertions(+), 9 deletions(-) create mode 100644 packages/spec/src/ui/component-props-unknown-members.pin.test.ts diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts new file mode 100644 index 00000000000..61b94171a09 --- /dev/null +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -0,0 +1,422 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * [#21464] Every `z.unknown()` member across `ComponentPropsMap` is typed, or + * is listed here with its recorded reason — the family close-out pin. + * + * ## The defect class this file holds + * + * A member a renderer reads with a fixed shape, declared `z.unknown()` on its + * page-component row, accepts any value at the component-props door, and the + * renderer answers an off-shape value with a silent default. #21445 closed the + * `object-grid` row's seven; this file enumerates the whole map, so the next + * such member is caught by a test instead of filed as a single point. + * + * ## What is pinned, and why each half + * + * - §1 THE ENUMERATION: a walk over every row (into arrays, record values, + * union arms, lazy schemas and catchalls) finds each `z.unknown()` member, + * and each one must be in {@link LEDGER} with a reason. A new one fails until + * it carries one. The other direction holds too: a ledger line whose member + * is no longer `z.unknown()` fails, so typing a member deletes its line and + * the ledger cannot outlive the debt it records. + * - §2 EACH REASON IS CHECKED AGAINST THE SCHEMA, where it can be: a slot is a + * declared slot position, and a runner-forwarded member says so in its own + * `.describe()`. + * - §3 THE WALK CAN FAIL: it finds a `z.unknown()` in every position it claims + * to walk, so a green §1 is not a walk that saw nothing. + * - §4 THE MEMBERS THIS CARD TYPES: `navigation` on `object-map`, + * `object-gantt` and `object-tree` is the list view's + * `NavigationConfigSchema`, by identity, and refuses an off-shape value with + * the code AND the path. + * + * ## The STAGED reason is debt, not a verdict + * + * A `staged` member IS read with a fixed shape at the `.objectui-sha` pin; its + * reader is cited on its line. Typing it is the next stage of #21464's + * close-out (the census found more than one reviewable PR's worth), each stage + * preceded by its own census of authored writers. The ledger may only lose + * `staged` lines: a stage that types a member deletes its line here (§1's + * second half enforces that), and ⛔ a NEW renderer-read member is typed, never + * added as `staged`. + */ + +import { describe, it, expect } from 'vitest'; +import { z } from 'zod'; + +import { + ComponentPropsMap, + ObjectGanttPropsSchema, + ObjectMapPropsSchema, + ObjectTreePropsSchema, + pageComponentSlotPositions, +} from './component.zod'; +import { NavigationConfigSchema } from './view.zod'; + +// ─────────────────────────────────────────────────────────────────────────── +// The walk +// ─────────────────────────────────────────────────────────────────────────── + +/** One `z.unknown()` member: its path inside the row, and the `.describe()` nearest it. */ +interface UnknownMember { + readonly path: string; + readonly describe: string | undefined; +} + +/** Wrapper types whose `innerType` is the member itself. */ +const WRAPPERS = new Set(['optional', 'nullable', 'default', 'prefault', 'readonly', 'catch', 'nonoptional']); + +/** + * Every `z.unknown()` member reachable from `schema`. Paths spell an array + * element `[]`, a record value `{}` and an object catchall `.*`; union arms are + * walked without an index, so two arms carrying one key name one member. + */ +function unknownMembers(schema: unknown): UnknownMember[] { + const found: UnknownMember[] = []; + const onPath = new Set(); + const visit = (node: unknown, path: string, describe: string | undefined): void => { + const s = node as { _zod?: { def?: Record }; description?: string } | undefined; + const def = s?._zod?.def; + if (!def || onPath.has(def)) return; + const here = s!.description ?? describe; + if (def.type === 'unknown') { + found.push({ path, describe: here }); + return; + } + onPath.add(def); + if (WRAPPERS.has(def.type)) visit(def.innerType, path, here); + else if (def.type === 'pipe') visit(def.in, path, here); + else if (def.type === 'lazy') visit(def.getter(), path, here); + else if (def.type === 'array') visit(def.element, `${path}[]`, undefined); + else if (def.type === 'record') visit(def.valueType, `${path}{}`, undefined); + else if (def.type === 'union') for (const arm of def.options) visit(arm, path, undefined); + else if (def.type === 'intersection') { + visit(def.left, path, undefined); + visit(def.right, path, undefined); + } else if (def.type === 'object') { + for (const [key, member] of Object.entries(def.shape as Record)) { + visit(member, path ? `${path}.${key}` : key, undefined); + } + if (def.catchall) visit(def.catchall, `${path}.*`, undefined); + } + onPath.delete(def); + }; + visit(schema, '', undefined); + return found; +} + +// ─────────────────────────────────────────────────────────────────────────── +// The ledger +// ─────────────────────────────────────────────────────────────────────────── + +/** + * The stages the remaining renderer-read members are typed in, by row family. + * Each stage runs its own census of authored writers first; a narrowing that + * would refuse a measured writer is reported, not shipped. + */ +const STAGES = { + 'object-metric': 'the metric tile\'s four config blocks; the dashboard widget schemas are the by-reference candidates', + 'object-form': 'the form and master-detail form rows; `FormViewSchema` (`sections`, `submitBehavior`) is the by-reference candidate', + 'list-family': 'the grid, kanban and calendar list members; `ListViewSchema` (`columns`, `selection`, `rowActions`, `bulkActions`, `calendar`) is the by-reference candidate', + 'objectui-held': 'element contracts whose only declaration is still objectui\'s (`GanttMarker`, `TimelineMappingSchema`, the timeline items, `UIActionSchema`)', + 'held-for-decision': 'a by-reference shape exists, but measured writers author values it refuses — the narrowing waits for a ruling', +} as const; +type Stage = keyof typeof STAGES; + +type Reason = + /** A child-component list; every child is judged at its own node by the walks that read `pageComponentSlotPositions()`. */ + | { readonly kind: 'slot' } + /** Handed to the action runner verbatim; checked: the member's own `.describe()` says so. */ + | { readonly kind: 'runner' } + /** Record rows or field values: their shape is the bound object's fields, which no page-component row can know. */ + | { readonly kind: 'records' } + /** A member of a schema another file owns and judges, carried here by reference. */ + | { readonly kind: 'shared'; readonly owner: string; readonly why: string } + /** The renderer takes any value on purpose. */ + | { readonly kind: 'any-value'; readonly why: string } + /** A deliberately open bag: the declared members are typed, the rest pass through. */ + | { readonly kind: 'open-bag'; readonly why: string } + /** No reader at the pin, and not visibly forwarded — an open question, not a verdict. */ + | { readonly kind: 'no-reader'; readonly why: string } + /** Read with a fixed shape at the pin (`reader`); typing it is a named later stage. */ + | { readonly kind: 'staged'; readonly stage: Stage; readonly reader: string }; + +const EXPRESSION_AST: Reason = { + kind: 'shared', + owner: 'shared/expression.zod.ts `ExpressionSchema.ast`', + why: 'the engine-native AST `objectstack compile` fills beside `source` — opaque at the spec layer by design; each engine validates its own shape', +}; +const HTTP_REQUEST: Reason = { + kind: 'shared', + owner: 'shared/http.zod.ts `HttpRequestSchema`', + why: 'the query parameters and request body an `api` data source sends, forwarded verbatim to the endpoint the author names; their shape is that endpoint\'s', +}; +const INLINE_JSON_SCHEMA: Reason = { + kind: 'shared', + owner: 'ui/view.zod.ts `ViewDataSchema` (`provider: \'schema\'`)', + why: 'an inline JSON Schema (Draft 2020-12) document: its keywords are JSON Schema\'s, not this spec\'s', +}; +const BULK_OPTION_ENTRY: Reason = { + kind: 'shared', + owner: 'ui/bulk-action.zod.ts `BulkActionDefSchema` `params[].options[]`', + why: 'a deliberately open option entry (`.passthrough()`): the widget reads `color` / `icon` / `disabled` / `visibleWhen` beyond the declared `{ label, value }` pair', +}; +const RECORDS: Reason = { kind: 'records' }; +const SLOT: Reason = { kind: 'slot' }; +const RUNNER: Reason = { kind: 'runner' }; +const staged = (stage: Stage, reader: string): Reason => ({ kind: 'staged', stage, reader }); + +/** `ObjectUI` source paths are at the `.objectui-sha` pin `89cad75d55`. */ +const LEDGER = new Map(); +const on = (types: readonly string[], paths: readonly string[], reason: Reason): void => { + for (const type of types) for (const path of paths) LEDGER.set(`${type} ${path}`, reason); +}; + +const ACTION_RUNNER_PATHS = ['params', 'bodyExtra', 'bodyShape', 'operation', 'patch', 'toast', 'resultDialog', 'onSuccess']; +const VIEW_DATA_TYPES = ['object-grid', 'object-map', 'object-gantt', 'object-tree']; + +// Composition slots. +on(['page:tabs', 'page:accordion'], ['items[].children[]'], SLOT); +on(['page:card'], ['children[]', 'footer[]'], SLOT); +on(['page:footer', 'page:sidebar', 'page:section'], ['children[]'], SLOT); + +// The engine AST beside every evaluated expression's `source`. +on(['page:tabs'], ['items[].visibleWhen.ast'], EXPRESSION_AST); +on(['record:alert', 'action:group', 'action:menu'], ['visible.ast'], EXPRESSION_AST); +on(['action:button', 'action:icon'], ['visible.ast', 'disabled.ast'], EXPRESSION_AST); +on(['record:line_items'], ['columns[].readonlyWhen.ast', 'columns[].requiredWhen.ast'], EXPRESSION_AST); +on(['object-master-detail-form'], ['details[].columns[].readonlyWhen.ast', 'details[].columns[].requiredWhen.ast'], EXPRESSION_AST); +on(['object-grid'], ['conditionalFormatting[].condition.ast', 'bulkActionDefs[].visible.ast'], EXPRESSION_AST); + +// The action blocks' runner-forwarded members. +on(['action:button', 'action:icon'], ACTION_RUNNER_PATHS, RUNNER); + +// The data-source binding every record-source block shares. +on(VIEW_DATA_TYPES, ['data.read.params{}', 'data.read.body', 'data.write.params{}', 'data.write.body'], HTTP_REQUEST); +on(VIEW_DATA_TYPES, ['data.items[]'], RECORDS); +on(VIEW_DATA_TYPES, ['data.schema{}'], INLINE_JSON_SCHEMA); + +// Record rows and field values. +on(['object-grid', 'object-map', 'object-gantt', 'object-tree'], ['staticData[]'], RECORDS); +on(['object-kanban', 'object-timeline'], ['data[]'], RECORDS); +on(['object-calendar'], ['data[]', 'staticData[]'], RECORDS); +on(['object-form', 'object-master-detail-form'], ['initialValues{}', 'initialData{}'], RECORDS); +on(['object-grid'], ['bulkActionDefs[].patch{}', 'bulkActionDefs[].params[].default'], RECORDS); +on(['object-grid'], ['bulkActionDefs[].params[].options[].*'], BULK_OPTION_ENTRY); + +// The rest, one line each. +on(['element:definition-list'], ['items[].description'], { + kind: 'any-value', + why: 'shown as-is — a string or number as text, an object as JSON (`components/src/renderers/basic/data-list.tsx:68`, `toText`)', +}); +on(['object-grid'], ['pagination.*'], { + kind: 'open-bag', + why: '`z.looseObject` on purpose: `pageSize` and `pageSizeOptions` are typed and are the only members a read point names; the member\'s own docblock records why the bag stays open', +}); +on(['ai:chat_window'], ['context{}'], { + kind: 'no-reader', + why: 'objectui registers no `ai:chat_window` renderer (`components/src/renderers/placeholders.tsx:109-113`), so nothing reads this row at all; whether the row retires is its own question', +}); + +// Read with a fixed shape at the pin — the later stages. +on(['object-metric'], ['aggregate'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:250, `.field` / `.function` / `.groupBy` at :404-443')); +on(['object-metric'], ['trend'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:254 (typed :176)')); +on(['object-metric'], ['drillDown'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:265 (`ObjectMetricDrillDownConfig`, :218)')); +on(['object-metric'], ['compareTo'], staged('object-metric', 'plugin-dashboard/src/ObjectMetricWidget.tsx:267 (`CompareToConfig`, :241)')); +on(['object-form'], ['fields[]'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:961')); +on(['object-form'], ['customFields'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:755, :1180')); +on(['object-form'], ['sections[]'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:364, :1518')); +on(['object-form'], ['contentLayout'], staged('object-form', 'plugin-form/src/ModalForm.tsx:854 (`\'simple\' | \'tabbed\'`, :151)')); +on(['object-form'], ['submitBehavior'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:1312-1313')); +on(['object-form'], ['navigateOnSuccess'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:1373, :1412-1424')); +on(['object-form'], ['mobile'], staged('object-form', 'plugin-form/src/ObjectForm.tsx:1857')); +on(['object-master-detail-form'], ['sections[]', 'fields[]'], staged('object-form', 'plugin-form/src/MasterDetailForm.tsx:1692-1693, into the parent form')); +on(['object-grid'], ['columns[]'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:2158 (`normalizeColumns`, `string | ListColumn`)')); +on(['object-grid'], ['fields[]'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:1946')); +on(['object-grid'], ['selection'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:4799-4810 (`.type`)')); +on(['object-grid'], ['selectable'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:4813-4815')); +on(['object-grid'], ['rowActions[]'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:1834-1835 (`string[]`)')); +on(['object-grid'], ['bulkActions[]', 'batchActions[]'], staged('list-family', 'plugin-grid/src/ObjectGrid.tsx:4763 (`batchActions ?? bulkActions`)')); +on(['object-kanban'], ['columns[]'], staged('list-family', 'plugin-kanban/src/KanbanBoardCore.tsx:95')); +on(['object-calendar'], ['calendar'], staged('list-family', 'plugin-calendar/src/ObjectCalendar.tsx:296-297 (`ObjectCalendarConfig`)')); +on(['object-gantt'], ['markers[]'], staged('objectui-held', 'plugin-gantt/src/ObjectGantt.tsx:2497 (`GanttMarker`)')); +on(['object-timeline'], ['items[]'], staged('objectui-held', 'plugin-timeline/src/ObjectTimeline.tsx:587')); +on(['object-timeline'], ['mapping'], staged('objectui-held', 'plugin-timeline/src/ObjectTimeline.tsx:551, :576-579')); +on(['action:group'], ['actions[]{}'], staged('objectui-held', 'components/src/renderers/action/action-group.tsx:303 (`UIActionSchema[]`)')); +on(['action:menu'], ['actions[]{}'], staged('objectui-held', 'components/src/renderers/action/action-menu.tsx:339 (`UIActionSchema[]`)')); +// The list view's own `conditionalFormatting` is the by-reference shape, as on +// `object-grid` (#21445) — but objectui's own kanban fixtures author both rule +// dialects it refuses (`plugin-kanban/src/__tests__/ObjectKanban. +// structuredMembersReachTheirSinks-8313.test.tsx:463-485`, +// `types/src/__tests__/kanban-conditional-formatting.test.ts:29-52`), so the +// narrowing is reported for a ruling instead of shipped. +on(['object-kanban'], ['conditionalFormatting'], staged('held-for-decision', 'plugin-kanban/src/KanbanBoardCore.tsx:114, evaluated at KanbanImpl.tsx:179 (`resolveConditionalFormatting`)')); + +/** Every `z.unknown()` member of every row, keyed as the ledger keys it. */ +function census(): Map { + const members = new Map(); + for (const [type, row] of Object.entries(ComponentPropsMap)) { + for (const member of unknownMembers(row)) members.set(`${type} ${member.path}`, member); + } + return members; +} + +// ─────────────────────────────────────────────────────────────────────────── +// §1 the enumeration +// ─────────────────────────────────────────────────────────────────────────── + +describe('§1 every `z.unknown()` member across ComponentPropsMap is typed, or listed with its reason', () => { + it('no `z.unknown()` member is missing from the ledger', () => { + const unlisted = [...census().keys()].filter((key) => !LEDGER.has(key)); + expect( + unlisted, + 'A `z.unknown()` member with no recorded reason. A member a renderer reads with a fixed shape is TYPED ' + + '(by reference where a schema already declares it, otherwise to the renderer\'s read at the ' + + '`.objectui-sha` pin); a member handed verbatim to a runner keeps `z.unknown()` with that reason in its ' + + '`.describe()` and a `runner` line here.', + ).toEqual([]); + }); + + it('no ledger line outlives its member — a typed member deletes its line', () => { + const members = census(); + expect([...LEDGER.keys()].filter((key) => !members.has(key))).toEqual([]); + }); + + it('reaches every row: the walk visits the whole map', () => { + // LIT CONTROL for the two above: a walk that saw no rows would pass both + // only if the ledger were empty, and it is not. + expect(LEDGER.size).toBeGreaterThan(0); + expect(census().size).toBe(LEDGER.size); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §2 each reason, checked against the schema where it can be +// ─────────────────────────────────────────────────────────────────────────── + +describe('§2 each recorded reason holds', () => { + const members = census(); + const entries = [...LEDGER.entries()]; + + it('a `slot` member is a declared slot position', () => { + const positions = new Set(pageComponentSlotPositions().filter((p) => !p.retired) + .map((p) => (p.panelKey ? `${p.key}[].${p.panelKey}` : p.key))); + const notSlots = entries + .filter(([, reason]) => reason.kind === 'slot') + .map(([key]) => key) + .filter((key) => !positions.has(key.slice(key.indexOf(' ') + 1).replace(/\[\]$/, ''))); + expect(notSlots).toEqual([]); + }); + + it('a `runner` member says so in its own `.describe()`', () => { + const silent = entries + .filter(([, reason]) => reason.kind === 'runner') + .map(([key]) => key) + .filter((key) => !/forwarded to the runner/.test(members.get(key)?.describe ?? '')); + expect(silent).toEqual([]); + }); + + it('a `staged` member names a declared stage and a reader at the pin', () => { + for (const [key, reason] of entries) { + if (reason.kind !== 'staged') continue; + expect(Object.keys(STAGES), key).toContain(reason.stage); + expect(reason.reader, key).toMatch(/\.tsx?:\d/); + } + }); + + it('a `shared` member really is the owner\'s: an `ast` beside a `source`, a request beside a `url`', () => { + for (const [key, reason] of entries) { + if (reason !== EXPRESSION_AST) continue; + expect(key, 'an expression-AST line names the `ast` member').toMatch(/\.ast$/); + } + for (const [key, reason] of entries) { + if (reason !== HTTP_REQUEST) continue; + expect(key, 'an HTTP-request line names `params` or `body` under `read` / `write`').toMatch(/ data\.(read|write)\.(params\{\}|body)$/); + } + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §3 the walk can fail +// ─────────────────────────────────────────────────────────────────────────── + +describe('§3 the walk finds a `z.unknown()` in every position it claims to walk', () => { + it('a plain member, an array element, a record value, a union arm, a lazy schema and a catchall', () => { + const probe = z.object({ + plain: z.unknown().optional().describe('said here'), + list: z.array(z.unknown()), + bag: z.record(z.string(), z.unknown()), + either: z.union([z.string(), z.object({ deep: z.unknown() })]), + later: z.lazy(() => z.object({ inner: z.unknown() })), + open: z.looseObject({ typed: z.string() }), + typed: z.string(), + }); + expect(unknownMembers(probe)).toEqual([ + { path: 'plain', describe: 'said here' }, + { path: 'list[]', describe: undefined }, + { path: 'bag{}', describe: undefined }, + { path: 'either.deep', describe: undefined }, + { path: 'later.inner', describe: undefined }, + { path: 'open.*', describe: undefined }, + ]); + }); +}); + +// ─────────────────────────────────────────────────────────────────────────── +// §4 the members this card types +// ─────────────────────────────────────────────────────────────────────────── + +describe('§4 `navigation` on object-map / object-gantt / object-tree is the list view\'s NavigationConfigSchema', () => { + const ROWS = [ + ['object-map', ObjectMapPropsSchema], + ['object-gantt', ObjectGanttPropsSchema], + ['object-tree', ObjectTreePropsSchema], + ] as const; + const BASE = { objectName: 'account' } as const; + + /** The issue codes and paths a refusal carries, so a refusal for the WRONG reason reds. */ + const issues = (result: z.ZodSafeParseResult): { code: string; path: string }[] => + result.success ? [] : result.error.issues.map((i) => ({ code: i.code, path: i.path.join('.') })); + + for (const [type, schema] of ROWS) { + const row = () => ComponentPropsMap[type]; + + it(`${type}: unwraps to NavigationConfigSchema — the same def`, () => { + expect(schema.shape.navigation.unwrap()._zod.def).toBe(NavigationConfigSchema._zod.def); + }); + + it(`${type}: parses a navigation block to exactly what NavigationConfigSchema answers`, () => { + const navigation = { mode: 'drawer', size: 'lg' }; + const r = row().safeParse({ ...BASE, navigation }); + expect(issues(r)).toEqual([]); + expect(r.success && (r.data as { navigation?: unknown }).navigation) + .toStrictEqual(NavigationConfigSchema.parse(navigation)); + }); + + for (const mode of NavigationConfigSchema.shape.mode.unwrap().options) { + it(`${type}: parses mode '${mode}'`, () => { + expect(issues(row().safeParse({ ...BASE, navigation: { mode } }))).toEqual([]); + }); + } + + const REFUSED: ReadonlyArray = [ + ['a number', 42, 'invalid_type', 'navigation'], + ['a bare mode string', 'drawer', 'invalid_type', 'navigation'], + ['an unknown mode', { mode: 'tab' }, 'invalid_value', 'navigation.mode'], + ['an undeclared key', { mode: 'drawer', target: '_blank' }, 'unrecognized_keys', 'navigation'], + ]; + for (const [label, navigation, code, path] of REFUSED) { + it(`${type}: refuses ${label} — ${code} at ${path}`, () => { + const r = row().safeParse({ ...BASE, navigation }); + expect(r.success).toBe(false); + expect(issues(r)).toEqual([{ code, path }]); + }); + } + + it(`${type}: an absent navigation stays absent`, () => { + const r = row().safeParse(BASE); + expect(issues(r)).toEqual([]); + expect(r.success && r.data).not.toHaveProperty('navigation'); + }); + } +}); diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 9b6bb1dd97d..58b6e27fbce 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -5668,8 +5668,20 @@ export const ObjectMapPropsSchema = lazySchema(() => strictObject({ .describe('Map field config, the author face — the same block `ListViewSchema.map` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored'), mapStyle: z.string().optional() .describe('MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record'), - navigation: z.unknown().optional() - .describe('Marker-click navigation config ({ mode: page | drawer | modal | split | popover | new_window | none }) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema'), + /** + * [#21464] The list view's own {@link NavigationConfigSchema}, by reference + * — the carrier `object-grid`, `object-kanban`, `object-calendar` and + * `object-timeline` already take. `ObjectMap.tsx:1189` (at the pin + * `89cad75d55`) hands `schema.navigation` to `useNavigationOverlay`, which + * reads `navigation?.mode ?? 'page'` + * (`react/src/hooks/useNavigationOverlay.ts:364`) and types its mode union + * as that schema's. + * Until #21464 this member was `z.unknown()`, so `navigation: 42` and a bare + * mode string such as `'drawer'` passed every door and opened the record + * page, whatever they named. + */ + navigation: NavigationConfigSchema.optional() + .describe('Marker-click navigation config — the same block `ListViewSchema.navigation` declares ({ mode, size, openNewTab, preventNavigation }), `mode` one of the seven `NavigationModeSchema` values'), enableClustering: z.boolean().optional() .describe('Group nearby markers into clusters. Absent, the renderer clusters only above 100 markers'), })); @@ -5679,7 +5691,9 @@ export type ObjectMapProps = z.input; * ADR-0122: the parsed state differs from the authored state — `filter` carries * `z.array(ViewFilterRuleSchema)` (`operator` normalizes on parse) and `data` * carries `ViewDataSchema`, so this block leaves the type-alias convention pin's - * default-free family the way `object-grid` did. + * default-free family the way `object-grid` did. Since #21464 `navigation` + * carries {@link NavigationConfigSchema} too, whose defaulted members + * materialize on parse on a document that authored the key. */ export type ObjectMapPropsParsed = z.infer; @@ -5881,8 +5895,19 @@ export const ObjectGanttPropsSchema = lazySchema(() => strictObject({ .describe('Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array`'), gantt: GanttConfigSchema.optional() .describe('Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored'), - navigation: z.unknown().optional() - .describe('Task-click navigation config ({ mode: page | drawer | modal | split | popover | new_window | none }) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema; renderer default `drawer`'), + /** + * [#21464] The list view's own {@link NavigationConfigSchema}, by reference + * — the carrier `object-grid`, `object-kanban`, `object-calendar` and + * `object-timeline` already take. `ObjectGantt.tsx:1960` (at the pin + * `89cad75d55`) reads `schema.navigation ?? { mode: 'drawer' }`, classifies + * its `.mode` there, and hands it to `useNavigationOverlay` (`:2025-2026`), + * which types its mode union as that schema's. Until #21464 this member was + * `z.unknown()`, so `navigation: 42` and a bare mode string such as + * `'drawer'` passed every door and opened the record page, whatever they + * named. + */ + navigation: NavigationConfigSchema.optional() + .describe("Task-click navigation config — the same block `ListViewSchema.navigation` declares ({ mode, size, openNewTab, preventNavigation }), `mode` one of the seven `NavigationModeSchema` values. The renderer's own default is `{ mode: 'drawer' }` when the key is absent"), label: I18nLabelSchema.optional() .describe('Gantt label — the second link of the exported PNG/PDF file-name chain, after `gantt.exportFileName` and before the bound object\'s own label'), skipWeekends: z.boolean().optional() @@ -5910,7 +5935,9 @@ export type ObjectGanttProps = z.input; * ADR-0122: the parsed state differs from the authored state — `filter` carries * `z.array(ViewFilterRuleSchema)` (`operator` normalizes on parse) and `data` * carries `ViewDataSchema`, so this block leaves the type-alias convention pin's - * default-free family the way `object-grid` did. + * default-free family the way `object-grid` did. Since #21464 `navigation` + * carries {@link NavigationConfigSchema} too, whose defaulted members + * materialize on parse on a document that authored the key. */ export type ObjectGanttPropsParsed = z.infer; @@ -6206,8 +6233,20 @@ export const ObjectTreePropsSchema = lazySchema(() => strictObject({ .describe('Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array`'), tree: TreeConfigSchema.optional() .describe('Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: { parentField?, labelField?, fields?, defaultExpandedDepth? }. `parentField` auto-detects from the object schema when omitted'), - navigation: z.unknown().optional() - .describe('Row-click navigation config ({ mode: page | drawer | modal | split | popover | new_window | none }) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema'), + /** + * [#21464] The list view's own {@link NavigationConfigSchema}, by reference + * — the carrier `object-grid`, `object-kanban`, `object-calendar` and + * `object-timeline` already take, and the type objectui's own + * `ObjectTreeSchema.navigation` mirrors (objectui#11168 slice 3). + * `ObjectTree.tsx:1054` (at the pin `89cad75d55`) hands `schema.navigation` + * to `useNavigationOverlay` (`:1046`), which reads `navigation?.mode ?? + * 'page'` and types its mode union as that schema's. Until #21464 this + * member was `z.unknown()`, so `navigation: 42` and a bare mode string such + * as `'drawer'` passed every door and opened the record page, whatever they + * named. + */ + navigation: NavigationConfigSchema.optional() + .describe('Row-click navigation config — the same block `ListViewSchema.navigation` declares ({ mode, size, openNewTab, preventNavigation }), `mode` one of the seven `NavigationModeSchema` values'), })); /** Author state (ADR-0122: the bare name is the author state). */ export type ObjectTreeProps = z.input; @@ -6215,7 +6254,9 @@ export type ObjectTreeProps = z.input; * ADR-0122: the parsed state differs from the authored state — `filter` carries * `z.array(ViewFilterRuleSchema)` (`operator` normalizes on parse) and `data` * carries `ViewDataSchema`, so this block leaves the type-alias convention pin's - * default-free family the way `object-grid` did. + * default-free family the way `object-grid` did. Since #21464 `navigation` + * carries {@link NavigationConfigSchema} too, whose defaulted members + * materialize on parse on a document that authored the key. */ export type ObjectTreePropsParsed = z.infer; From 527957de245c3d76b5722d5aa2651a9bf2e6ac25 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:36:58 +0000 Subject: [PATCH 2/5] wip: ADR-0087 D3 entry, step-18 rationale and changeset for the navigation narrowing (#21464) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .../21464-component-props-navigation-typed.md | 33 ++++++++++++ ...-object-map-gantt-tree-navigation-typed.ts | 41 +++++++++++++++ packages/spec/src/migrations/registry.ts | 50 +++++++++++++++++++ ...omponent-props-unknown-members.pin.test.ts | 7 ++- 4 files changed, 130 insertions(+), 1 deletion(-) create mode 100644 .changeset/21464-component-props-navigation-typed.md create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-object-map-gantt-tree-navigation-typed.ts diff --git a/.changeset/21464-component-props-navigation-typed.md b/.changeset/21464-component-props-navigation-typed.md new file mode 100644 index 00000000000..9afdef6faf0 --- /dev/null +++ b/.changeset/21464-component-props-navigation-typed.md @@ -0,0 +1,33 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: `navigation` on an `object-map`, `object-gantt` or `object-tree` page block takes the list view's navigation block instead of any value, and every remaining `z.unknown()` member of `ComponentPropsMap` is enumerated with its recorded reason (#21464) + +Clause-②: yes (narrowing) + + + +**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 row: 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`** + +- **`navigation` is typed on three rows.** `ComponentPropsMap['object-map']`, `['object-gantt']` and `['object-tree']` declared `navigation` as `z.unknown()`, although each renderer hands it to the console's shared navigation hook, which reads `navigation.mode` and falls back to `page` when it finds none. Any value passed, and an off-shape one was answered with a silent default: `navigation: 42` and a bare mode string such as `'drawer'` both opened the record page, whatever they named. Each row now takes the list view's `NavigationConfigSchema` by reference, the same block `object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take: `{ mode?, size?, openNewTab?, preventNavigation? }`, with `mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none`. +- **`ObjectMapProps`, `ObjectGanttProps` and `ObjectTreeProps`** (and their `…Parsed` twins) carry `NavigationConfig` on `navigation` instead of `unknown`. +- **No other member changes.** Every other `z.unknown()` member across `ComponentPropsMap` is now listed, with its recorded reason, by a test that fails on a new one until it carries one: composition slots, the action blocks' runner-forwarded members, record rows and field values, members of schemas another file owns, and 28 members a renderer reads with a fixed shape whose typing is staged into later changes. + +## FROM → TO + +| you wrote on an `object-map` / `object-gantt` / `object-tree` | write instead | +|:--|:--| +| `navigation: 'drawer'` | `navigation: { mode: 'drawer' }` | +| `navigation: { mode: 'tab' }` | a mode the hook knows: `page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none` | +| `navigation: { mode: 'drawer', target: '_blank' }` | `navigation: { mode: 'new_window' }`, or `openNewTab: true` beside a `page` mode | + +The one-line fix: write `navigation` as the block a list view declares, `{ mode, size?, openNewTab?, preventNavigation? }`. No conversion is registered, because an off-shape value has no rewrite that both keeps what the block shows today (the record page) and honours what the author wrote; the D3 entry `ui-object-map-gantt-tree-navigation-typed` carries that judgment. + +## Who is affected, measured + +- **objectstack.** Measured on this branch, based on `origin/main` `aa4632235b`: no `object-map`, `object-gantt` or `object-tree` block authors `navigation` in the examples, `packages/apps`, `@objectstack/platform-objects`, the plugins and services, the spec tests, the documentation or the published skills. The control: the same census finds the blocks' `objectName`. +- **objectui.** Measured at the `.objectui-sha` pin, over the 88 / 98 / 59 files that name `object-map` / `object-gantt` / `object-tree`: every authored `navigation` is `{ mode }` with one of the seven modes, some with `size: 'lg'`, and each parses. The one non-object value, `navigation: 'anything'`, is a parity probe in objectui's own mirror tests, which assert that both faces answer alike; it authors nothing. +- **Deployed metadata** was not measured. diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-object-map-gantt-tree-navigation-typed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-object-map-gantt-tree-navigation-typed.ts new file mode 100644 index 00000000000..e9bddf3dbae --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-object-map-gantt-tree-navigation-typed.ts @@ -0,0 +1,41 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +// #21464 — `navigation` on the `object-map`, `object-gantt` and `object-tree` +// page blocks was `z.unknown()` although each renderer hands it to the shared +// navigation hook, which reads `.mode` and types its mode union as the list +// view's `NavigationConfigSchema`; any value passed and an off-shape one opened +// the record page in silence. The three rows now take that schema by reference, +// the carrier `object-grid`, `object-kanban`, `object-calendar` and +// `object-timeline` already take. D3 only: page-component `properties` is not +// parsed on the metadata save or load path, so a stored page is never refused; +// an off-shape value has no rewrite that says what the author meant; and the +// authored census found nothing in either repository's corpora to respell. +export const entry: SemanticMigration = { + id: 'ui-object-map-gantt-tree-navigation-typed', + surface: 'page `object-map`, `object-gantt` and `object-tree` components — `properties.navigation` ' + + '(which used to accept any value)', + replacement: 'the list view\'s navigation block `{ mode?, size?, openNewTab?, preventNavigation? }`, ' + + '`mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window`, `none` — the block ' + + '`object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take. Rewrite a bare ' + + 'mode string such as `navigation: \'drawer\'` as `navigation: { mode: \'drawer\' }`.', + reason: 'Each of the three renderers hands `navigation` to the shared navigation hook, which reads ' + + '`navigation.mode`, falls back to `page` when it finds none, and types its mode union as the list ' + + 'view\'s `NavigationConfigSchema`. The rows declared the member `z.unknown()`, so any value passed ' + + 'the component-props gate and an off-shape one was answered with a silent default: `navigation: 42` ' + + 'and a bare mode string such as `\'drawer\'` both opened the record page, whatever they named. The ' + + 'three rows now take the list view\'s schema by reference, so one value is judged the same way on ' + + 'every door that carries it. It is read where every page component\'s props are: the ' + + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' + + '`component-props-unknown-key` 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, and an off-shape value has no rewrite that both keeps ' + + 'what the block shows today (the record page) and honours what the author wrote — which is the ' + + 'judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-map`, `object-gantt` and `object-tree` node validates: ' + + '`objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` ' + + 'finding under `properties.navigation`. Each block that set `navigation` opens the mode it names ' + + 'on a marker, task or row click.', +}; diff --git a/packages/spec/src/migrations/registry.ts b/packages/spec/src/migrations/registry.ts index e0f36abbb5d..737d2da2320 100644 --- a/packages/spec/src/migrations/registry.ts +++ b/packages/spec/src/migrations/registry.ts @@ -6117,6 +6117,19 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'the component-props gate (advisory); a stored page still saves and loads, so no conversion ' + 'is registered. Its D3 record is the semantic entry `ui-object-grid-row-members-typed`.', }, + { + id: 'ui-object-map-gantt-tree-navigation-typed', + order: 64, + text: + 'It also types `navigation` on the `object-map`, `object-gantt` and `object-tree` page blocks ' + + '(#21464, the first stage of the `ComponentPropsMap` `z.unknown()` close-out): each renderer ' + + 'hands it to the shared navigation hook, which reads `navigation.mode` and falls back to `page`, ' + + 'so `navigation: 42` and a bare mode string passed every door and opened the record page. The ' + + 'three rows now take the list view\'s `NavigationConfigSchema` by reference, the carrier the grid, ' + + 'kanban, calendar and timeline blocks already take. Read by the component-props gate ' + + '(advisory); a stored page still saves and loads, so no conversion is registered. Its D3 record ' + + 'is the semantic entry `ui-object-map-gantt-tree-navigation-typed`.', + }, { id: 'ui-object-master-detail-form-details-closed', order: 56, @@ -19397,6 +19410,43 @@ const step18: MigrationStep = { + 'its `colors` map names, the navigation mode on a row click, the conditional styles, the bulk ' + 'actions, the group-header numbers and the affordances `operations` names.', }, + // #21464 — `navigation` on the `object-map`, `object-gantt` and `object-tree` + // page blocks was `z.unknown()` although each renderer hands it to the shared + // navigation hook, which reads `.mode` and types its mode union as the list + // view's `NavigationConfigSchema`; any value passed and an off-shape one opened + // the record page in silence. The three rows now take that schema by reference, + // the carrier `object-grid`, `object-kanban`, `object-calendar` and + // `object-timeline` already take. D3 only: page-component `properties` is not + // parsed on the metadata save or load path, so a stored page is never refused; + // an off-shape value has no rewrite that says what the author meant; and the + // authored census found nothing in either repository's corpora to respell. + { + id: 'ui-object-map-gantt-tree-navigation-typed', + surface: 'page `object-map`, `object-gantt` and `object-tree` components — `properties.navigation` ' + + '(which used to accept any value)', + replacement: 'the list view\'s navigation block `{ mode?, size?, openNewTab?, preventNavigation? }`, ' + + '`mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window`, `none` — the block ' + + '`object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take. Rewrite a bare ' + + 'mode string such as `navigation: \'drawer\'` as `navigation: { mode: \'drawer\' }`.', + reason: 'Each of the three renderers hands `navigation` to the shared navigation hook, which reads ' + + '`navigation.mode`, falls back to `page` when it finds none, and types its mode union as the list ' + + 'view\'s `NavigationConfigSchema`. The rows declared the member `z.unknown()`, so any value passed ' + + 'the component-props gate and an off-shape one was answered with a silent default: `navigation: 42` ' + + 'and a bare mode string such as `\'drawer\'` both opened the record page, whatever they named. The ' + + 'three rows now take the list view\'s schema by reference, so one value is judged the same way on ' + + 'every door that carries it. It is read where every page component\'s props are: the ' + + 'component-props gate reports a refused value as an advisory `component-props-invalid` / ' + + '`component-props-unknown-key` 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, and an off-shape value has no rewrite that both keeps ' + + 'what the block shows today (the record page) and honours what the author wrote — which is the ' + + 'judgment this entry leaves to the upgrader. Deployed metadata NOT MEASURED.', + acceptanceCriteria: 'Every `object-map`, `object-gantt` and `object-tree` node validates: ' + + '`objectstack validate` reports no `component-props-invalid` / `component-props-unknown-key` ' + + 'finding under `properties.navigation`. Each block that set `navigation` opens the mode it names ' + + 'on a marker, task or row click.', + }, // #20928 — the third carrier of the inline grid column. An // `object-master-detail-form` page block's `details` was `z.array(z.unknown())` // while the other two carriers — a relationship field's `inlineColumns` and a diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index 61b94171a09..4429291a631 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -28,7 +28,7 @@ * - §4 THE MEMBERS THIS CARD TYPES: `navigation` on `object-map`, * `object-gantt` and `object-tree` is the list view's * `NavigationConfigSchema`, by identity, and refuses an off-shape value with - * the code AND the path. + * the code AND the path; its ADR-0087 D3 entry is registered. * * ## The STAGED reason is debt, not a verdict * @@ -52,6 +52,7 @@ import { pageComponentSlotPositions, } from './component.zod'; import { NavigationConfigSchema } from './view.zod'; +import { MIGRATIONS_BY_MAJOR } from '../migrations/registry'; // ─────────────────────────────────────────────────────────────────────────── // The walk @@ -419,4 +420,8 @@ describe('§4 `navigation` on object-map / object-gantt / object-tree is the lis expect(r.success && r.data).not.toHaveProperty('navigation'); }); } + + it('is registered as the ADR-0087 D3 entry step 18 carries', () => { + expect(MIGRATIONS_BY_MAJOR[18]!.semantic.map((s) => s.id)).toContain('ui-object-map-gantt-tree-navigation-typed'); + }); }); From 9a8037abbe68c96c749e5c84dab95fb1c3cd0c7e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 22:44:25 +0000 Subject: [PATCH 3/5] chore(spec): regenerate the component references page for the typed navigation members (#21464) Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 39 ++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 3 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index d6e0a544082..b8ab062e826 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -584,7 +584,7 @@ Sort field and direction pair | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | -| **navigation** | `any` | optional | Task-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| new_window \| none }`) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema; renderer default `drawer` | +| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Task-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values. The renderer's own default is `{ mode: 'drawer' }` when the key is absent | | **label** | `string \| Record` | optional | Gantt label — the second link of the exported PNG/PDF file-name chain, after `gantt.exportFileName` and before the bound object's own label | | **skipWeekends** | `boolean` | optional | Measure duration and auto-schedule math in WORKING days, skipping Saturdays and Sundays | | **holidays** | `string[]` | optional | Additional non-working dates for the working calendar, ISO `yyyy-mm-dd` strings; folded into a Set for the duration math | @@ -679,6 +679,17 @@ Sort field and direction pair | **interactions** | `{ move?: boolean; resize?: boolean; progress?: boolean; link?: boolean }` | optional | Per-interaction switches, each defaulting to true: allow bar moves but pin durations (resize: false), or keep the dependency UI read-only (link: false). They only narrow what readOnly and row locks already allow | | **timeSegments** | `{ dayStart?: string; bands: object[]; showMidnight?: boolean }` | optional | Shift segmentation for the day-mode timeline: splits each shift-day (starting at dayStart) into the configured bands — a two-tier header (date over band), per-band tints and drag/resize snapping to band boundaries. No shift concept is hardcoded; bands are pure config. Off when omitted | +### Nested Shape: `ObjectGanttProps.navigation` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | | +| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). | +| **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | +| **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | +| **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. | + --- @@ -940,7 +951,7 @@ View filter rule | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map field config, the author face — the same block `ListViewSchema.map` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | | **mapStyle** | `string` | optional | MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record | -| **navigation** | `any` | optional | Marker-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| new_window \| none }`) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema | +| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Marker-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values | | **enableClustering** | `boolean` | optional | Group nearby markers into clusters. Absent, the renderer clusters only above 100 markers | ### Nested Shape: `ObjectMapProps.data[provider='object']` @@ -1005,6 +1016,17 @@ Sort field and direction pair | **center** | `[number, number]` | optional | Initial camera center as [latitude, longitude]. Omit to let the renderer fit the camera to the queried records | | **style** | `string` | optional | Map style URL — the MapLibre style document the renderer loads in place of the public demo tiles. The component-level `mapStyle` is read FIRST and wins when both are present; this is NOT the inline CSS `style` record a component node carries | +### Nested Shape: `ObjectMapProps.navigation` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | | +| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). | +| **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | +| **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | +| **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. | + --- @@ -1164,7 +1186,7 @@ Sort field and direction pair | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted | -| **navigation** | `any` | optional | Row-click navigation config (`{ mode: page \| drawer \| modal \| split \| popover \| new_window \| none }`) — all seven `NavigationModeSchema` values, since the shared `useNavigationOverlay` hook types its own mode union as that schema | +| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values | ### Nested Shape: `ObjectTreeProps.data[provider='object']` @@ -1215,6 +1237,17 @@ View filter rule | **fields** | `string[]` | optional | Additional fields rendered as flat columns alongside the label | | **defaultExpandedDepth** | `integer` | optional | Initial expansion depth (0 = roots only; omit = expand all) | +### Nested Shape: `ObjectTreeProps.navigation` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **mode** | `Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>` | optional (default: `"page"`) | | +| **view** | `never` | optional | [REMOVED] `view.list.navigation.view` was removed in @objectstack/spec 17.5.0 (ADR-0049 enforce-or-remove) — it named the form view to open for a record detail, and no layer resolved a view by that name: the value was passed straight into the navigation-MODE argument of the console's `onNavigate`, where anything other than `edit` or `view` matched no branch, so the key selected nothing and could silently deaden the row click. Delete the key; to choose what opens for a record, assign a `record` page to the object and let `isDefault` pick the one that opens — page assignment is the machinery that resolves a detail layout, and a list view's navigation block only decides HOW the detail is surfaced (`mode`, `size`). | +| **preventNavigation** | `boolean` | optional (default: `false`) | Disable standard navigation entirely | +| **openNewTab** | `boolean` | optional (default: `false`) | Force open in new tab (applies to page mode) | +| **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. | + --- From d3431deed339c1825fa5831af79cd92e0f156e83 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 2 Oct 2026 23:41:48 +0000 Subject: [PATCH 4/5] docs(spec): finish the navigation narrowing's changeset and the pin's stage reasons, re-measured on the merged head (#21464) Completes the wip commit 527957de24. The D3 entry and the step-18 rationale fragment stand as written (order 64 is still one above the highest after merging origin/main 100c394f6f). The changeset's measured census is re-anchored to the merged head, with its controls, and its summary now matches the ledger: 107 listed members, 28 renderer-read, 27 staged and one held for a ruling. The pin's object-metric and objectui-held stage reasons now name the real by-reference candidates. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- .changeset/21464-component-props-navigation-typed.md | 6 +++--- .../spec/src/ui/component-props-unknown-members.pin.test.ts | 4 ++-- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/.changeset/21464-component-props-navigation-typed.md b/.changeset/21464-component-props-navigation-typed.md index 9afdef6faf0..1b4b151e4f6 100644 --- a/.changeset/21464-component-props-navigation-typed.md +++ b/.changeset/21464-component-props-navigation-typed.md @@ -14,7 +14,7 @@ Clause-②: yes (narrowing) - **`navigation` is typed on three rows.** `ComponentPropsMap['object-map']`, `['object-gantt']` and `['object-tree']` declared `navigation` as `z.unknown()`, although each renderer hands it to the console's shared navigation hook, which reads `navigation.mode` and falls back to `page` when it finds none. Any value passed, and an off-shape one was answered with a silent default: `navigation: 42` and a bare mode string such as `'drawer'` both opened the record page, whatever they named. Each row now takes the list view's `NavigationConfigSchema` by reference, the same block `object-grid`, `object-kanban`, `object-calendar` and `object-timeline` already take: `{ mode?, size?, openNewTab?, preventNavigation? }`, with `mode` one of `page`, `drawer`, `modal`, `split`, `popover`, `new_window` or `none`. - **`ObjectMapProps`, `ObjectGanttProps` and `ObjectTreeProps`** (and their `…Parsed` twins) carry `NavigationConfig` on `navigation` instead of `unknown`. -- **No other member changes.** Every other `z.unknown()` member across `ComponentPropsMap` is now listed, with its recorded reason, by a test that fails on a new one until it carries one: composition slots, the action blocks' runner-forwarded members, record rows and field values, members of schemas another file owns, and 28 members a renderer reads with a fixed shape whose typing is staged into later changes. +- **No other member changes.** Every other `z.unknown()` member across `ComponentPropsMap` (107 of them) is now listed, with its recorded reason, by a test that fails on a new one until it carries one. The reasons are composition slots, the action blocks' runner-forwarded members, record rows and field values, members of schemas another file owns, a value shown as-is, a deliberately open bag, and a row no renderer draws. The list also holds 28 members a renderer reads with a fixed shape. 27 of them are typed in later changes, and one, `object-kanban`'s `conditionalFormatting`, waits for a ruling, because the console's own kanban fixtures author two rule dialects the list view's schema refuses. ## FROM → TO @@ -28,6 +28,6 @@ The one-line fix: write `navigation` as the block a list view declares, `{ mode, ## Who is affected, measured -- **objectstack.** Measured on this branch, based on `origin/main` `aa4632235b`: no `object-map`, `object-gantt` or `object-tree` block authors `navigation` in the examples, `packages/apps`, `@objectstack/platform-objects`, the plugins and services, the spec tests, the documentation or the published skills. The control: the same census finds the blocks' `objectName`. -- **objectui.** Measured at the `.objectui-sha` pin, over the 88 / 98 / 59 files that name `object-map` / `object-gantt` / `object-tree`: every authored `navigation` is `{ mode }` with one of the seven modes, some with `size: 'lg'`, and each parses. The one non-object value, `navigation: 'anything'`, is a parity probe in objectui's own mirror tests, which assert that both faces answer alike; it authors nothing. +- **objectstack.** Measured on this branch after merging `origin/main` `100c394f6f`, over the 5 files per row that name `object-map` / `object-gantt` / `object-tree` in the examples, `packages/apps`, `@objectstack/platform-objects`, the plugins and services, the spec sources, the documentation and the published skills: no block of the three authors `navigation`. The one file that co-mentions a row and a `navigation:` key writes app navigation arrays, not this member. The control: the same census finds `objectName` in 4 of the 5 files per row. +- **objectui.** Measured at the `.objectui-sha` pin, over the 88 / 98 / 59 files that name `object-map` / `object-gantt` / `object-tree` (the control: `objectName` in 55 / 70 / 40 of them): every authored `navigation` is `{ mode }` with one of the seven modes, some with `size: 'lg'` or `openNewTab`, and each of those parses on all three rows. The non-object values are probes that expect a refusal: `navigation: 'anything'` in objectui's mirror tests, which assert that both faces answer alike, and a `navigation: 'drawer'` under a `@ts-expect-error`. Neither authors anything. - **Deployed metadata** was not measured. diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index 4429291a631..03b1bf90ece 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -116,10 +116,10 @@ function unknownMembers(schema: unknown): UnknownMember[] { * would refuse a measured writer is reported, not shipped. */ const STAGES = { - 'object-metric': 'the metric tile\'s four config blocks; the dashboard widget schemas are the by-reference candidates', + 'object-metric': 'the metric tile\'s four config blocks; the dashboard widget\'s `compareTo` and the chart\'s `aggregate` / `drillDown` are the by-reference candidates, and `trend` has no spec declaration, so it is typed to the renderer\'s read', 'object-form': 'the form and master-detail form rows; `FormViewSchema` (`sections`, `submitBehavior`) is the by-reference candidate', 'list-family': 'the grid, kanban and calendar list members; `ListViewSchema` (`columns`, `selection`, `rowActions`, `bulkActions`, `calendar`) is the by-reference candidate', - 'objectui-held': 'element contracts whose only declaration is still objectui\'s (`GanttMarker`, `TimelineMappingSchema`, the timeline items, `UIActionSchema`)', + 'objectui-held': 'element contracts whose only declaration is still objectui\'s (`GanttMarker`, `TimelineMappingSchema`, the timeline items, and `UIActionSchema`, an objectui interface that borrows some members from the spec `Action`); the spec declares each first, contract-first, then the row takes it', 'held-for-decision': 'a by-reference shape exists, but measured writers author values it refuses — the narrowing waits for a ruling', } as const; type Stage = keyof typeof STAGES; From c222ab563b65319ac713f766ad58b088ba46890a Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 3 Oct 2026 01:06:42 +0000 Subject: [PATCH 5/5] chore(spec): regenerate the component references page on the merged tree, so the typed navigation cells take main's optionality face (#21464) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The merge of origin/main 85e29b8858 brought fd96a8473d, which renders every defaulted member of a shared def with `?` in a Type-cell shape summary. The page was regenerated from the merged sources with `check:generated --fix` (gen:docs, the only stale artifact): the three new navigation cells on ObjectGanttProps, ObjectMapProps and ObjectTreeProps now read `{ mode?: …; preventNavigation?: …; openNewTab?: …; size?: …; … }`, like the grid, kanban, calendar and timeline cells. The branch's delta against main is unchanged: the same six files, +637/-12. Claude-Session: https://claude.ai/code/session_01YDt3PzwfrkuFzUBF89WPmM Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 34 ++++++++++++------------ 1 file changed, 17 insertions(+), 17 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index b8ab062e826..bd4a6c69daf 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -488,7 +488,7 @@ Sort field and direction pair | **staticData** | `any[]` | optional | Static inline records | | **locale** | `string` | optional | Locale override for the calendar chrome | | **loading** | `boolean` | optional | External loading state (honoured only alongside `data`) | -| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Event-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`). The renderer's own default is `{ mode: 'drawer' }` when the key is absent; it is documented rather than declared, so a parsed calendar carries the key only when the author wrote it | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Event-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`). The renderer's own default is `{ mode: 'drawer' }` when the key is absent; it is documented rather than declared, so a parsed calendar carries the key only when the author wrote it | ### Nested Shape: `ObjectCalendarProps.filter[number]` @@ -584,7 +584,7 @@ Sort field and direction pair | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Task order for the fetched bars — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration, the author face — the same block `ListViewSchema.gantt` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | -| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Task-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values. The renderer's own default is `{ mode: 'drawer' }` when the key is absent | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Task-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values. The renderer's own default is `{ mode: 'drawer' }` when the key is absent | | **label** | `string \| Record` | optional | Gantt label — the second link of the exported PNG/PDF file-name chain, after `gantt.exportFileName` and before the bound object's own label | | **skipWeekends** | `boolean` | optional | Measure duration and auto-schedule math in WORKING days, skipping Saturdays and Sundays | | **holidays** | `string[]` | optional | Additional non-working dates for the working calendar, ISO `yyyy-mm-dd` strings; folded into a Set for the duration math | @@ -608,8 +608,8 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | +| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | +| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | ### Nested Shape: `ObjectGanttProps.data[provider='value']` @@ -906,7 +906,7 @@ Sort field and direction pair | **quickAdd** | `never` | optional | [REMOVED] `object-kanban` property `quickAdd` was removed in @objectstack/spec 17 (ADR-0049) — the board forwarded it, but the per-column affordance is gated on both `quickAdd` and `onQuickAdd`, and `onQuickAdd` is a host-supplied function JSON cannot carry and no producer ever put on an `object-kanban` node, so authoring it was a parse-clean no-op. Delete the key; `object-kanban` offers no quick-add control. Run `os migrate meta --from 17` to list the mechanical edits for existing sources; apply them by hand. | | **coverImageField** | `string` | optional | Image field rendered as the card cover | | **conditionalFormatting** | `any` | optional | Card conditional formatting rules | -| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Card-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`). The renderer's own default is `{ mode: 'drawer' }` when the key is absent; it is documented rather than declared, so a parsed board carries the key only when the author wrote it | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Card-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`). The renderer's own default is `{ mode: 'drawer' }` when the key is absent; it is documented rather than declared, so a parsed board carries the key only when the author wrote it | ### Nested Shape: `ObjectKanbanProps.filter[number]` @@ -922,7 +922,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **fields** | `{ field: string; order: Enum<'asc' \| 'desc'>; collapsed: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` | +| **fields** | `{ field: string; order?: Enum<'asc' \| 'desc'>; collapsed?: boolean }[]` | ✅ | Fields to group by, in nesting order — the first entry is the outermost group and each later entry nests one level deeper (at least one field); the same order as the group header query's `groupBy` | ### Nested Shape: `ObjectKanbanProps.navigation` @@ -951,7 +951,7 @@ View filter rule | **sort** | `{ field: string; order: Enum<'asc' \| 'desc'> }[]` | optional | Marker order for the fetched records — the SortItem array form `[{ field, order }, ...]`, the one sort orthography every declared `sort` door on this platform shares; lowered to the wire `$orderby`. The legacy string clause (`name desc`) is refused — see migration `object-block-sort-item-array` | | **map** | `{ latitudeField?: string; longitudeField?: string; locationField?: string; titleField?: string; … }` | optional | Map field config, the author face — the same block `ListViewSchema.map` declares, and the one the renderer validates this node against. Taken WHOLE when present: the flat top-level spelling beside it is ignored | | **mapStyle** | `string` | optional | MapLibre style URL or spec, overriding the public demo tiles. Read before `map.style`; NOT the base node `style`, which is an inline CSS record | -| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Marker-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Marker-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values | | **enableClustering** | `boolean` | optional | Group nearby markers into clusters. Absent, the renderer clusters only above 100 markers | ### Nested Shape: `ObjectMapProps.data[provider='object']` @@ -966,8 +966,8 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | +| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | +| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | ### Nested Shape: `ObjectMapProps.data[provider='value']` @@ -1129,7 +1129,7 @@ View filter rule | **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 | -| **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 }`) | +| **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` @@ -1186,7 +1186,7 @@ Sort field and direction pair | **staticData** | `any[]` | optional | Inline records — read SECOND by `resolveRecordSourceConfig`, wrapped into a `{ provider: 'value' }` config | | **filter** | `{ field: string; operator: Enum<'equals' \| 'not_equals' \| 'contains' \| 'not_contains' \| 'icontains' \| …>; value?: string \| number \| boolean \| null \| (string \| number)[] }[]` | optional | Base query filter — the ViewFilterRule array form `[{ field, operator, value }, ...]`, the one filter orthography every `filter` door in this map shares; lowered to the wire `$filter`. The MongoDB-style record form is refused — see migration `element-data-source-and-object-block-filter-rule-array` | | **tree** | `{ parentField?: string; labelField?: string; fields?: string[]; defaultExpandedDepth?: integer }` | optional | Tree/hierarchy configuration, the author face — the same block `ListViewSchema.tree` declares: `{ parentField?, labelField?, fields?, defaultExpandedDepth? }`. `parentField` auto-detects from the object schema when omitted | -| **navigation** | `{ mode: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation: boolean; openNewTab: boolean; size: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values | +| **navigation** | `{ mode?: Enum<'page' \| 'drawer' \| 'modal' \| 'split' \| 'popover' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Row-click navigation config — the same block `ListViewSchema.navigation` declares (`{ mode, size, openNewTab, preventNavigation }`), `mode` one of the seven `NavigationModeSchema` values | ### Nested Shape: `ObjectTreeProps.data[provider='object']` @@ -1200,8 +1200,8 @@ Sort field and direction pair | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | | **provider** | `'api'` | ✅ | | -| **read** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | -| **write** | `{ url: string; method: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | +| **read** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for fetching data | +| **write** | `{ url: string; method?: Enum<'GET' \| 'POST' \| 'PUT' \| 'PATCH' \| 'DELETE'>; headers?: Record; params?: Record; … }` | optional | Configuration for submitting data (for forms/editable tables) | ### Nested Shape: `ObjectTreeProps.data[provider='value']` @@ -1257,7 +1257,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **items** | `{ label: string \| Record; icon?: string; collapsed: boolean; children: any[] }[]` | ✅ | | +| **items** | `{ label: string \| Record; icon?: string; collapsed?: boolean; children: any[] }[]` | ✅ | | | **allowMultiple** | `boolean` | optional (default: `false`) | Allow multiple panels to be expanded simultaneously | | **variant** | `Enum<'flush' \| 'card'>` | optional (default: `"flush"`) | Panel framing: 'flush' draws a divider under each panel; 'card' leaves the border to each panel's own content | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | @@ -1461,7 +1461,7 @@ View filter rule | **width** | `string \| number` | optional | Panel width (e.g., "350px", "30%") — side positions (`right`/`left`) only. | | **collapsible** | `boolean` | optional | Whether the panel can be collapsed (renderer default: off). | | **defaultCollapsed** | `boolean` | optional | Whether the panel starts collapsed (renderer default: off; only meaningful with `collapsible`). | -| **feed** | `{ types?: (Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| …> \| string)[]; filterMode: Enum<'all' \| 'comments_only' \| 'changes_only' \| 'tasks_only'>; showFilterToggle: boolean; limit: integer; … }` | optional | Embedded activity feed configuration | +| **feed** | `{ types?: (Enum<'comment' \| 'field_change' \| 'task' \| 'event' \| 'email' \| 'call' \| 'note' \| …> \| string)[]; filterMode?: Enum<'all' \| 'comments_only' \| 'changes_only' \| 'tasks_only'>; showFilterToggle?: boolean; limit?: integer; … }` | optional | Embedded activity feed configuration | | **aria** | `{ ariaLabel?: string \| Record; ariaDescribedBy?: string; role?: string }` | optional | ARIA accessibility attributes | ### Nested Shape: `RecordChatterProps.feed` @@ -1788,7 +1788,7 @@ Sort field and direction pair | **type** | `string` | optional | Renderer type override (e.g., "currency", "date") | | **pinned** | `Enum<'left' \| 'right'>` | optional | Pin/freeze column to left or right side | | **summary** | `Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …> \| { type: Enum<'none' \| 'count' \| 'count_empty' \| 'count_filled' \| 'count_unique' \| …>; field?: string }` | optional | Footer aggregation for this column — the function alone, or `{ type, field }` to aggregate another field | -| **prefix** | `{ field: string; type: Enum<'badge' \| 'text'> }` | optional | Field rendered inline before this cell value | +| **prefix** | `{ field: string; type?: Enum<'badge' \| 'text'> }` | optional | Field rendered inline before this cell value | | **link** | `boolean` | optional | Functions as the primary navigation link (triggers View navigation) | | **action** | `string` | optional | Registered Action ID to execute when clicked | @@ -1806,7 +1806,7 @@ View filter rule | Property | Type | Required | Description | | :--- | :--- | :--- | :--- | -| **picker** | `{ object: string; valueField: string; labelField?: string; filter?: object[] }` | ✅ | Where the Add affordance sources records from. | +| **picker** | `{ object: string; valueField?: string; labelField?: string; filter?: object[] }` | ✅ | Where the Add affordance sources records from. | | **linkField** | `string` | optional | Field on `objectName` that stores the picked record id (junction case). Omit for a 1:m re-parent. | | **label** | `string \| Record` | optional | Label for the Add button (default "Add"). |