Skip to content

feat(spec): element:button declares the action it executes — InlineActionSchema (objectui#2997) - #4135

Merged
os-zhuang merged 1 commit into
mainfrom
claude/inline-action-2997
Jul 30, 2026
Merged

feat(spec): element:button declares the action it executes — InlineActionSchema (objectui#2997)#4135
os-zhuang merged 1 commit into
mainfrom
claude/inline-action-2997

Conversation

@os-zhuang

Copy link
Copy Markdown
Contributor

Stage A of objectstack-ai/objectui#2997. Framework-only and carries no migration — nothing that parses today stops parsing.

The gap

element:button's renderer reads properties.action and dispatches it through the ActionRunner (packages/components/src/renderers/basic/elements.tsx:212-240). That prop is the only thing making a standalone-page button interactive. ElementButtonPropsSchema declared label, variant, size, icon, iconPosition, disabled, aria — and no action.

It is being authored regardless. cloud main, packages/service-tenant/src/pages/:

Site Shape
pricing.page.ts:42 const goToEnvironments = { type: 'navigation', to: ENVIRONMENTS_ROUTE }
welcome.page.ts:66 const go = (to) => ({ type: 'navigation', to }) — used 4×
billing-cancel.page.ts:66, :76 { type: 'navigation', to: … }
billing-success.page.ts:74 { type: 'navigation', to: … }

Five declaration sites, ~9 rendered buttons, the billing and pricing funnel. Every one cast as any.

An undeclared prop is stripped, not ignored

ElementButtonPropsSchema.safeParse({ label: 'Go', action: { type: 'navigation', to: '/x' } })
→ success: true, data: { label: 'Go', variant: 'primary', size: 'medium', iconPosition: 'left', … }

No action. Non-strict z.object, so Zod drops the unknown key.

Harmless today only because page block properties are z.record(z.string(), z.unknown()) (page.zod.ts:86), so this schema never runs on a real page save. The moment anyone tightens properties to validate against ComponentPropsMap — an obvious future hardening — every one of those cloud buttons silently loses its action at save, with a green parse. Declaring the prop is what defuses that; nothing else does.

InlineActionSchema is derived, not a fourth dialect

ActionSchema is z.object(…).refine(…).refine(…), so .pick() is not available on the exported schema — a ZodObject method behind two refinement wrappers. (Worth recording: this, not the lazySchema proxy, is why subsetting failed. The proxy forwards everything, which is why ChartTypeSchema.extract(…) works — that one wraps a bare z.enum.)

So the object half is now a factory both schemas build from:

const actionObject = () => z.object({ …47 fields… });
export const ActionSchema       = lazySchema(() => actionObject().refine().refine());
export const InlineActionSchema = lazySchema(() => z.preprocess(normalizeInlineAction,
  actionObject().pick({ …12 fields… }).partial({ name: true, label: true }).refine()));

A factory rather than a module-scope schema so lazySchema's whole point survives — the fields are constructed on first use of whichever schema is touched, not at module load.

The twelve picked fields are exactly what element:button forwards to the runner, so their describe() text, the ActionType vocabulary and the target-required refinement are shared, not restated.

name and label are optional here — the substantive difference. ActionSchema requires both because a registry entry needs an identity and a menu label; an inline action has neither, and the button already has its own label prop (requiring action.label too would mean writing it twice — the reason action: ActionSchema was the wrong shape).

Excluded on purpose: every registry-only concern (objectName, locations, order, ai, requiredPermissions, visible/disabled, resultDialog), plus icon/variant (the button has its own) and body (a page button running an inline sandboxed script is a separate decision). A declared field no renderer reads is the failure this schema exists to stop — widen it when a renderer widens, not before.

navigation and to are normalizing aliases, with an end state

normalizeInlineAction folds type: 'navigation''url' and totarget on parse — the VIEW_FILTER_OPERATOR_ALIASES pattern. cloud's pages keep validating unchanged, parse output is always canonical, and the aliases get a retiredKey tombstone once the producers migrate.

url is also the better target, not merely the canonical one: the runner's url path has ${param.X} / ${ctx.X} interpolation, apiBase promotion for /api/… paths and popup-blocker-safe openIn. Its navigation path has none of those and adds only replace — which no producer writes (checked: no replace, newTab, external or navigate anywhere in cloud).

Deliberately not done: promoting navigation into ActionType as a bare member, which was considered and declined in #4070. It would name the type while leaving the prop undeclared and to homeless — half a fix — and add a permanent synonym to a vocabulary whose members cannot be removed later.

Verification

  • 16 new tests (packages/spec/src/ui/inline-action.test.ts). The derivation is asserted from both directions: the inline field set is exactly the documented twelve; every one of them round-trips through ActionSchema (so a rename there fails here); every registry-only concern is absent; every ActionType member is inline-authorable, so a new action type needs no second edit. The fold is pinned against the literal shape cloud writes, and against the negative cases — a canonical body passes through by identity, an explicit target wins over to, to is never emitted back out, non-objects are untouched.
  • Also pinned: InlineActionSchema accepts exactly what ActionSchema refuses ({type:'url', target} → the latter reports name and label missing), so the two cannot silently converge.
  • Full @objectstack/spec suite: 7038 tests across 271 files, green. tsc --noEmit: clean.
  • All twelve check:* gates pass, and every generated surface gains entries only — zero removals across api-surface.json, authorable-surface.json and json-schema.manifest.json.

What follows

  • Stage B (cloud): rewrite the five sites to { type: 'url', target } and drop the as any. Note objectstack#4107's normalization graft does not reach them — those are code-defined *.page.ts, not saveMeta-persisted.
  • objectui: add action to block-config.ts's element:button entry, or the Studio inspector still cannot author it (that table is hand-written, not derived from these props).
  • Stage C (next major): tombstone to, drop the navigation alias.

Refs objectstack-ai/objectui#2997, objectstack-ai/objectui#2945, #4070, #4107

🤖 Generated with Claude Code

@vercel

vercel Bot commented Jul 30, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

1 Skipped Deployment
Project Deployment Actions Updated (UTC)
objectstack Ignored Ignored Jul 30, 2026 11:07am

Request Review

@github-actions github-actions Bot added documentation Improvements or additions to documentation tests protocol:ui tooling size/m labels Jul 30, 2026
@github-actions

Copy link
Copy Markdown
Contributor

📓 Docs Drift Check

This PR changes 1 package(s): @objectstack/spec.

106 hand-written doc(s) reference the affected code and may need an implementation-accuracy re-verification:

  • content/docs/ai/agents.mdx (via @objectstack/spec)
  • content/docs/ai/skills-reference.mdx (via @objectstack/spec)
  • content/docs/ai/skills.mdx (via @objectstack/spec)
  • content/docs/api/client-sdk.mdx (via @objectstack/spec)
  • content/docs/api/environment-routing.mdx (via @objectstack/spec)
  • content/docs/api/error-catalog.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-client.mdx (via @objectstack/spec)
  • content/docs/api/error-handling-server.mdx (via @objectstack/spec)
  • content/docs/api/index.mdx (via @objectstack/spec)
  • content/docs/automation/approvals.mdx (via packages/spec)
  • content/docs/automation/flows.mdx (via @objectstack/spec)
  • content/docs/automation/hook-bodies.mdx (via packages/spec)
  • content/docs/automation/hooks.mdx (via @objectstack/spec)
  • content/docs/automation/index.mdx (via @objectstack/spec)
  • content/docs/automation/webhooks.mdx (via @objectstack/spec)
  • content/docs/automation/workflows.mdx (via @objectstack/spec)
  • content/docs/concepts/architecture.mdx (via @objectstack/spec)
  • content/docs/concepts/design-principles.mdx (via packages/spec)
  • content/docs/concepts/index.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-driven.mdx (via @objectstack/spec)
  • content/docs/concepts/metadata-lifecycle.mdx (via packages/spec)
  • content/docs/concepts/north-star.mdx (via packages/spec)
  • content/docs/data-modeling/analytics.mdx (via @objectstack/spec)
  • content/docs/data-modeling/drivers.mdx (via @objectstack/spec)
  • content/docs/data-modeling/external-datasources.mdx (via @objectstack/spec)
  • content/docs/data-modeling/field-types.mdx (via @objectstack/spec)
  • content/docs/data-modeling/fields.mdx (via @objectstack/spec)
  • content/docs/data-modeling/formulas.mdx (via @objectstack/spec)
  • content/docs/data-modeling/index.mdx (via @objectstack/spec)
  • content/docs/data-modeling/objects.mdx (via @objectstack/spec)
  • content/docs/data-modeling/queries.mdx (via @objectstack/spec)
  • content/docs/data-modeling/schema-design.mdx (via @objectstack/spec)
  • content/docs/data-modeling/seed-data.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation-rules.mdx (via @objectstack/spec)
  • content/docs/data-modeling/validation.mdx (via @objectstack/spec)
  • content/docs/deployment/cli.mdx (via @objectstack/spec)
  • content/docs/deployment/troubleshooting.mdx (via @objectstack/spec)
  • content/docs/deployment/validating-metadata.mdx (via @objectstack/spec)
  • content/docs/getting-started/build-with-claude-code.mdx (via @objectstack/spec)
  • content/docs/getting-started/common-patterns.mdx (via @objectstack/spec)
  • content/docs/getting-started/examples.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-reference.mdx (via @objectstack/spec)
  • content/docs/getting-started/quick-start.mdx (via @objectstack/spec)
  • content/docs/getting-started/your-first-project.mdx (via @objectstack/spec)
  • content/docs/kernel/cluster.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/auth-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/cache-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/data-engine.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/index.mdx (via @objectstack/spec)
  • content/docs/kernel/contracts/metadata-service.mdx (via packages/spec)
  • content/docs/kernel/contracts/storage-service.mdx (via packages/spec)
  • content/docs/kernel/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/email-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/index.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/queue-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sharing-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/sms-service.mdx (via packages/spec)
  • content/docs/kernel/runtime-services/storage-service.mdx (via packages/spec)
  • content/docs/kernel/services-checklist.mdx (via @objectstack/spec)
  • content/docs/kernel/services.mdx (via @objectstack/spec)
  • content/docs/permissions/authorization.mdx (via @objectstack/spec)
  • content/docs/permissions/permission-sets.mdx (via @objectstack/spec)
  • content/docs/permissions/permissions-matrix.mdx (via @objectstack/spec)
  • content/docs/permissions/positions.mdx (via @objectstack/spec)
  • content/docs/permissions/rls.mdx (via @objectstack/spec)
  • content/docs/permissions/sharing-rules.mdx (via @objectstack/spec)
  • content/docs/plugins/adding-a-metadata-type.mdx (via @objectstack/spec)
  • content/docs/plugins/development.mdx (via @objectstack/spec)
  • content/docs/plugins/index.mdx (via @objectstack/spec)
  • content/docs/plugins/packages.mdx (via @objectstack/spec)
  • content/docs/protocol/backward-compatibility.mdx (via @objectstack/spec)
  • content/docs/protocol/diagram.mdx (via packages/spec)
  • content/docs/protocol/kernel/config-resolution.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/i18n-standard.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/index.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/lifecycle.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/plugin-spec.mdx (via @objectstack/spec)
  • content/docs/protocol/kernel/runtime-capabilities.mdx (via @objectstack/spec)
  • content/docs/protocol/knowledge.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/query-syntax.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/schema.mdx (via @objectstack/spec)
  • content/docs/protocol/objectql/security.mdx (via packages/spec)
  • content/docs/protocol/objectql/state-machine.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/actions.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/concept.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/index.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/layout-dsl.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/record-alert.mdx (via @objectstack/spec)
  • content/docs/protocol/objectui/widget-contract.mdx (via @objectstack/spec)
  • content/docs/releases/implementation-status.mdx (via @objectstack/spec)
  • content/docs/releases/index.mdx (via @objectstack/spec)
  • content/docs/releases/v12.mdx (via @objectstack/spec)
  • content/docs/releases/v13.mdx (via @objectstack/spec)
  • content/docs/releases/v16.mdx (via @objectstack/spec)
  • content/docs/releases/v17.mdx (via @objectstack/spec)
  • content/docs/releases/v9.mdx (via @objectstack/spec)
  • content/docs/ui/actions.mdx (via @objectstack/spec)
  • content/docs/ui/create-vs-edit-form.mdx (via @objectstack/spec)
  • content/docs/ui/dashboards.mdx (via @objectstack/spec)
  • content/docs/ui/forms.mdx (via @objectstack/spec)
  • content/docs/ui/index.mdx (via @objectstack/spec)
  • content/docs/ui/public-data-collection.mdx (via @objectstack/spec)
  • content/docs/ui/setup-app.mdx (via @objectstack/spec)
  • content/docs/ui/translations.mdx (via @objectstack/spec)
  • content/docs/ui/views.mdx (via @objectstack/spec)

Advisory only. To re-verify, run the docs-accuracy-audit workflow scoped to these files:
node scripts/docs-audit/affected-docs.mjs origin/main → pass the list as args.docs.

…tionSchema (objectui#2997)

element:button's renderer reads `properties.action` and dispatches it
through the ActionRunner — it is the only thing making a standalone-page
button interactive — and ElementButtonPropsSchema did not declare it.

It is authored anyway: cloud's service-tenant pages carry five sites across
the billing and pricing funnel, each `{type:'navigation', to: …}`, each cast
`as any`.

An undeclared prop is STRIPPED, not ignored — safeParse returned success
with no `action` in `data`. Harmless only because page block `properties`
are still z.record(z.string(), z.unknown()); the moment that is tightened
to validate against ComponentPropsMap, every one of those buttons loses its
action at save with a green parse.

InlineActionSchema is derived, not a new dialect. ActionSchema is
z.object(…).refine(…).refine(…), so `.pick()` is unavailable on the export;
the object half is now a factory both build from, preserving lazySchema's
deferral. The inline schema picks the twelve fields element:button actually
forwards, sharing their describe() text, the ActionType vocabulary and the
target-required refinement.

`name`/`label` are optional — a registry entry needs an identity and a menu
label, an inline action has neither (the button supplies its own label).
Registry-only concerns are excluded, as are icon/variant (the button has its
own) and body (a page button running a sandboxed script is a separate
decision).

`navigation` and `to` become normalizing aliases folding to `url`/`target`,
the VIEW_FILTER_OPERATOR_ALIASES pattern: cloud's pages keep validating
while output is always canonical, and the aliases can be tombstoned once
producers migrate. `url` is also the better target — its runner path has
${param} interpolation, apiBase promotion and popup-safe openIn; the
navigation path has none of those.

NOT done: promoting `navigation` into ActionType bare (declined in #4070) —
it names the type while leaving the prop undeclared and `to` homeless, and
adds a permanent synonym to a vocabulary whose members cannot be removed.

Nothing narrowed; all generated surfaces gain entries only. spec suite
7038/271 green, tsc clean, all twelve check:* gates pass.

Refs objectstack-ai/objectui#2997, objectstack-ai/objectui#2945, #4070

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

documentation Improvements or additions to documentation protocol:ui size/m tests tooling

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant