From 6307efe7c9f0e56e9b39ccd9f1442f1f901e4058 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:55:53 +0000 Subject: [PATCH 1/5] feat(spec): InlineActionSchema picks `description`, so an inline action's param dialog can say what it does An inline action (`element:button`'s `properties.action`) was refused `description` as an unknown key, although the button's renderer already forwards it to the runner and the console's param dialog draws it under its title. The pick now takes ActionSchema's own field (the `template` slot with the inline locale-map arm). The docblock records the one remaining window: the inline copy localizer does not yet collapse a locale-map `description`, so it reaches the dialog unresolved until the renderer half lands. It also corrects the `bodyExtra` paragraph, whose forward-list gap has closed. Claude-Session: https://claude.ai/code/session_01ADHkmT9dnPvF2SeYafuvmH Co-authored-by: Claude --- .../spec/src/ui/action-description.test.ts | 27 +++--- packages/spec/src/ui/action.zod.ts | 34 +++++--- .../spec/src/ui/inline-action-type.test.ts | 9 ++ packages/spec/src/ui/inline-action.test.ts | 85 ++++++++++++++++++- 4 files changed, 131 insertions(+), 24 deletions(-) diff --git a/packages/spec/src/ui/action-description.test.ts b/packages/spec/src/ui/action-description.test.ts index 417b8f2fd88..d38dc1f793e 100644 --- a/packages/spec/src/ui/action-description.test.ts +++ b/packages/spec/src/ui/action-description.test.ts @@ -16,10 +16,11 @@ // These pins hold the key open to the input shapes `label` takes (a string or // an inline locale map; since #27948 a string is the `template` slot's // shorthand and parses to its envelope), and hold the -// three things this change must NOT do: relax the strict shape, widen the -// INLINE action surface ahead of its renderer, or re-point the action PARAM's -// `description` → `helpText` alias, which is a different surface and still -// correct. +// two things this change must NOT do: relax the strict shape, or re-point the +// action PARAM's `description` → `helpText` alias, which is a different surface +// and still correct. The INLINE action surface took the key later, once +// `element:button`'s renderer forwarded it (cloud#3281, ruling A); its pins +// live in `inline-action.test.ts`. import { describe, it, expect } from 'vitest'; import { z } from 'zod'; @@ -148,22 +149,22 @@ describe('the surfaces the action `description` key deliberately does NOT widen' expect(issue?.message).toContain('helpText'); }); - it('an INLINE action still refuses it — the pick widens when a renderer widens, not before', () => { + it('an INLINE action takes it too — the pick widened once element:button forwarded it', () => { // `InlineActionSchema` forwards exactly what `element:button`'s renderer - // honours. That renderer has no description slot, so picking the key here - // would declare a field no renderer reads — the failure that schema exists - // to stop (`bodyExtra` is the one knowing exception, ruled in #5777). + // honours, and that renderer now hands `description` to the runner, whose + // param dialog draws it (cloud#3281, ruling A). Same field, same parse. const result = InlineActionSchema.safeParse({ type: 'url', target: '/docs', label: 'Docs', - description: 'Not honoured by element:button', + description: 'Opens the product documentation.', }); - expect(result.success).toBe(false); - const issue = result.success ? undefined : result.error.issues[0]; - expect(issue?.code).toBe('unrecognized_keys'); - expect(issue?.message).toContain('description'); + expect(result.success).toBe(true); + expect(result.success && result.data.description).toEqual({ + dialect: 'template', + source: 'Opens the product documentation.', + }); }); }); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index ea136c36dbf..cc20334f677 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -2567,16 +2567,27 @@ export function normalizeInlineAction(value: unknown): unknown { * issue's option C names. The pick is therefore what makes the ruled direction * A reachable from a page, and it is deliberately taken before objectui's half. * - * The divergence that buys, stated rather than hidden: objectui's - * `element:button` renderer builds an explicit forward list - * (`packages/components/src/renderers/basic/elements.tsx`) that does not yet - * include `bodyExtra`, so between this landing and that follow-up an inline - * `bodyExtra` validates, publishes, and is dropped one hop before the runner. - * The runner and the console `apiHandler` below it already read the key — the - * missing hop is the forward list alone. Direction of the window: spec accepts - * ahead of the renderer, never the reverse. Tracked as the objectui card the - * ruling splits out under `Blocked-by:` this change; the general rule above is - * unchanged for every other field. + * That window has since closed: the `element:button` renderer's explicit + * forward list (`ui/packages/components/src/renderers/basic/elements.tsx`) now + * carries `bodyExtra` to the runner, and the console `apiHandler` below it reads + * the key. Direction of such a window: spec accepts ahead of the renderer, never + * the reverse. + * + * **`description` is picked because the renderer already forwards it.** The + * same forward list hands `action.description` to the runner, and the console's + * param-collection handler (`ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx`) + * draws it as the line under the param dialog's title — the handler that draws + * a registered action's `description`, bundle lookup by the action's `name` + * first, the authored text as the fallback. Without the pick an inline action + * could not say what it does, and the dialog showed its generic line instead. + * + * One window remains, stated rather than hidden: the inline copy localizer + * (`ui/packages/react/src/hooks/useActionTextLocalizer.ts`) collapses to the + * user's language only the keys its `DECLARED_COPY_KEYS` lists — `confirmText`, + * `successMessage`, `errorMessage` — and `description` is not one of them. So + * until the renderer half lands, an inline locale map (`{ en, "zh-CN" }`) on + * `description` reaches the dialog unresolved. Same direction as above: spec + * accepts ahead of the renderer, never the reverse. */ export const InlineActionSchema = lazySchema(() => z.preprocess( normalizeInlineAction, @@ -2589,6 +2600,9 @@ export const InlineActionSchema = lazySchema(() => z.preprocess( method: true, params: true, bodyExtra: true, + // objectstack-ai/cloud#3281 (ruling A): the param dialog's explanatory + // line. The localizer half that resolves its locale map is #29249. + description: true, confirmText: true, successMessage: true, errorMessage: true, diff --git a/packages/spec/src/ui/inline-action-type.test.ts b/packages/spec/src/ui/inline-action-type.test.ts index c5da44e7503..712712a52fc 100644 --- a/packages/spec/src/ui/inline-action-type.test.ts +++ b/packages/spec/src/ui/inline-action-type.test.ts @@ -37,6 +37,13 @@ const apiAction: InlineAction = { refreshAfter: true, }; const namedModal: InlineAction = { name: 'open_upgrade', label: 'Upgrade', type: 'modal', target: 'upgrade_dialog' }; +// `description` follows the pick into the derived type, in both of its input shapes. +const describedInvite: InlineAction = { + type: 'api', + target: '/api/v1/auth/organization/invite-member', + description: { en: 'Sends an invitation e-mail.', 'zh-CN': '发送邀请邮件。' }, +}; +const describedPlain: InlineAction = { type: 'url', target: '/docs', description: 'Opens the documentation.' }; // ── What `InlineAction` refuses at compile time ────────────────────────────────────────────── @@ -56,6 +63,8 @@ describe('InlineAction is an inline action body, not unknown', () => { ['a url action', urlAction], ['an api action with a static payload', apiAction], ['a named modal action', namedModal], + ['an api action with a locale-map description', describedInvite], + ['a url action with a plain description', describedPlain], ])('%s typed as InlineAction parses', (_label, body) => { expect(InlineActionSchema.safeParse(body).success).toBe(true); }); diff --git a/packages/spec/src/ui/inline-action.test.ts b/packages/spec/src/ui/inline-action.test.ts index 0391f14e54d..31866b1f179 100644 --- a/packages/spec/src/ui/inline-action.test.ts +++ b/packages/spec/src/ui/inline-action.test.ts @@ -28,7 +28,7 @@ import { ElementButtonPropsSchema } from './component.zod'; /** The fields an inline action admits, in the order they are picked. */ const INLINE_FIELDS = [ 'type', 'name', 'label', 'target', 'openIn', 'method', 'params', 'bodyExtra', - 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', 'opensInNewTab', + 'description', 'confirmText', 'successMessage', 'errorMessage', 'refreshAfter', 'opensInNewTab', ] as const; /** Reach the inner object through the preprocess → object → refine chain. */ @@ -63,6 +63,7 @@ describe('InlineActionSchema is derived from ActionSchema, not restated', () => method: 'POST' as const, params: [], bodyExtra: { resend: true }, + description: 'Sends the invitation again.', confirmText: 'Sure?', successMessage: 'Done', errorMessage: 'Failed', @@ -205,6 +206,88 @@ describe('InlineActionSchema — `bodyExtra` is the payload key, `params` is not }); }); +/** + * The param dialog's explanatory line (cloud#3281, ruling A). `element:button`'s + * forward list already hands `description` to the runner and the console's + * param-collection handler draws it under the dialog title, so the pick follows + * the renderer here rather than leading it. The field is `ActionSchema`'s own — + * the same `template` slot with the inline locale-map arm — not a restatement. + */ +describe('InlineActionSchema — `description` says what the action does', () => { + /** The cloud invitation dialog's shape: an inline `api` action that collects params. */ + const invite = { + type: 'api' as const, + target: '/api/v1/auth/organization/invite-member', + params: [{ name: 'email', label: 'Email', type: 'email' as const, required: true }], + }; + + it('accepts a plain string and keeps it, as the `template` envelope it is shorthand for', () => { + const r = InlineActionSchema.safeParse({ + ...invite, + description: 'Your colleague gets an e-mail and joins with the chosen role after accepting.', + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect(r.data?.description).toEqual({ + dialect: 'template', + source: 'Your colleague gets an e-mail and joins with the chosen role after accepting.', + }); + }); + + it('accepts an inline locale map and keeps it verbatim', () => { + const localized = { + en: 'Your colleague gets an e-mail and joins after accepting.', + 'zh-CN': '对方会收到邀请邮件,接受后以所选角色加入。', + }; + const r = InlineActionSchema.safeParse({ ...invite, description: localized }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect(r.data?.description).toEqual(localized); + }); + + it('still parses without one, and adds none', () => { + const r = InlineActionSchema.safeParse(invite); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect(r.data).not.toHaveProperty('description'); + }); + + it('takes the field ActionSchema declares, so both doors refuse a bad value alike', () => { + // Shared field factory, not a restated key: a value the registered door + // refuses by shape is refused inline too, at the same path, same code. + const codeAt = (r: { success: boolean; error?: z.ZodError }) => + r.error?.issues.find(i => i.path.join('.') === 'description')?.code; + const inline = InlineActionSchema.safeParse({ ...invite, description: 42 }); + const registered = ActionSchema.safeParse({ name: 'invite', label: 'Invite', ...invite, description: 42 }); + expect(inline.success).toBe(false); + expect(registered.success).toBe(false); + expect(codeAt(inline)).toBeDefined(); + expect(codeAt(inline)).toBe(codeAt(registered)); + }); + + it('reaches parse output through element:button, the surface that forwards it', () => { + const r = ElementButtonPropsSchema.safeParse({ + label: { en: 'Invite member', 'zh-CN': '邀请成员' }, + action: { ...invite, description: { en: 'Sends an invitation e-mail.', 'zh-CN': '发送邀请邮件。' } }, + }); + expect(r.success, JSON.stringify((r as { error?: unknown }).error)).toBe(true); + expect((r.data as { action: { description: unknown } }).action.description) + .toEqual({ en: 'Sends an invitation e-mail.', 'zh-CN': '发送邀请邮件。' }); + }); + + it.each([ + ['icon', { icon: 'mail' }], + ['variant', { variant: 'primary' }], + ['body', { body: 'return 1' }], + ['cancelText', { cancelText: 'Back' }], + ['submitText', { submitText: 'Send' }], + ])('CONTROL: the widening is `description` alone — `%s` is still an unknown key inline', (key, extra) => { + const r = InlineActionSchema.safeParse({ ...invite, ...extra }); + expect(r.success).toBe(false); + const issues = (r as { error: z.ZodError }).error.issues; + const unknown = issues.find(i => i.code === 'unrecognized_keys') as { keys?: string[] } | undefined; + expect(unknown, JSON.stringify(issues)).toBeDefined(); + expect(unknown!.keys).toEqual([key]); + }); +}); + /** * #6828 — the THIRD meaning of `params`, and why one prescription is not enough. * From 546636660333f0b8609d35f13210f5f3023b46dd Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 11:56:44 +0000 Subject: [PATCH 2/5] fix(lint): judge an inline action's `description` as the template slot it is MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The action-copy pass judged an inline `properties.action` on `confirmText`, `successMessage` and `params[].helpText` only, because `InlineActionSchema` did not pick `description`. Now that it does, an inline sentence with a `${…}` token, a hole over an unbound root or a blank text would publish from a page while the same text on a registered action is refused at `objectstack validate`, `compile` and the publish gate. INLINE_KEYS gains `description`; the inline pass already takes the locale map. Claude-Session: https://claude.ai/code/session_01ADHkmT9dnPvF2SeYafuvmH Co-authored-by: Claude --- .../validate-action-copy-templates.test.ts | 41 +++++++++++++++++++ .../src/validate-action-copy-templates.ts | 18 +++++--- 2 files changed, 53 insertions(+), 6 deletions(-) diff --git a/packages/lint/src/validate-action-copy-templates.test.ts b/packages/lint/src/validate-action-copy-templates.test.ts index 7965caeb87e..ada1c09baca 100644 --- a/packages/lint/src/validate-action-copy-templates.test.ts +++ b/packages/lint/src/validate-action-copy-templates.test.ts @@ -317,6 +317,47 @@ describe('validateStackExpressions — description, params[].helpText and outcom expect(onObjectWrite.map((i) => i.where)).toEqual(["object 'crm_account' · action 'invite' description"]); }); + // cloud#3281 (ruling A): `InlineActionSchema` picks `description`, so an + // inline sentence is judged by the same slot rules as a registered one — + // otherwise a text the registered door refuses would publish from a page. + it('judges an inline `properties.action` description like a registered one, locale map included', () => { + const button = (description: unknown) => ({ + type: 'element:button', + properties: { label: 'Invite', action: { type: 'api', target: '/api/v1/invite', description } }, + }); + const page = pageWith([ + button('Invite ${record.name}?'), + button({ en: 'Invite {{ record.name }}?', 'zh-CN': '邀请 ${record.name}?' }), + button(' '), + button('Sent to {{ result.email }}'), + button('Invite {{ record.name }} to this account.'), + button({ en: 'Sends an invitation e-mail.', 'zh-CN': '发送邀请邮件。' }), + ]); + const issues = runStackExpressionPasses({ objects, pages: [page] }, { runtimeWriteType: 'page' }) + .filter((i) => /description/.test(i.where)); + expect(issues.map((i) => [i.where, i.severity])).toEqual([ + ["page 'account_home'.regions[0].components[0] (element:button) · action description", 'error'], + ["page 'account_home'.regions[0].components[1] (element:button) · action description.zh-CN", 'error'], + ["page 'account_home'.regions[0].components[2] (element:button) · action description", 'error'], + ["page 'account_home'.regions[0].components[3] (element:button) · action description", 'error'], + ]); + expect(issues[0]!.message).toContain("An action's `description`"); + expect(issues[0]!.message).toContain('as `Invite {{ record.name }}?`'); + expect(issues[2]!.message).toMatch(/^An action's `description` is blank/); + }); + + it('the publish gate answers an inline `${…}` description on a page write with one error and no advisory', () => { + const page = pageWith([{ + type: 'element:button', + properties: { label: 'Invite', action: { type: 'api', target: '/api/v1/invite', description: 'Invite ${record.name}?' } }, + }]); + const { errors, advisories } = runRuntimeAuthoringRules({ type: 'page', item: page, context: { objects } }); + const copy = (f: { where: string }) => /description/.test(f.where); + expect(errors.filter(copy)).toHaveLength(1); + expect(errors.filter(copy)[0]!.where).toContain('(element:button) · action description'); + expect(advisories.filter(copy)).toEqual([]); + }); + it('the publish gate answers each `${…}` with one error and no advisory', () => { const gate = (item: unknown) => { const { errors, advisories } = runRuntimeAuthoringRules({ type: 'action', item, context: { objects } }); diff --git a/packages/lint/src/validate-action-copy-templates.ts b/packages/lint/src/validate-action-copy-templates.ts index c2bbc45e0d0..610b17bbcc3 100644 --- a/packages/lint/src/validate-action-copy-templates.ts +++ b/packages/lint/src/validate-action-copy-templates.ts @@ -17,7 +17,8 @@ * all three keys; * - a page component's inline `properties.action` (`InlineActionSchema`, the * pick of `ActionSchema` an `element:button` carries): `confirmText`, - * `successMessage` and each `params[].helpText`. + * `successMessage`, `description` (each with the locale map) and each + * `params[].helpText`. * * The same two steps the bulk `confirmText` pass takes, through the same * judge: `closedScopeTemplateRefusal` names a `${…}` or single-brace token @@ -60,16 +61,21 @@ type CopyKey = 'confirmText' | 'successMessage' | 'errorMessage' | 'description' const ACTION_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage']; /** - * [#27948] The keys only a REGISTERED action carries as `template` slots, past + * [#27948] The keys a REGISTERED action carries as `template` slots past * {@link ACTION_KEYS}: the param dialog's `description`, each * `outcomeMessages` entry, each `params[].helpText`. An inline action's pick - * has `params` but neither of the other two, so it is judged on `helpText` - * alone ({@link INLINE_KEYS}). + * has `params` and `description` but not `outcomeMessages`, so it is judged on + * `description` and `helpText` ({@link INLINE_KEYS}). */ const REGISTERED_KEYS: readonly CopyKey[] = [...ACTION_KEYS, 'description', 'outcomeMessages', 'helpText']; -/** The keys an inline `properties.action` carries as `template` slots. */ -const INLINE_KEYS: readonly CopyKey[] = [...ACTION_KEYS, 'helpText']; +/** + * The keys an inline `properties.action` carries as `template` slots. + * `description` joined when `InlineActionSchema` picked it (cloud#3281, ruling + * A): the same slot, so the same judge, or an inline sentence the registered + * door refuses would publish. + */ +const INLINE_KEYS: readonly CopyKey[] = [...ACTION_KEYS, 'description', 'helpText']; /** The keys a page action row carries as `template` slots. */ const ROW_KEYS: readonly CopyKey[] = ['confirmText', 'successMessage', 'errorMessage']; From 89985489bd9349b4ef6cf872cfad302ad24c2ec5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:02:58 +0000 Subject: [PATCH 3/5] chore(spec): regenerate the InlineAction references and authorable surface; changeset Claude-Session: https://claude.ai/code/session_01ADHkmT9dnPvF2SeYafuvmH Co-authored-by: Claude --- .changeset/29248-inline-action-description.md | 15 +++++++++++++++ content/docs/references/ui/action.mdx | 1 + content/docs/references/ui/component.mdx | 1 + packages/spec/authorable-surface/ui.json | 1 + 4 files changed, 18 insertions(+) create mode 100644 .changeset/29248-inline-action-description.md diff --git a/.changeset/29248-inline-action-description.md b/.changeset/29248-inline-action-description.md new file mode 100644 index 00000000000..4370b94f05a --- /dev/null +++ b/.changeset/29248-inline-action-description.md @@ -0,0 +1,15 @@ +--- +'@objectstack/spec': minor +'@objectstack/lint': minor +--- + +feat(spec): an inline action can carry `description`, the line its param dialog shows under the title + +Clause-②: yes (widening) +Metadata-protocol: added + +An action declared inline on a page (`element:button`'s `properties.action`, typed `InlineAction`) can now say what it does. Before, `description` on it was refused as an unknown key (`unrecognized_keys`), so a page action that collects params opened its dialog with the generic line and nothing about what confirming would do. + +- **The key.** `InlineActionSchema` picks `ActionSchema`'s own `description`: a `template` slot (a bare string is shorthand for `{ dialect: 'template', source }`, with `{{ record.* }}` holes) or an inline locale map (`{ en, "zh-CN" }`), each value such a sentence. The `InlineAction` type gains it too. Nothing else is picked: `icon`, `variant`, `body`, `cancelText` and `submitText` stay unknown keys on an inline action. +- **Judged like a registered action's.** `objectstack validate`, `compile` and the publish gate now judge an inline `description` with the same rules as a registered one: a `${…}` or single-brace token, a hole over a root the runner does not bind, a hole holding logic or an unknown formatter, and a blank sentence are each an error, named at `… (element:button) · action description` (or `description.` inside a locale map). +- **Where it shows.** The console's `element:button` already forwards `description` to the action runner, and the param dialog draws it under its title. One gap remains on the console side: its inline copy localizer collapses `confirmText`, `successMessage` and `errorMessage` to the user's language but not yet `description`, so until it does, an inline locale map on `description` reaches the dialog unresolved. diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index e1abaf0d5b4..5e4859de294 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -354,6 +354,7 @@ L2 sandboxed JS body — runs inside an isolated VM with declared capabilities | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. | | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | +| **description** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. It is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for `{{ record.name }}`.'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. | | **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | | **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index cc71cdff29c..b48ef169ca1 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -233,6 +233,7 @@ const result = ActionButtonPropsSchema.parse(data); | **method** | `Enum<'POST' \| 'PATCH' \| 'PUT' \| 'DELETE'>` | optional | HTTP method for type:"api" actions. Defaults to POST. | | **params** | `{ name?: string; field?: string; objectOverride?: string; label?: string \| Record; … }[]` | optional | Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`). A param name (its `name`, or the `field` a field-backed param reads) may repeat only for per-gate variants of ONE input (declarations that differ in `visible` / `requiresFeature`, e.g. one optional and one required); the params check treats the name as `required` only when EVERY declaration of it requires it, and judges its value by the FIRST declaration's shape (`type`, `multiple`, `options`), so keep that shape identical across them. | | **bodyExtra** | `Record` | optional | Static request-body fields for a type:"api" action, merged last (overrides user params). `{{page.}}` tokens are resolved by the runtime. This — not `params` — is where a payload goes. | +| **description** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Explanatory line shown under the title in the action's param dialog. Carries the confirm question for an action that collects params (one dialog, not two). Not the LLM-facing `ai.description`. It is a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record); the values the dialog collects are not bound, since the line shows before they exist. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Choose the new role for `{{ record.name }}`.'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. | | **confirmText** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Confirmation message before execution — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `record`, the record the action runs against (the clicked row on a list, else the page's record), and — only when the action's `params` is an object of static values — `params` (`{{ params.tier }}`); an input list binds nothing, since its values are collected after the confirmation. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Delete `{{ record.name }}`?'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action, pairing this with a non-empty `params` is refused — that opens a second dialog for one decision; put the question on `description` instead. Correct on a param-LESS action, where the confirm is the only dialog there is. | | **successMessage** | `string \| { dialect: 'template'; source?: string; ast?: any; meta?: object } \| string \| Record` | optional | Success message shown after the action succeeds — a `template` slot (ADR-0032): a bare string is shorthand for `{ dialect: 'template', source }`, and text with no hole is shown as written. Holes are `{{ path }}` or `{{ path \| formatter[:arg] }}` over `result`, the payload the action's handler answered (`{{ result.email }}`; for an `api` or `script` action the server response), and `record`, the record the action ran against. Formatters come from the template engine's whitelist (`TEMPLATE_FORMATTERS`, `@objectstack/formula`); a hole holds no logic (no CEL, no arithmetic). Example: 'Invitation sent to `{{ result.email }}`'. A hole over any other root, a `${…}` or single-brace `{x}` token (each named with its `{{ }}` spelling), a hole holding logic or an unknown formatter, and a blank sentence are named and refused at `objectstack validate`, `compile` and the publish gate; the text still parses, so a stored one loads until its next save. An inline locale map (`{ en, "zh-CN" }`) is accepted too, each value such a sentence. On a registered action that declares `outcomeMessages`, the entry named by the response's `outcome` is shown instead, and this message is the fallback. | | **errorMessage** | `string \| Record` | optional | Error message to show when the action fails (overrides the raw error). | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index be773fe3492..d25fb8cff38 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -666,6 +666,7 @@ "ui/HttpRequest:url", "ui/InlineAction:bodyExtra", "ui/InlineAction:confirmText", + "ui/InlineAction:description", "ui/InlineAction:errorMessage", "ui/InlineAction:label", "ui/InlineAction:method", From 7b59669918a5f84a79c14fbbc509d1e9e494004e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 12:42:21 +0000 Subject: [PATCH 4/5] test(spec): hold the inline description pin under its own describe, not the does-not-widen block Claude-Session: https://claude.ai/code/session_01ADHkmT9dnPvF2SeYafuvmH Co-authored-by: Claude --- packages/spec/src/ui/action-description.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/spec/src/ui/action-description.test.ts b/packages/spec/src/ui/action-description.test.ts index d38dc1f793e..700ca2b0903 100644 --- a/packages/spec/src/ui/action-description.test.ts +++ b/packages/spec/src/ui/action-description.test.ts @@ -148,7 +148,9 @@ describe('the surfaces the action `description` key deliberately does NOT widen' expect(issue?.code).toBe('unrecognized_keys'); expect(issue?.message).toContain('helpText'); }); +}); +describe('the INLINE action surface took the key once its renderer forwarded it', () => { it('an INLINE action takes it too — the pick widened once element:button forwarded it', () => { // `InlineActionSchema` forwards exactly what `element:button`'s renderer // honours, and that renderer now hands `description` to the runner, whose From ab80f700cd15a49694ed96485b167b09c0598615 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Nov 2026 13:35:31 +0000 Subject: [PATCH 5/5] docs(spec): the confirmText-beside-params note no longer says the inline pick lacks `description` Comment only. The inline pick carries `description` on this branch, so the remedy the refusal prescribes has a slot on an inline action too; whether the inline door should also refuse the pair stays a separate question, and no behaviour changes. Claude-Session: https://claude.ai/code/session_01ADHkmT9dnPvF2SeYafuvmH Co-authored-by: Claude --- packages/spec/src/ui/action.zod.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index cc20334f677..4f93ed8c269 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -2295,8 +2295,10 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { // its own refinements, so this refusal is structurally incapable of // reaching it; `action-confirm-params-guard.test.ts` pins that both ways. // The same holds for {@link InlineActionSchema}, which `.pick()`s from the - // field factory rather than deriving from this chain — and which does not - // pick `description`, so the remedy below has no slot there to move into. + // field factory rather than deriving from this chain, so this refusal does + // not reach an inline action. The inline pick does carry `description` now, + // so the remedy below has a slot there too; whether the inline door should + // also refuse the pair is a separate narrowing question, not decided here. if (data.confirmText != null && Array.isArray(data.params) && data.params.length > 0) { return false; }