element:button renders an action and dispatches it, but ElementButtonPropsSchema (packages/spec/src/ui/component.zod.ts:298) has no action property at all.
What runs today
packages/components/src/__tests__/element-button-action.test.tsx:33 exercises the live path:
<ElementButton schema={{ type: 'element:button', properties: {
label: 'Upgrade',
action: { type: 'navigation', to: '/apps/cloud_control/sys_environment' },
} }} />
The click reaches the navigation handler. So both the action prop and the navigation action type are implemented and reachable, and neither is named in a spec schema:
ElementButtonPropsSchema declares label, variant, size, icon, iconPosition, disabled, aria — no action;
ActionType (ui/action.zod.ts:226) is script | url | modal | flow | api | form — no navigation.
Why nothing breaks, and why that is the problem
Page block properties are z.record(z.string(), z.unknown()) (ui/page.zod.ts:86), so an authored action is neither validated nor stripped. Nothing is losing data. What is lost is everything a declaration buys:
Studio's inspector cannot offer the prop, because it derives fields from the props schema;
Corrected (see the analysis comment): block-config.ts is hand-written, so the inspector
needs its own edit either way. Declaring the prop buys validation and discoverability, not a field.
- no validation, so a typo in
action.type or a missing to fails silently at click time rather than at save;
- the
type vocabulary is undocumented, so an author cannot discover that navigation exists, or that it is not url.
The navigation / url question comes with it
ActionRunner.executeNavigation and executeUrl (packages/core/src/actions/ActionRunner.ts) both navigate. executeUrl is strictly richer — ${param.X} / ${ctx.X} interpolation, apiBase promotion for /api/... paths, openIn — while executeNavigation adds only replace (and reads to where url reads target).
So declaring the prop means deciding one of:
action accepts the spec's ActionSchema, and navigation becomes an alias executeUrl absorbs (replace moving onto ActionSchema if it is worth keeping) — one spelling of navigate;
element:button.action gets its own narrow schema that names navigation + to, accepting a second vocabulary for this surface;
navigation is promoted into ActionType as-is.
(3) was considered and rejected while widening the action vocabularies for #2945 (objectstack-ai/objectstack#4070): adding a synonym to a closed vocabulary is a one-way door — members cannot be removed later — and it would not have declared the prop, which is the actual gap. (1) looks right, but it changes what an authored element:button may say, so it wants a decision rather than a drive-by.
Cross-repo: the schema lives in framework packages/spec, the renderer in objectui packages/components.
Refs #2945, #2901, objectstack-ai/objectstack#4070
element:buttonrenders an action and dispatches it, butElementButtonPropsSchema(packages/spec/src/ui/component.zod.ts:298) has noactionproperty at all.What runs today
packages/components/src/__tests__/element-button-action.test.tsx:33exercises the live path:The click reaches the navigation handler. So both the
actionprop and thenavigationaction type are implemented and reachable, and neither is named in a spec schema:ElementButtonPropsSchemadeclareslabel,variant,size,icon,iconPosition,disabled,aria— noaction;ActionType(ui/action.zod.ts:226) isscript | url | modal | flow | api | form— nonavigation.Why nothing breaks, and why that is the problem
Page block
propertiesarez.record(z.string(), z.unknown())(ui/page.zod.ts:86), so an authoredactionis neither validated nor stripped. Nothing is losing data. What is lost is everything a declaration buys:Studio's inspector cannot offer the prop, because it derives fields from the props schema;Corrected (see the analysis comment):
block-config.tsis hand-written, so the inspectorneeds its own edit either way. Declaring the prop buys validation and discoverability, not a field.
action.typeor a missingtofails silently at click time rather than at save;typevocabulary is undocumented, so an author cannot discover thatnavigationexists, or that it is noturl.The
navigation/urlquestion comes with itActionRunner.executeNavigationandexecuteUrl(packages/core/src/actions/ActionRunner.ts) both navigate.executeUrlis strictly richer —${param.X}/${ctx.X}interpolation,apiBasepromotion for/api/...paths,openIn— whileexecuteNavigationadds onlyreplace(and readstowhereurlreadstarget).So declaring the prop means deciding one of:
actionaccepts the spec'sActionSchema, andnavigationbecomes an aliasexecuteUrlabsorbs (replacemoving ontoActionSchemaif it is worth keeping) — one spelling of navigate;element:button.actiongets its own narrow schema that namesnavigation+to, accepting a second vocabulary for this surface;navigationis promoted intoActionTypeas-is.(3) was considered and rejected while widening the action vocabularies for #2945 (objectstack-ai/objectstack#4070): adding a synonym to a closed vocabulary is a one-way door — members cannot be removed later — and it would not have declared the prop, which is the actual gap. (1) looks right, but it changes what an authored
element:buttonmay say, so it wants a decision rather than a drive-by.Cross-repo: the schema lives in framework
packages/spec, the renderer in objectuipackages/components.Refs #2945, #2901, objectstack-ai/objectstack#4070