From 0013672db32d6542131540c947b0df091b80c98c Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:44:53 +0000 Subject: [PATCH 01/10] =?UTF-8?q?feat(spec):=20an=20action's=20param=20dia?= =?UTF-8?q?log=20can=20name=20its=20own=20submit=20verb=20=E2=80=94=20subm?= =?UTF-8?q?itText?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../validate-translation-references.test.ts | 31 ++- .../src/validate-translation-references.ts | 36 +++- packages/spec/src/system/i18n-resolver.ts | 26 ++- packages/spec/src/system/translation.zod.ts | 7 + .../spec/src/ui/action-submit-text.test.ts | 193 ++++++++++++++++++ packages/spec/src/ui/action.zod.ts | 65 ++++++ 6 files changed, 349 insertions(+), 9 deletions(-) create mode 100644 packages/spec/src/ui/action-submit-text.test.ts diff --git a/packages/lint/src/validate-translation-references.test.ts b/packages/lint/src/validate-translation-references.test.ts index 0152216bc7f..0de96efd5f9 100644 --- a/packages/lint/src/validate-translation-references.test.ts +++ b/packages/lint/src/validate-translation-references.test.ts @@ -3851,6 +3851,23 @@ describe('validateTranslationReferences — keyed children of an action entry (# }); }); + describe('submitText — the param dialog\'s button (#27944)', () => { + it('errors on a `submitText` under an action that declares no params, at both addresses', () => { + const findings = validateTranslationReferences( + actionStack({ ...onEnv({ plain_env: { submitText: '发送' } }), globalActions: { mint_token: { submitText: '生成' } } }), + ); + expect(findings.map((f) => [f.rule, f.severity, f.path])).toEqual([ + [TRANSLATION_TARGET_UNKNOWN, 'error', `${ENV}.plain_env.submitText`], + [TRANSLATION_TARGET_UNKNOWN, 'error', `${GLOBAL}.mint_token.submitText`], + ]); + expect(findings[0].message).toMatch(/^Translations carry a param dialog `submitText`, but action "plain_env" declares no `params`/); + }); + + it('accepts it under an action that collects params (the control)', () => { + expect(validateTranslationReferences(actionStack(onEnv({ archive_env: { submitText: '归档' } })))).toEqual([]); + }); + }); + describe('params..options.', () => { it('warns on an option key a declared param does not declare, as the field `options` leg does', () => { const findings = validateTranslationReferences( @@ -3927,7 +3944,7 @@ describe('validateTranslationReferences — keyed children of an action entry (# }; type Address = 'bound' | 'global'; - type Host = 'outcome' | 'dialog' | 'noDialog'; + type Host = 'outcome' | 'dialog' | 'noDialog' | 'noParams'; /** * Which declared action a ghost is written under, per address. `noDialog` * names an action that declares no `resultDialog`: the dialog's leaves @@ -3937,6 +3954,10 @@ describe('validateTranslationReferences — keyed children of an action entry (# outcome: { bound: 'archive_env', global: 'check_updates' }, dialog: { bound: 'rotate_secret', global: 'mint_token' }, noDialog: { bound: 'plain_env', global: 'check_updates' }, + // [#27944] An action with no `params` opens no param dialog, so its + // `submitText` has no button to land on (`coverageStack` gives + // `check_updates` params, so the global host is `mint_token`). + noParams: { bound: 'plain_env', global: 'mint_token' }, }; type Coverage = | { kind: 'reference-checked'; host: Host; ghost: Record; suffix: string; rule: string } @@ -3948,6 +3969,13 @@ describe('validateTranslationReferences — keyed children of an action entry (# confirmText: { kind: 'leaf-copy', why: 'prose' }, successMessage: { kind: 'leaf-copy', why: 'prose' }, disabledReason: { kind: 'leaf-copy', why: 'prose' }, + submitText: { + kind: 'reference-checked', + host: 'noParams', + ghost: { submitText: 'x' }, + suffix: '.submitText', + rule: TRANSLATION_TARGET_UNKNOWN, + }, outcomeMessages: { kind: 'reference-checked', host: 'outcome', @@ -4050,6 +4078,7 @@ describe('validateTranslationReferences — keyed children of an action entry (# description: 'x', confirmText: 'x', successMessage: 'x', + submitText: 'x', outcomeMessages: address === 'bound' ? { archived: 'x' } : { up_to_date: 'x' }, params: { mode: { label: 'x', helpText: 'x', placeholder: 'x', options: { soft: 'x' } } }, }, diff --git a/packages/lint/src/validate-translation-references.ts b/packages/lint/src/validate-translation-references.ts index 070c8f6ae56..57b265d620d 100644 --- a/packages/lint/src/validate-translation-references.ts +++ b/packages/lint/src/validate-translation-references.ts @@ -2573,14 +2573,18 @@ interface ActionEntryContext { * | `outcomeMessages.` | the action's `outcomeMessages` keys | `resolveActionOutcomeMessages` | * | `resultDialog.fields.` | `resultDialog.fields[].path` | `resolveActionResultDialog` | * | `resultDialog.title` / `.description` / `.acknowledge` | the action's `resultDialog`, present at all | `resolveActionResultDialog` | + * | `submitText` | a non-empty `params` (the param dialog) | `translateAction` | * * A dialog leaf under a DECLARED dialog is read whether or not the dialog * authors that leaf itself — the resolver overlays a translated `title` onto a * dialog that declares none — so declaring the dialog is the whole condition. * - * The leaf keys beside them (`label`, `description`, `confirmText`, - * `successMessage`, `disabledReason`) are prose on a node that resolved, read - * whenever the action is, as on the object branch. + * `submitText` is the param dialog's button, the one leaf with a host of its + * own: the dialog opens only for a non-empty `params`, and `translateAction` + * overlays the key only then (#27944). The leaf keys beside them (`label`, + * `description`, `confirmText`, `successMessage`, `disabledReason`) are prose + * on a node that resolved, read whenever the action is, as on the object + * branch. * * ⛔ One vocabulary: a key naming a target the action does not declare is * `translation-target-unknown` at `params`' severity; an OPTION key keyed off @@ -2593,6 +2597,32 @@ function checkActionEntry(findings: TranslationRefFinding[], ctx: ActionEntryCon checkActionOutcomeMessages(findings, ctx); checkActionResultDialogLeaves(findings, ctx); checkActionResultDialogFields(findings, ctx); + checkActionSubmitText(findings, ctx); +} + +/** + * [#27944] `submitText` — the label of the param dialog's submit button. The + * dialog opens only for an action with a non-empty `params`, and + * `translateAction` overlays the key only for such an action, so under any + * other action the translation is read by nothing: the result-dialog leaves' + * rule (declaring the dialog is the whole condition), with `params` as the + * declaration. + */ +function checkActionSubmitText(findings: TranslationRefFinding[], ctx: ActionEntryContext): void { + if (!isRec(ctx.rawAction) || ctx.rawAction.submitText === undefined) return; + if (Array.isArray(ctx.action.params) && ctx.action.params.length > 0) return; + findings.push({ + severity: TRANSLATION_TARGET_UNKNOWN_SEVERITY, + rule: TRANSLATION_TARGET_UNKNOWN, + where: `${ctx.where} · submit button`, + path: `${ctx.path}.submitText`, + message: + `Translations carry a param dialog \`submitText\`, but ${ctx.subject} declares no ` + + `\`params\`, so it opens no param dialog and nothing reads this label.`, + hint: + `Move the \`submitText\` translation under the action that collects the params, declare ` + + `\`params\` on this action first if it should open a dialog, or drop it.`, + }); } /** Action-parameter translations are keyed by the param's `name`. */ diff --git a/packages/spec/src/system/i18n-resolver.ts b/packages/spec/src/system/i18n-resolver.ts index da933e0cd48..af601884ca5 100644 --- a/packages/spec/src/system/i18n-resolver.ts +++ b/packages/spec/src/system/i18n-resolver.ts @@ -16,6 +16,7 @@ * objects.._actions..confirmText * objects.._actions..successMessage * objects.._actions..disabledReason + * objects.._actions..submitText * objects.._actions..outcomeMessages. * objects.._actions..params..label * objects.._actions..params..helpText @@ -47,8 +48,8 @@ * object-less action (no `objectName`) reads only: * * globalActions..label / .description / .confirmText / - * .successMessage / .disabledReason / .outcomeMessages. / - * .params..* / .resultDialog.* + * .successMessage / .disabledReason / .submitText / + * .outcomeMessages. / .params..* / .resultDialog.* * * That is the declaration `TranslationDataSchema.globalActions` makes: global * translations are for object-less actions. A `globalActions` entry that names @@ -228,6 +229,11 @@ export interface ActionLike { * action shows. Narrowed to `string` for the same reason `description` is. */ disabledReason?: string; + /** + * `ActionSchema.submitText` (#27944) — the param dialog's submit button + * label. Narrowed to `string` for the same reason `description` is. + */ + submitText?: string; /** * `ActionSchema.outcomeMessages` — success copy per handler `outcome`, * narrowed to `string` values for the same reason `successMessage` is. @@ -657,7 +663,7 @@ function actionTranslationNode( function lookupActionField( bundle: TranslationBundle | undefined, action: ActionLike, - field: 'label' | 'description' | 'confirmText' | 'successMessage' | 'disabledReason', + field: 'label' | 'description' | 'confirmText' | 'successMessage' | 'disabledReason' | 'submitText', opts?: ResolveOptions, ): string | undefined { if (!bundle) return undefined; @@ -1437,8 +1443,9 @@ export function translateView( /** * Apply the active locale to an action metadata document by overwriting * `label`, `description`, `confirmText`, `successMessage`, `disabledReason`, - * each declared `outcomeMessages` entry, the `params[]` copy and the - * `resultDialog` copy with translated values when available. The + * `submitText` (for an action that declares `params`), each declared + * `outcomeMessages` entry, the `params[]` copy and the `resultDialog` copy + * with translated values when available. The * original document is not mutated; a shallow copy is returned. * * ## Why `description` and `params` are overlaid HERE @@ -1470,6 +1477,14 @@ export function translateAction( // [#26619] Display-only copy, overlaid exactly like `description`: the // bundle's sentence when there is one, else the authored value as written. const disabledReason = lookupActionField(bundle, action, 'disabledReason', opts) ?? action.disabledReason; + // [#27944] The param dialog's button, overlaid like `description` — but only + // for an action that opens that dialog, a non-empty `params`, the way + // `resolveActionResultDialog` reads nothing for an action with no dialog. + // `ActionSchema` refuses the key anywhere else, so the guard only decides + // whether a bundle can give a button to a dialog that never opens: never. + const submitText = Array.isArray(action.params) && action.params.length > 0 + ? lookupActionField(bundle, action, 'submitText', opts) ?? action.submitText + : action.submitText; const outcomeMessages = resolveActionOutcomeMessages(bundle, action, opts); const resultDialog = resolveActionResultDialog(bundle, action, opts); const params = translateActionParams(action, bundle, opts); @@ -1480,6 +1495,7 @@ export function translateAction( ...(confirmText !== undefined ? { confirmText } : {}), ...(successMessage !== undefined ? { successMessage } : {}), ...(disabledReason !== undefined ? { disabledReason } : {}), + ...(submitText !== undefined ? { submitText } : {}), ...(outcomeMessages !== action.outcomeMessages ? { outcomeMessages } : {}), ...(resultDialog !== undefined ? { resultDialog } : {}), ...(params !== action.params ? { params } : {}), diff --git a/packages/spec/src/system/translation.zod.ts b/packages/spec/src/system/translation.zod.ts index baf7e69d3d2..f588cc022a9 100644 --- a/packages/spec/src/system/translation.zod.ts +++ b/packages/spec/src/system/translation.zod.ts @@ -199,6 +199,7 @@ const actionTranslationSchema = (surface: string) => strictObject({ success: 'successMessage', successText: 'successMessage', toast: 'successMessage', disabledMessage: 'disabledReason', disabledTooltip: 'disabledReason', disabledHint: 'disabledReason', disabledText: 'disabledReason', + submitLabel: 'submitText', confirmLabel: 'submitText', parameters: 'params', args: 'params', inputs: 'params', result: 'resultDialog', dialog: 'resultDialog', }, @@ -216,6 +217,10 @@ const actionTranslationSchema = (surface: string) => strictObject({ // `ActionSchema.disabledReason` (#26619) — the display-only reason a greyed // action shows; overlaid by `translateAction` like the copy keys above. disabledReason: z.string().optional().describe('Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE'), + // `ActionSchema.submitText` (#27944) — the param dialog's submit button + // label; overlaid by `translateAction` like `description`, the same + // dialog's line under its title, for an action that declares `params`. + submitText: z.string().optional().describe('Translated label of the submit button in the action\'s param dialog (the action must declare `params`; no renderer shows it yet)'), // `ActionSchema.outcomeMessages` (#21095) — one translated message per // declared outcome, keyed by the same snake_case outcome name, so a bundle // key that could never match a handler's `outcome` is refused here rather @@ -453,6 +458,7 @@ export const ObjectTranslationDataSchema = lazySchema(() => strictObject({ * objects.._actions..confirmText * objects.._actions..successMessage * objects.._actions..disabledReason + * objects.._actions..submitText * objects.._actions..outcomeMessages. * objects.._actions..params..label * objects.._actions..params..helpText @@ -981,6 +987,7 @@ const appTranslationDataShape = () => ({ * globalActions..confirmText * globalActions..successMessage * globalActions..disabledReason + * globalActions..submitText * globalActions..outcomeMessages. * globalActions..params..label * globalActions..params..helpText diff --git a/packages/spec/src/ui/action-submit-text.test.ts b/packages/spec/src/ui/action-submit-text.test.ts new file mode 100644 index 00000000000..f2ccd055fb8 --- /dev/null +++ b/packages/spec/src/ui/action-submit-text.test.ts @@ -0,0 +1,193 @@ +// Copyright (c) 2026 ObjectStack contributors. Apache-2.0 license. +// +// objectstack#27944 — `submitText` on the action contract: the verb on the +// submit button of the dialog an action opens to collect its `params`, in +// place of the dialog's default "Confirm". +// +// These pins hold the spec half: the parse (both I18nLabel forms), the blank +// refusal, the refusal on an action that opens no param dialog, the aliases, +// and the translation slot with its `translateAction` overlay. The dangling +// translation half is pinned in +// `packages/lint/src/validate-translation-references.test.ts`. + +import { describe, it, expect } from 'vitest'; +import { ActionSchema, InlineActionSchema } from './action.zod'; +import { ObjectTranslationDataSchema, TranslationDataSchema, type TranslationBundle } from '../system/translation.zod'; +import { translateAction } from '../system/i18n-resolver'; + +/** Minimum legal registered action that collects params (HotCRM's `send_email` shape). */ +const base = { + name: 'send_email', + label: 'Send Email', + objectName: 'crm_contact', + target: 'sendEmail', + params: [ + { name: 'subject', label: 'Subject', type: 'text', required: true }, + { name: 'body', label: 'Body', type: 'textarea' }, + ], +} as const; + +const firstIssue = (input: unknown) => { + const result = ActionSchema.safeParse(input); + expect(result.success).toBe(false); + return result.success ? undefined : result.error.issues[0]; +}; + +describe('ActionSchema.submitText — the parse', () => { + it('accepts a plain string and keeps it verbatim', () => { + const result = ActionSchema.safeParse({ ...base, submitText: 'Send' }); + + expect(result.success).toBe(true); + expect(result.success && result.data.submitText).toBe('Send'); + }); + + it('accepts the inline locale map, as every I18nLabel does', () => { + const localized = { en: 'Send', 'zh-CN': '发送' }; + const result = ActionSchema.safeParse({ ...base, submitText: localized }); + + expect(result.success).toBe(true); + expect(result.success && result.data.submitText).toEqual(localized); + }); + + it('is optional — an action without it parses, and the dialog keeps "Confirm"', () => { + const result = ActionSchema.safeParse(base); + + expect(result.success).toBe(true); + expect(result.success && 'submitText' in result.data).toBe(false); + }); + + it('sits beside `description`, the same dialog\'s line under its title', () => { + const result = ActionSchema.safeParse({ + ...base, + description: 'The email goes to the contact\'s primary address.', + submitText: 'Send', + }); + + expect(result.success).toBe(true); + }); + + it('refuses every non-I18nLabel shape the `label` key refuses', () => { + for (const value of [42, null, ['a list'], { en: 7 }, { key: 'actions.send' }] as unknown[]) { + const asLabel = ActionSchema.safeParse({ ...base, label: value }).success; + const asSubmit = ActionSchema.safeParse({ ...base, submitText: value }).success; + + expect({ value, asLabel, asSubmit }).toEqual({ value, asLabel: false, asSubmit: false }); + } + }); +}); + +describe('ActionSchema.submitText — the refusals', () => { + it('refuses the empty string and a whitespace-only one, naming the default it would replace', () => { + for (const blank of ['', ' ']) { + const issue = firstIssue({ ...base, submitText: blank }); + + expect(issue?.code).toBe('custom'); + expect(issue?.path).toEqual(['submitText']); + expect(issue?.message).toMatch(/^a blank `submitText` would replace the param dialog's default button label, "Confirm"/); + } + }); + + it('refuses it on an action that declares no params — no param dialog opens to show it', () => { + const { params: _params, ...paramless } = base; + for (const input of [{ ...paramless, submitText: 'Send' }, { ...paramless, params: [], submitText: 'Send' }]) { + const issue = firstIssue(input); + + expect(issue?.code).toBe('custom'); + expect(issue?.path).toEqual(['submitText']); + expect(issue?.message).toMatch(/^`submitText` labels the submit button of the dialog an action opens to collect its `params`/); + } + }); + + it('refuses the inline locale map on a param-less action too — the rule reads the key, not its form', () => { + const { params: _params, ...paramless } = base; + expect(firstIssue({ ...paramless, submitText: { en: 'Send' } })?.path).toEqual(['submitText']); + }); +}); + +describe('ActionSchema.submitText — the shape around it', () => { + it('renames the two measured near-miss spellings onto the key', () => { + for (const nearMiss of ['submitLabel', 'confirmLabel']) { + const issue = firstIssue({ ...base, [nearMiss]: 'Send' }); + + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain(`\`${nearMiss}\` → \`submitText\``); + } + }); + + it('is not picked onto the INLINE action — that surface has no `description` either', () => { + const result = InlineActionSchema.safeParse({ + type: 'api', + target: '/api/v1/send', + label: 'Send', + params: [{ name: 'subject', label: 'Subject', type: 'text' }], + submitText: 'Send', + }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('submitText'); + }); +}); + +describe('the translation slot and its overlay', () => { + it('accepts the key at the object-scoped address, beside description', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { send_email: { label: '发送邮件', description: '邮件发往联系人的主邮箱。', submitText: '发送' } }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data._actions?.send_email?.submitText).toBe('发送'); + }); + + it('accepts it at the globalActions address an object-less action reads', () => { + const result = TranslationDataSchema.safeParse({ + globalActions: { log_call: { submitText: '记录通话' } }, + }); + + expect(result.success).toBe(true); + expect(result.success && result.data.globalActions?.log_call?.submitText).toBe('记录通话'); + }); + + it('renames the same near-misses inside a bundle', () => { + const result = ObjectTranslationDataSchema.safeParse({ + _actions: { send_email: { submitLabel: '发送' } }, + }); + + expect(result.success).toBe(false); + const issue = result.success ? undefined : result.error.issues[0]; + expect(issue?.code).toBe('unrecognized_keys'); + expect(issue?.message).toContain('`submitLabel` → `submitText`'); + }); + + const bundle: TranslationBundle = { + 'zh-CN': { + objects: { crm_contact: { _actions: { send_email: { submitText: '发送' }, archive: { submitText: '归档' } } } }, + globalActions: { log_call: { submitText: '记录通话' } }, + }, + }; + const action = { name: 'send_email', label: 'Send Email', objectName: 'crm_contact', params: [{ name: 'subject' }], submitText: 'Send' }; + + it('translateAction overlays the translated label for the requested locale', () => { + expect(translateAction(action, bundle, { locale: 'zh-CN' }).submitText).toBe('发送'); + }); + + it('reads an object-less action at globalActions', () => { + const logCall = { name: 'log_call', label: 'Log Call', params: [{ name: 'notes' }], submitText: 'Log Call' }; + expect(translateAction(logCall, bundle, { locale: 'zh-CN' }).submitText).toBe('记录通话'); + }); + + it('falls back to the authored label when the locale has none', () => { + expect(translateAction(action, bundle, { locale: 'ja-JP' }).submitText).toBe('Send'); + }); + + it('resolves the translated label for an action that collects params but authors none', () => { + const { submitText: _authored, ...unlabelled } = action; + expect(translateAction(unlabelled, bundle, { locale: 'zh-CN' }).submitText).toBe('发送'); + }); + + it('gives no button to an action without params, whatever the bundle carries', () => { + const archive = { name: 'archive', label: 'Archive', objectName: 'crm_contact' }; + expect('submitText' in translateAction(archive, bundle, { locale: 'zh-CN' })).toBe(false); + }); +}); diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index d4624d14d35..c6a6ef6a8ad 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -919,6 +919,41 @@ const actionDisabledReason = () => z.union([ InlineLocaleMapSchema, ]); +/** + * [#27944] `submitText` — the verb on the submit button of the dialog an action + * opens to collect its `params` ("Send", "Log Call"), in place of the dialog's + * default "Confirm". + * + * An I18nLabel (a plain string translated at + * `objects.OBJECT._actions.ACTION.submitText`, or an inline locale map), the + * route `description` — the same dialog's line under its title — takes. The + * name is the protocol's existing word for a submit button's copy: + * `object-form`'s `submitText` (`component.zod.ts`), beside its `cancelText`. + * `submitLabel` is the spelling the platform retired twice in favour of that + * one (the `element:form` prop and the page component-copy translation key), + * so it is an alias here, not the key. + * + * It is not `confirmText`. That one is the MESSAGE of the confirm dialog a + * param-LESS action opens; this one is the BUTTON of the param dialog, the + * dialog an action with params opens instead. The two never meet on one + * registered action: `confirmText` beside a non-empty `params` is refused + * below, and so is `submitText` without one — an action with no params opens + * no param dialog, so the text would parse clean and never show (ADR-0078). + * + * A blank string is refused for the reason `disabledReason`'s is: it would + * replace the default label with an empty button. Omitting the key keeps + * "Confirm". The rule is {@link NON_BLANK_STRING}, so the published JSON Schema + * carries it too. + */ +const SUBMIT_TEXT_BLANK = + 'a blank `submitText` would replace the param dialog\'s default button label, "Confirm", with ' + + 'an empty button. Write the verb the button performs (e.g. "Send", "Log Call"), or omit the ' + + 'key to keep the default.'; +const actionSubmitText = () => z.union([ + z.string().refine(NON_BLANK_STRING, { message: SUBMIT_TEXT_BLANK }), + InlineLocaleMapSchema, +]); + /** * The object half of {@link ActionSchema}, before its refinements. * @@ -953,6 +988,12 @@ const actionObject = () => strictObject({ // by the copy keys' `*Message` family. disabledMessage: 'disabledReason', disabledTooltip: 'disabledReason', disabledHint: 'disabledReason', disabledText: 'disabledReason', + // [#27944] The two spellings of the param dialog's button copy that are + // measured on a neighbouring surface: `submitLabel`, the form copy key the + // platform retired in favour of `submitText`, and `confirmLabel`, the + // spelling of the same button on a view's `bulkActionDefs` dialog. Neither + // is reachable by distance from `submitText`. + submitLabel: 'submitText', confirmLabel: 'submitText', style: 'variant', color: 'variant', appearance: 'variant', placement: 'locations', location: 'locations', position: 'locations', verb: 'method', httpMethod: 'method', @@ -1058,6 +1099,12 @@ const actionObject = () => strictObject({ */ description: I18nLabelSchema.optional().describe('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`.'), + /** + * The param dialog's submit button label — the contract is on + * {@link actionSubmitText}. + */ + submitText: actionSubmitText().optional().describe('Label of the submit button in the action\'s param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action\'s confirm dialog. A blank label is refused.'), + /** Target object this action belongs to (optional, snake_case) */ objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional().describe('Target object this action belongs to. When set, the action is auto-merged into the object\'s actions array by defineStack().'), @@ -2167,6 +2214,24 @@ export const ActionSchema = lazySchema(() => actionObject().refine((data) => { + 'correct for a param-LESS action, where the confirm IS the only dialog, and for a view\'s ' + '`bulkActionDefs`, where the pair renders one dialog by that schema\'s own contract.', path: ['confirmText'], +}).refine((data) => { + // [#27944] The mirror of the rule above, for the param dialog's button copy. + // The console's action runner opens the param dialog only for a non-empty + // `params` array (objectui `ActionRunner.execute`), so `submitText` on any + // other action parses clean and is never drawn — the ADR-0078 shape. Scoped + // by schema boundary like the rule above: `InlineActionSchema` does not pick + // the key at all. + if (data.submitText != null && !(Array.isArray(data.params) && data.params.length > 0)) { + return false; + } + return true; +}, { + message: + '`submitText` labels the submit button of the dialog an action opens to collect its `params`, ' + + 'and this action declares no `params`, so it opens no such dialog and the label would never ' + + 'show. Declare the `params` the dialog collects, or drop `submitText`. A param-less action ' + + 'that asks first opens a confirm dialog instead, whose message is `confirmText`.', + path: ['submitText'], }).superRefine((data, ctx) => { // #9566/#9474 — `onSuccess` is scoped to the action types whose success // carries a result for `${result.*}` to read: `api` (the `target` call's From df2d2167d950406e5605cca35bb37836bdb6987e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 12:46:24 +0000 Subject: [PATCH 02/10] =?UTF-8?q?feat(spec):=20submitText=20=E2=80=94=20li?= =?UTF-8?q?veness=20row,=20docs,=20key=20inventory,=20changesets?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .changeset/27944-action-keys-submit-text.md | 8 ++++++ .changeset/27944-action-submit-text.md | 28 +++++++++++++++++++ .../27944-lint-submit-text-translation.md | 8 ++++++ content/docs/protocol/objectui/actions.mdx | 25 +++++++++++++++++ content/docs/ui/translations.mdx | 2 +- ...l.meta-types-degenerate-derivation.test.ts | 6 ++-- packages/spec/liveness/action.json | 8 ++++++ ui/packages/core/src/actions/actionKeys.ts | 4 +++ 8 files changed, 86 insertions(+), 3 deletions(-) create mode 100644 .changeset/27944-action-keys-submit-text.md create mode 100644 .changeset/27944-action-submit-text.md create mode 100644 .changeset/27944-lint-submit-text-translation.md diff --git a/.changeset/27944-action-keys-submit-text.md b/.changeset/27944-action-keys-submit-text.md new file mode 100644 index 00000000000..a152f04756e --- /dev/null +++ b/.changeset/27944-action-keys-submit-text.md @@ -0,0 +1,8 @@ +--- +'@object-ui/core': patch +--- + +`SPEC_ACTION_KEYS` lists `submitText`, the key `@objectstack/spec` adds to `ActionSchema` for the param dialog's submit button label. An action that carries it is not counted as having an unknown key, and the inventory pin stays green. + +Clause-②: no +Metadata-protocol: none diff --git a/.changeset/27944-action-submit-text.md b/.changeset/27944-action-submit-text.md new file mode 100644 index 00000000000..d844eb87cf5 --- /dev/null +++ b/.changeset/27944-action-submit-text.md @@ -0,0 +1,28 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec): an action's param dialog can name its own submit verb — `submitText` + +Clause-②: yes (widening) +Metadata-protocol: added + +`ActionSchema` gains one optional key, `submitText`: the label of the submit button in the dialog an action opens to collect its `params`. The button says "Confirm" today for every such action. With the key, a send-email action can say "Send" and a log-call action "Log Call". An action that does not declare it parses exactly as before. + +```ts +defineAction({ + name: 'log_call', + label: 'Log Call', + objectName: 'crm_contact', + target: 'logCall', + submitText: 'Log Call', + params: [{ name: 'subject', label: 'Subject', type: 'text', required: true }], +}); +``` + +- **An `I18nLabel`.** A plain string or an inline locale map (`{ en, 'zh-CN' }`). A blank string is refused at parse: `''` or whitespace only. Omit the key to keep "Confirm". `submitLabel` and `confirmLabel` (the same button's spelling on a view's `bulkActionDefs`) are refused with a rename to `submitText`, on the action and in a translation bundle. +- **Actions with `params` only.** The param dialog opens only for a non-empty `params`, so `submitText` on any other registered action is refused at parse, naming the key. It is not `confirmText`, the message of the confirm dialog a param-less action opens. +- **Translated like `description`.** The slot is `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an action with no `objectName`). `translateAction` overlays it for an action that declares `params`, and never gives a button to an action without them. +- **The name.** `submitText` is the protocol's existing word for a submit button's copy: the `object-form` component's `submitText`, beside its `cancelText`. + +Nothing draws the key yet. The console's param dialog reads it in a follow-up change. Until then the liveness ledger marks it `planned` with `authorWarn`, so `os validate` warns an author who declares it that the button still says "Confirm". diff --git a/.changeset/27944-lint-submit-text-translation.md b/.changeset/27944-lint-submit-text-translation.md new file mode 100644 index 00000000000..e7b91f6fa78 --- /dev/null +++ b/.changeset/27944-lint-submit-text-translation.md @@ -0,0 +1,8 @@ +--- +'@objectstack/lint': patch +--- + +`validateTranslationReferences` judges an action translation's new `submitText` key (`@objectstack/spec`'s param dialog submit label). Under an action that declares no `params`, at `objects.OBJECT._actions.ACTION.submitText` or `globalActions.ACTION.submitText`, it reports `translation-target-unknown` at `error`: such an action opens no param dialog, so nothing reads the label. Under an action that declares `params` the key is accepted. + +Clause-②: no +Metadata-protocol: none diff --git a/content/docs/protocol/objectui/actions.mdx b/content/docs/protocol/objectui/actions.mdx index 43222d3d850..c4b271255aa 100644 --- a/content/docs/protocol/objectui/actions.mdx +++ b/content/docs/protocol/objectui/actions.mdx @@ -230,6 +230,7 @@ interface Action { patch?: Record; // Static field values for operation: 'update', merged under params body?: HookBody; // Inline logic for type: 'script' params?: ActionParam[]; // Inputs collected before execution + submitText?: I18nLabel; // The param dialog's submit button verb (default "Confirm") method?: 'POST' | 'PATCH' | 'PUT' | 'DELETE'; // for type: 'api' // UX @@ -496,6 +497,30 @@ params: `ActionParam` fields: `name`, `field`, `objectOverride`, `label`, `type`, `required` (default `false`), `options`, `placeholder`, `helpText`, `defaultValue`, `defaultFromRow`, `visible` (CEL predicate — the dialog omits the param when false), `requiresFeature` (capability gate, lowered into `visible` — see [Capability gates](#capability-gates-requiresfeature)). +### Naming the submit button: `submitText` + +An action with `params` collects them in a dialog whose submit button says **Confirm**. `submitText` gives that button the action's own verb, so a send-email action can say **Send** and a log-call action **Log Call**. The dialog's other copy stays where it was: the title is the action's `label`, and the line under it is `description`. + +```yaml +name: log_call +label: Log Call +objectName: crm_contact +type: script +target: logCall +description: Record a call with this contact. +submitText: Log Call +params: + - { name: subject, label: Subject, type: text, required: true } + - { name: notes, label: Notes, type: textarea } +``` + +- It is an `I18nLabel`: a plain string, translated at `objects.._actions..submitText` (`globalActions..submitText` for an action with no `objectName`), or an inline locale map. +- **Actions with `params` only.** An action with no `params` (or an empty list) opens no param dialog, so `submitText` there is refused at parse, and a translation of it under such an action is reported by `os validate` (`translation-target-unknown`). +- It is not `confirmText`. `confirmText` is the message of the confirm dialog a param-less action opens; `submitText` is the button of the param dialog an action with params opens instead. +- A blank string (`''` or whitespace only) is refused at parse. Omit the key to keep **Confirm**. +- `submitLabel` and `confirmLabel` (the button's spelling on a view's `bulkActionDefs`) are refused with a rename to `submitText`. +- The console does not draw it yet: until its param dialog reads the key, the button says **Confirm**, and `os validate` warns an author who declares it. + ## AI Tool Exposure (ADR-0011) An action can be exposed as an AI-callable tool. Exposure is **opt-in and default off** — set `ai.exposed: true`, which then **requires** `ai.description` (≥ 40 chars, the LLM-facing contract). diff --git a/content/docs/ui/translations.mdx b/content/docs/ui/translations.mdx index 32ab06f311d..2cc974eba17 100644 --- a/content/docs/ui/translations.mdx +++ b/content/docs/ui/translations.mdx @@ -73,7 +73,7 @@ export default defineStack({ | Picklist option labels | `objects..fields..options.` | | View titles, descriptions, empty states | `objects.._views.` | | Bulk-action copy on a list view (button, confirm prompt, dialog fields) | `objects.._views..bulkActions..label` / `.confirmText` / `.confirmLabel` / `.params..label` / `.help` / `.placeholder` — a bulk param's hint is `help`, not `helpText` | -| Action labels, confirm text, success messages, disabled reasons | `objects.._actions.` | +| Action labels, confirm text, success messages, disabled reasons, param dialog submit labels | `objects.._actions.` | | Action result dialogs (title / description / acknowledge / field labels) | `objects.._actions..resultDialog` | | Form sections | `objects.._sections.
` | | Custom validation-rule messages | `objects.._validations..message` | diff --git a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts index edffc002f1d..feba0c8e3ac 100644 --- a/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts +++ b/packages/metadata-protocol/src/protocol.meta-types-degenerate-derivation.test.ts @@ -255,10 +255,12 @@ describe('#17501 — /meta/types serves a real schema for `action`, and moves no // live sample below still holds every live key. // [#26619] 47 → 48: `disabledReason` (why a greyed action is // unavailable) joined the accepted set, and the sample names it. - expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(48); + // [#27944] 48 → 49: `submitText` (the param dialog's submit button + // label) joined the accepted set, and the sample names it. + expect(Object.keys(properties).length + retiredTopLevelCount('action')).toBe(49); // A sample an author would actually address, and the one #17500's // repeater titles need a node to sit on. - for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach', 'disabledReason']) { + for (const key of ['name', 'label', 'objectName', 'type', 'params', 'locations', 'outcomeMessages', 'requiresMembershipReach', 'disabledReason', 'submitText']) { expect(Object.keys(properties)).toContain(key); } }); diff --git a/packages/spec/liveness/action.json b/packages/spec/liveness/action.json index 88acfe8d89c..6576f90612f 100644 --- a/packages/spec/liveness/action.json +++ b/packages/spec/liveness/action.json @@ -16,6 +16,14 @@ "evidence": "ui/packages/app-shell/src/views/ActionParamDialog.tsx#ActionParamDialog renders it as the dialog's DialogDescription, falling back to the generic `actionDialog.description` string; ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#paramCollectionHandler and ui/packages/app-shell/src/views/RecordDetailView.tsx#paramCollectionHandler both feed it as `actionDescription(objForI18n, action?.name, action?.description)`; ui/packages/i18n/src/useObjectLabel.ts#actionDescription resolves it through ui/packages/i18n/src/useObjectLabel.ts#actionSuffixes, reading objects.{o}._actions.{a}.description for an action with a key object or globalActions.{a}.description for one without, never both, then this literal; framework: packages/cli/src/utils/i18n-extract.ts emits the key and packages/spec/src/system/translation.zod.ts declares the translation slot.", "note": "Added #7367 — the RENDERER half predates the key by design: the consumer chain above shipped while `ActionSchema` (strictObject) refused `description`, so no producer could reach it (the mirror image of declared-but-unenforced). Live from the moment this key exists: the dialog reads `action.description` directly, no second input required. Mechanism for the 2026-08-10 ruling on #7278 — an action with `params` carries its confirm question here instead of in `confirmText`, so one decision is one dialog. Moved out of spec source, #23434: packages/spec/src/ui/action.zod.ts (this key's docblock) and packages/spec/src/system/translation.zod.ts (the translation slot's comment) cited this chain by line, sha-less. Re-read at objectui @b9814b334 on 2026-10-15: the object-then-global fallback both described, and this entry's evidence with them, is gone — objectui#11439 retired it, so the client resolves one bundle entry the way packages/spec/src/system/i18n-resolver.ts#actionTranslationNode does." }, + "submitText": { + "status": "planned", + "verifiedAt": "2026-10-30", + "authorWarn": true, + "authorHint": "Keep it, but nothing reads it yet: no console draws an action's `submitText`, so the param dialog's button still says \"Confirm\" until the console's param dialog reads the key.", + "evidence": "packages/spec/src/system/i18n-resolver.ts#translateAction overlays the translated `objects.{o}._actions.{a}.submitText` (or `globalActions.{a}.submitText`) onto the served action when it declares `params`; packages/spec/src/system/translation.zod.ts declares the translation slot; packages/lint/src/validate-translation-references.ts#checkActionSubmitText refuses a translation under an action with no `params`.", + "note": "PLANNED until the console half of #27944 lands (spec PR first, console card second). The framework half is live — parse, blank refusal, the refusal without `params`, translation slot, translateAction overlay, the dangling-translation lint — but no renderer reads the key yet: ui/packages/app-shell/src/views/ActionParamDialog.tsx still draws `actionDialog.confirm`, fed by the two param-collection handlers (ui/packages/app-shell/src/hooks/useConsoleActionRuntime.tsx#paramCollectionHandler, ui/packages/app-shell/src/views/RecordDetailView.tsx#paramCollectionHandler). `authorWarn` is set because an author who writes the key today sees \"Confirm\", not their verb. The console PR flips this row to `live` with those read points, and drops `authorWarn` / `authorHint` in that same edit (a warned `live` row reds this gate, #21127)." + }, "objectName": { "status": "live", "verifiedAt": "2026-09-15", diff --git a/ui/packages/core/src/actions/actionKeys.ts b/ui/packages/core/src/actions/actionKeys.ts index 100c1f5a63c..3e1be6d685f 100644 --- a/ui/packages/core/src/actions/actionKeys.ts +++ b/ui/packages/core/src/actions/actionKeys.ts @@ -315,6 +315,10 @@ export const SPEC_ACTION_KEYS = [ // question. 'requiresMembershipReach', 'resultDialog', + // Added to `ActionSchema` by objectstack#27944: the param dialog's submit + // button label. Listed for this array's contract alone, like `description` + // above; the dialog reading it is the console's own follow-up. + 'submitText', 'successMessage', 'target', 'type', From 3dce4e93ad286563bb3cc035f4d60d82c2a8581e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 13:28:27 +0000 Subject: [PATCH 03/10] feat(spec): submitText sits beside params, the key it depends on; test types the overlay Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- packages/spec/src/ui/action-submit-text.test.ts | 5 +++-- packages/spec/src/ui/action.zod.ts | 12 ++++++------ 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/packages/spec/src/ui/action-submit-text.test.ts b/packages/spec/src/ui/action-submit-text.test.ts index f2ccd055fb8..24046b300b1 100644 --- a/packages/spec/src/ui/action-submit-text.test.ts +++ b/packages/spec/src/ui/action-submit-text.test.ts @@ -13,7 +13,7 @@ import { describe, it, expect } from 'vitest'; import { ActionSchema, InlineActionSchema } from './action.zod'; import { ObjectTranslationDataSchema, TranslationDataSchema, type TranslationBundle } from '../system/translation.zod'; -import { translateAction } from '../system/i18n-resolver'; +import { translateAction, type ActionLike } from '../system/i18n-resolver'; /** Minimum legal registered action that collects params (HotCRM's `send_email` shape). */ const base = { @@ -183,7 +183,8 @@ describe('the translation slot and its overlay', () => { it('resolves the translated label for an action that collects params but authors none', () => { const { submitText: _authored, ...unlabelled } = action; - expect(translateAction(unlabelled, bundle, { locale: 'zh-CN' }).submitText).toBe('发送'); + const translated: ActionLike = translateAction(unlabelled, bundle, { locale: 'zh-CN' }); + expect(translated.submitText).toBe('发送'); }); it('gives no button to an action without params, whatever the bundle carries', () => { diff --git a/packages/spec/src/ui/action.zod.ts b/packages/spec/src/ui/action.zod.ts index c6a6ef6a8ad..517bfdff5af 100644 --- a/packages/spec/src/ui/action.zod.ts +++ b/packages/spec/src/ui/action.zod.ts @@ -1099,12 +1099,6 @@ const actionObject = () => strictObject({ */ description: I18nLabelSchema.optional().describe('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`.'), - /** - * The param dialog's submit button label — the contract is on - * {@link actionSubmitText}. - */ - submitText: actionSubmitText().optional().describe('Label of the submit button in the action\'s param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action\'s confirm dialog. A blank label is refused.'), - /** Target object this action belongs to (optional, snake_case) */ objectName: z.string().regex(/^[a-z_][a-z0-9_]*$/).optional().describe('Target object this action belongs to. When set, the action is auto-merged into the object\'s actions array by defineStack().'), @@ -1417,6 +1411,12 @@ const actionObject = () => strictObject({ : undefined ), }).optional().describe('Input parameters required from user — an ActionParam[] DEFINITION array, never a payload map (a static request body goes in `bodyExtra`).'), + + /** + * The submit button label of the dialog `params` opens — the contract is + * on {@link actionSubmitText}. + */ + submitText: actionSubmitText().optional().describe('Label of the submit button in the action\'s param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action\'s confirm dialog. A blank label is refused.'), /** Visual Style */ variant: z.enum(['primary', 'secondary', 'danger', 'ghost', 'link']).optional().describe('Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent)'), From 9892ef8fbe75b40182b20eec27e1b654bb56ccff Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:22:55 +0000 Subject: [PATCH 04/10] chore(spec): regenerate artefacts for submitText Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/object.mdx | 1 + content/docs/references/kernel/metadata-plugin.mdx | 1 + content/docs/references/system/translation.mdx | 5 +++++ content/docs/references/ui/action.mdx | 1 + packages/spec/authorable-surface/ui.json | 1 + packages/spec/liveness/state-counts/action.md | 2 +- 6 files changed, 10 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 750b3ad0b84..c2db25c1cac 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -488,6 +488,7 @@ const result = ApiMethod.parse(data); | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | | **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`). | +| **submitText** | `string \| Record` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | | **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. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 851a7e3cbfa..4675d1d673e 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -333,6 +333,7 @@ const result = MetadataBulkResultSchema.parse(data); | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | | **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`). | +| **submitText** | `string \| Record` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | | **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. | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index ef8b0737a71..eab57bb99b0 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -129,6 +129,7 @@ Translation data for a single field | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`; no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -231,6 +232,7 @@ Translation data for a single object | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`; no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -405,6 +407,7 @@ Translation data for a single object | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`; no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -641,6 +644,7 @@ Translation data for a single object | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`; no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | @@ -846,6 +850,7 @@ Translation data for a single object | **confirmText** | `string` | optional | Translated confirmation prompt | | **successMessage** | `string` | optional | Translated success toast/message | | **disabledReason** | `string` | optional | Translated reason a greyed action shows (hover title and accessible description) while its `disabled` predicate is TRUE | +| **submitText** | `string` | optional | Translated label of the submit button in the action's param dialog (the action must declare `params`; no renderer shows it yet) | | **outcomeMessages** | `Record` | optional | Translated success copy per handler outcome, keyed by the snake_case outcome name the action's `outcomeMessages` declares | | **params** | `Record }>` | optional | Action parameter translations keyed by parameter name | | **resultDialog** | `{ title?: string; description?: string; acknowledge?: string; fields?: Record }` | optional | Translations for the action result dialog | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 94749085a6d..67160fed800 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -43,6 +43,7 @@ const result = ActionSchema.parse(data); | **patch** | `Record` | optional | For `operation: 'update'` — static field values written to the current record, merged UNDER the user-supplied `params` so a fixed value can be declared without exposing it in the dialog. Written on the data plane as the caller: object permissions, hooks and validations fire as for a user edit. Refused on an action without `operation: 'update'` (it would be silently dropped). | | **execution** | `Enum<'perRecord' \| 'aggregate'>` | optional | The bulk dispatch contract this action's BODY is written for, in `bulkActionDefs`' own vocabulary: 'perRecord' = one dispatch per selected row carrying that row's `recordId` (the view's `bulkActions: ['']` bare-string form); 'aggregate' = ONE dispatch for the whole selection carrying every id in `params._selectedIds` (a `bulkActionDefs` entry with `execution: 'aggregate'`). Optional with NO default — omit it only when the body genuinely serves both. A list view wiring a declared action under the other contract is refused by `@objectstack/lint` (`action-dispatch-contract-mismatch`). | | **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`). | +| **submitText** | `string \| Record` | optional | Label of the submit button in the action's param dialog, the verb the button performs (e.g. "Send", "Log Call"), in place of the default "Confirm" (declared ahead of its console reader: no renderer shows it yet) — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.submitText` (`globalActions.ACTION.submitText` for an object-less action). Only on an action with a non-empty `params`: without one no param dialog opens, so it is refused. Not `confirmText`, which is the message of a param-less action's confirm dialog. A blank label is refused. | | **variant** | `Enum<'primary' \| 'secondary' \| 'danger' \| 'ghost' \| 'link'>` | optional | Button visual variant for styling (primary = highlighted, danger = destructive, ghost = transparent) | | **order** | `number` | optional | Sort order within a location group (lower = higher). Promotes/demotes an action toward the record_header primary button; stable, so actions without `order` keep their registration order. | | **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. | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 8653b7dcf6a..dcd2db79f1e 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -44,6 +44,7 @@ "ui/Action:requiresFeature", "ui/Action:requiresMembershipReach", "ui/Action:resultDialog", + "ui/Action:submitText", "ui/Action:successMessage", "ui/Action:target", "ui/Action:type", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index 8e8f2660248..c19eb3cb863 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 48 | 0 | 0 | 1 | 0 | 49 | +| `action` | 48 | 0 | 0 | 1 | 1 | 50 | From 734502240e111e087821cdc9297c880e3ca0cd0f Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 14:40:03 +0000 Subject: [PATCH 05/10] test(spec): the action form records submitText as planned, not yet offered Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- .../system/metadata-form-zod-reconciliation.test.ts | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts index 9897e447d00..b53adb58fae 100644 --- a/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts +++ b/packages/spec/src/system/metadata-form-zod-reconciliation.test.ts @@ -365,6 +365,17 @@ const LEDGER: ReadonlyArray = [ key: 'conditionalFormatting', why: "planned, not yet rendered — a field's cell formatting rules (`[{ condition, style }]`, the list view's own rule element) are validated at parse and their CEL is judged at the authoring doors (it parses, it reads only `value` and `record`), but no renderer styles a cell from them until objectui's cell renderers read them, after the spec publishes. A form control offered today would let an author set a style nothing paints", }, + // #27944 — the param dialog's submit label lands spec-first the same way: + // `planned` in the liveness ledger, drawn by no renderer until the console's + // param dialog reads it. The console change that flips the ledger row + // deletes this row and offers the key in `action.form.ts`. + { + kind: 'omit', + type: 'action', + path: ROOT_PATH, + key: 'submitText', + why: "planned, not yet rendered — the param dialog's submit button label is validated at parse (an I18nLabel, never blank, only beside a non-empty `params`) and translated by `translateAction`, but the console's param dialog still draws its default \"Confirm\" until it reads the key. A form control offered today would let an author set a button label nothing shows", + }, { kind: 'omit', type: 'view', From f485240a2969f6fa5400faa50afeff7edc3b39e8 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 17:54:08 +0000 Subject: [PATCH 06/10] chore(spec): regenerate the authorable surface and object reference after merging main The merge took the branch's side of two regenerated files and dropped main's `KanbanConfig:lanes` and `ReportChart:seriesBy` entries; gen:schema and gen:docs put them back. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/object.mdx | 2 +- packages/spec/authorable-surface/ui.json | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index c2db25c1cac..137469658e2 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -382,7 +382,7 @@ const result = ApiMethod.parse(data); | **selection** | `{ type?: Enum<'none' \| 'single' \| 'multiple'> }` | optional | Row selection configuration | | **navigation** | `{ mode?: Enum<'drawer' \| 'page' \| 'new_window' \| 'none'>; preventNavigation?: boolean; openNewTab?: boolean; size?: Enum<'auto' \| 'sm' \| 'md' \| 'lg' \| 'xl' \| 'full'>; … }` | optional | Configuration for item click navigation (drawer by default, page, new_window or none) | | **pagination** | `{ pageSize?: integer; pageSizeOptions?: integer[] }` | optional | Pagination configuration | -| **kanban** | `{ groupByField: string; summarizeField?: string; titleField?: string; columns: string[] }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | +| **kanban** | `{ groupByField: string; lanes?: string[]; summarizeField?: string; titleField?: string; … }` | optional | Kanban-board configuration — applies when the view renders as a kanban layout | | **calendar** | `{ startDateField: string; endDateField?: string; titleField?: string; colorField?: string; … }` | optional | Calendar configuration — applies when the view renders as a calendar layout | | **gantt** | `{ startDateField: string; endDateField: string; titleField: string; progressField?: string; … }` | optional | Gantt-timeline configuration — applies when the view renders as a gantt layout | | **gallery** | `{ coverField?: string; coverFit?: Enum<'cover' \| 'contain'>; cardSize?: Enum<'small' \| 'medium' \| 'large'>; titleField?: string; … }` | optional | Gallery/card view configuration | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index dcd2db79f1e..23a6896e441 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -694,6 +694,7 @@ "ui/JoinedReportBlock:values", "ui/KanbanConfig:columns", "ui/KanbanConfig:groupByField", + "ui/KanbanConfig:lanes", "ui/KanbanConfig:summarizeField", "ui/KanbanConfig:titleField", "ui/ListChartConfig:chartType", @@ -1261,6 +1262,7 @@ "ui/ReportChart:height", "ui/ReportChart:interaction", "ui/ReportChart:series", + "ui/ReportChart:seriesBy", "ui/ReportChart:showDataLabels", "ui/ReportChart:showLegend", "ui/ReportChart:subtitle", From 528f8bf5f21b3a6feeb2988c99f24b9d3558e10b Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 30 Oct 2026 20:35:31 +0000 Subject: [PATCH 07/10] chore(spec): regenerate the authorable surface and references after merging main Main's side of the three regenerated files, re-derived from the merged tree so this branch's `submitText` rows sit beside main's new entries. Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/object.mdx | 2 ++ content/docs/references/system/translation.mdx | 11 ++++++----- packages/spec/authorable-surface/ui.json | 1 + 3 files changed, 9 insertions(+), 5 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 137469658e2..24d66ce3d5a 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -239,6 +239,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | @@ -603,6 +604,7 @@ const result = ApiMethod.parse(data); | **min** | `number` | optional | Minimum value. Checked on the WRITTEN value only — the same transition-gate class as `requiredWhen`: an UPDATE validates just the fields the payload carries, so a stored value below a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused, and a repairing write is accepted. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **max** | `number` | optional | Maximum value. Checked on the WRITTEN value only — the same transition-gate class as `min`: a stored value above a bound declared later is never re-read and survives unrelated edits; only a write that carries an out-of-bound value is refused. For an invariant re-checked on every write, declare a `validations[]` `script` rule instead. | | **useGrouping** | `boolean` | optional | Digit-grouping presentation hint for `number` fields — maps to `Intl.NumberFormat`'s `useGrouping`. Absent = renderer decides (interim heuristic today, locale default eventually); `false` = author opts out of grouping (e.g. a year or other ordinal/identifier integer); `true` = author pins grouping on. | +| **percentDisplay** | `Enum<'bar' \| 'plain'>` | optional | How a `percent` field is displayed read-only (list cells, record page). Absent = the renderer default, a progress bar beside the percentage; `bar` = pin the progress bar; `plain` = the percentage as text alone, with no bar, for a rate or a probability rather than progress. Display only: the stored value, its validation and its decimal places (`scale`) are unchanged. Declared ahead of its renderer: the Studio UI does not read it yet, so every percent field still draws the bar until it does. | | **accept** | `string[]` | optional | Permitted upload types for media fields, as MIME types or extensions (e.g. ["image/*", ".pdf"]). Offered to the file picker AND enforced on write. | | **maxSize** | `integer` | optional | Maximum permitted file size in BYTES for media fields. Enforced on write against the stored file size, not just checked in the browser. | | **options** | `{ label: string; value: string; description?: string; color?: string; … }[]` | optional | Static options for the option types. A `select` / `radio` field needs a non-empty list here or a `picklist` — with neither (or `options: []`) it is refused at parse. | diff --git a/content/docs/references/system/translation.mdx b/content/docs/references/system/translation.mdx index eab57bb99b0..1cdf019ee9e 100644 --- a/content/docs/references/system/translation.mdx +++ b/content/docs/references/system/translation.mdx @@ -92,7 +92,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record; … }>` | optional | Field-level translations | -| **_views** | `Record` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | @@ -117,6 +117,7 @@ Translation data for a single field | **label** | `string` | optional | Translated view label | | **description** | `string` | optional | Translated view description | | **title** | `string` | optional | Translated form view title — overlays a form view's own `title` (`FormViewSchema.title`); only a form that authors one is translated | +| **submitBehavior** | `{ title?: string; message?: string }` | optional | Translated thank-you panel copy — overlays a form view's `submitBehavior` `title` / `message` when its `kind` is `'thank-you'`; only copy the form authors is translated | | **emptyState** | `{ title?: string; message?: string }` | optional | Translated empty-state copy shown when the view has no rows | | **bulkActions** | `Record }>` | optional | Selection-bar translations keyed by bulk-action def name (`BulkActionDefSchema.name`) | @@ -202,7 +203,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record; … }>` | optional | Field-level translations | -| **_views** | `Record` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | @@ -377,7 +378,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record; … }>` | optional | Field-level translations | -| **_views** | `Record` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | @@ -614,7 +615,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record; … }>` | optional | Field-level translations | -| **_views** | `Record` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | @@ -820,7 +821,7 @@ Translation data for a single object | **pluralLabel** | `string` | optional | Translated plural label | | **description** | `string` | optional | Translated object description | | **fields** | `Record; … }>` | optional | Field-level translations | -| **_views** | `Record` | optional | View translations keyed by view name | +| **_views** | `Record` | optional | View translations keyed by view name | | **_actions** | `Record` | optional | Action translations keyed by action name | | **_sections** | `Record }>` | optional | Section translations keyed by section name | | **_tabs** | `Record` | optional | Filter-preset tab translations keyed by tab name | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 23a6896e441..271e6e1e104 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -1198,6 +1198,7 @@ "ui/RecordLineItemsProps:sort", "ui/RecordLineItemsProps:title", "ui/RecordLineItemsProps:totalField", + "ui/RecordPathProps:allowStageChange", "ui/RecordPathProps:aria", "ui/RecordPathProps:stages", "ui/RecordPathProps:statusField", From 26933721a568a44dbf6c128cd15928b8b1181541 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:37:18 +0000 Subject: [PATCH 08/10] chore(spec): regenerate the authorable surface and references after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/object.mdx | 3 +++ content/docs/references/kernel/metadata-plugin.mdx | 1 + content/docs/references/ui/action.mdx | 1 + packages/spec/authorable-surface/ui.json | 1 + 4 files changed, 6 insertions(+) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index 24d66ce3d5a..a6a753f787c 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -264,6 +264,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | @@ -504,6 +505,7 @@ const result = ApiMethod.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **pressed** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Pressed-state predicate (CEL string or `{dialect, source}` envelope) over the same scope as `visible`: the action surface draws the button as already pressed for this reader (a toggle face, `aria-pressed`) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under `attachedOnRead`, as `has(record.viewer) && record.viewer.vote == 'helpful'`. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | @@ -629,6 +631,7 @@ const result = ApiMethod.parse(data); | **lookupFilters** | `{ field: string; operator: Enum<'eq' \| 'ne' \| 'gt' \| 'lt' \| 'gte' \| 'lte' \| 'contains' \| 'in' \| 'notIn'>; value: any }[]` | optional | Base filters restricting which records are selectable (e.g. only active). The structured, picker-honoured lookup filter. | | **dependsOn** | `(string \| { field: string; param?: string })[]` | optional | Declares that this field's available values depend on the value of other field(s) on the same record — the form gates the field until they are set and re-evaluates as they change. For `lookup`/`master_detail` it scopes the candidate query (string = same local/remote key; `{field,param}` when the remote filter key differs — the `{field,param}` form is lookup-only). For `select`/`multiselect`/`radio` the actual per-option rule lives in each option's `visibleWhen`; list the referenced fields here (string form) so the option list gates and refreshes with the parent. | | **allowCreate** | `boolean` | optional | Allow inline quick-create from the record picker: when no match exists the user can create a record from the typed text (optimistic dataSource.create with the display field). Best for simple objects whose only required field is the display field. | +| **lookupAutofill** | `{ target: string; source: string; overwrite?: boolean }[]` | optional | Fields a pick in this lookup fills on the record being edited: each `{ target, source }` pair copies the picked record's `source` field (a field of the `reference` object) into this object's `target` field — e.g. `[{ target: 'unit_price', source: 'list_price' }]` on a quote line's product lookup. Read wherever this field is picked: the record form, and a line-item grid column over it. OVERWRITE RULE — the default, `overwrite` absent or false: a pick never replaces a value somebody wrote. The target is filled only while it is empty or still holds the value an earlier pick filled in; a value the user typed, or one the record already stored, stays. Only a pair that declares `overwrite: true` replaces the target on every pick, whatever it holds. Client-side only: it fills the form, and nothing on the write path reads it — the filled value is saved as an ordinary write, and an import or API write that sets the lookup fills nothing. Only on a single-value `lookup` / `master_detail`. Each target is a field of this object other than this one, written by its author — not `readonly`, not computed (`formula`, `summary`, `autonumber`, or an `expression`) — and named once. Each source is a field of the referenced object that the record carries (not `internal`) and whose value the target can hold; `os validate` / `os build` judge the source. In a line-item grid the column key `autofill` still governs only the same-named copy and is unchanged; a declared pair takes its target out of that copy. | | **expression** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | CEL expression the field's value is computed from, e.g. F`record.amount * 0.1`. On a `formula` field: computed on read, no column. On any other (stored) field: computed by the engine from the row the write stores and stored, so a client-sent value never stands and a `summary` can roll the column up — on every insert and every update by id (the stored row merged with the patch), and on a predicate update only when the patch moves an input (one value for every matched row, else the update is refused). | | **returnType** | `Enum<'number' \| 'text' \| 'boolean' \| 'date' \| 'currency'>` | optional | Inferred value type of a formula field (number/text/boolean/date/currency). `currency`: an amount of money, in the currency the formula's own `currencyConfig` declares, as a currency field declares it (no `currencyConfig`: dynamic, the tenant default currency). | | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | diff --git a/content/docs/references/kernel/metadata-plugin.mdx b/content/docs/references/kernel/metadata-plugin.mdx index 4675d1d673e..0ae32a9e826 100644 --- a/content/docs/references/kernel/metadata-plugin.mdx +++ b/content/docs/references/kernel/metadata-plugin.mdx @@ -348,6 +348,7 @@ const result = MetadataBulkResultSchema.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| …>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **pressed** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Pressed-state predicate (CEL string or `{dialect, source}` envelope) over the same scope as `visible`: the action surface draws the button as already pressed for this reader (a toggle face, `aria-pressed`) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under `attachedOnRead`, as `has(record.viewer) && record.viewer.vote == 'helpful'`. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/content/docs/references/ui/action.mdx b/content/docs/references/ui/action.mdx index 67160fed800..e198223488e 100644 --- a/content/docs/references/ui/action.mdx +++ b/content/docs/references/ui/action.mdx @@ -58,6 +58,7 @@ const result = ActionSchema.parse(data); | **requiresMembershipReach** | `Enum<'invite_member' \| 'cancel_invitation' \| 'update_member_role' \| 'transfer_ownership' \| 'remove_member' \| 'create_team' \| 'update_team' \| 'remove_team' \| … +4 more>` | optional | Organization endpoint (a MEMBERSHIP_REACH row) whose membership-grade gate this action follows; lowered into `visible` over current_user.positions at parse time. | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — `true`/`false` literal, CEL string, or `{dialect, source}` envelope. The action is shown but refused when it evaluates TRUE. Omit = never disabled. | | **disabledReason** | `string \| Record` | optional | Display-only reason a greyed action shows (its hover title and accessible description, or a menu item's second line) while `disabled` evaluates TRUE — the default-language string, or an inline locale map; translated at `objects.OBJECT._actions.ACTION.disabledReason` (`globalActions.ACTION.disabledReason` for an object-less action). Not a gate: the server refuses a call on its own rules (hooks, `requiredPermissions`), and nothing reads this as a condition. Omit = the default reason, "Not available for this record". A blank reason is refused. | +| **pressed** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Pressed-state predicate (CEL string or `{dialect, source}` envelope) over the same scope as `visible`: the action surface draws the button as already pressed for this reader (a toggle face, `aria-pressed`) while it evaluates TRUE. Display only — it never gates, greys or routes the action. A per-reader fact (e.g. the reader's own vote) is read from a block the object declares under `attachedOnRead`, as `has(record.viewer) && record.viewer.vote == 'helpful'`. A boolean literal is refused: a constant is a style, not a reader's state. Omit = never drawn pressed. | | **requiredPermissions** | `string[]` | optional | [ADR-0066 D4] Capabilities required to invoke this action. Enforced with 403 on every call the platform action door (`POST /actions/:object/:action` and MCP `run_action`) dispatches, and mirrored as a UI hide. ADR-0066 D4, as amended 2026-10-29, brings a `type: api` action whose declared `target` is a same-origin platform path (exactly one leading `/`, followed by neither `/` nor a backslash, with a path after it) under that door; its `target` endpoint stays directly reachable, so it MUST still enforce this capability itself, the door's gate being the added check. A `type: api` action with an off-origin `target` is browser-only and not gated server-side. | | **ai** | `{ exposed?: boolean; description?: string; category?: Enum<'data' \| 'action' \| 'flow' \| 'integration' \| 'vector_search' \| 'analytics' \| 'utility'>; paramHints?: Record; … }` | optional | AI exposure (opt-in). Set ai.exposed=true + ai.description to make this callable by agents. | | **recordIdParam** | `string` | optional | Body key to inject the row id into when running from a list_item context. | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index 271e6e1e104..e324dfc2b4f 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -37,6 +37,7 @@ "ui/Action:outcomeMessages", "ui/Action:params", "ui/Action:patch", + "ui/Action:pressed", "ui/Action:recordIdField", "ui/Action:recordIdParam", "ui/Action:refreshAfter", From 5f7d84af8dc541e5e3b58277e8580bc63eae1bdf Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 02:41:50 +0000 Subject: [PATCH 09/10] chore(spec): recount the action liveness ledger after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- packages/spec/liveness/state-counts/action.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index c19eb3cb863..d5c6f9f8c6f 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 48 | 0 | 0 | 1 | 1 | 50 | +| `action` | 48 | 0 | 0 | 1 | 2 | 51 | From 301f5b8c5bde38cb4c094f02703ef429ad3ea6c4 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 31 Oct 2026 05:13:24 +0000 Subject: [PATCH 10/10] chore(spec): regenerate the authorable surface and references after merging main Co-Authored-By: Claude Claude-Session: https://claude.ai/code/session_017ihL2fi9zfJhUTubyJYb6V --- content/docs/references/data/object.mdx | 4 ++-- packages/spec/authorable-surface/ui.json | 1 + packages/spec/liveness/state-counts/action.md | 2 +- 3 files changed, 4 insertions(+), 3 deletions(-) diff --git a/content/docs/references/data/object.mdx b/content/docs/references/data/object.mdx index a6a753f787c..864b75599f6 100644 --- a/content/docs/references/data/object.mdx +++ b/content/docs/references/data/object.mdx @@ -270,7 +270,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | @@ -637,7 +637,7 @@ const result = ApiMethod.parse(data); | **summaryOperations** | `{ object: string; field?: string; function: Enum<'count' \| 'sum' \| 'min' \| 'max' \| 'avg'>; relationshipField?: string; … }` | optional | Roll-up summary definition. The engine recomputes the value when child records are inserted/updated/deleted. | | **language** | `string` | optional | Programming language for syntax highlighting (e.g., javascript, python, sql) | | **step** | `number` | optional | Step increment for slider (default: 1) | -| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | +| **currencyConfig** | `{ currencyMode?: Enum<'dynamic' \| 'fixed' \| 'record'>; defaultCurrency?: string; currencyField?: string; homeAmount?: object }` | optional | Which currency an amount is in: for a currency field, and for a formula field whose returnType is currency. Absent: dynamic mode. | | **dimensions** | `integer` | optional | Vector dimensionality (e.g., 1536 for OpenAI embeddings) | | **trackHistory** | `boolean` | optional | Render this field's value changes as human-readable entries on the record activity timeline (ADR-0052 §5b). Opt-in per field. | | **group** | `string` | optional | Field group name for organizing fields in forms and layouts (e.g., "contact_info", "billing", "system") | diff --git a/packages/spec/authorable-surface/ui.json b/packages/spec/authorable-surface/ui.json index e324dfc2b4f..057a463d806 100644 --- a/packages/spec/authorable-surface/ui.json +++ b/packages/spec/authorable-surface/ui.json @@ -230,6 +230,7 @@ "ui/CalendarConfig:allDayField", "ui/CalendarConfig:colorField", "ui/CalendarConfig:endDateField", + "ui/CalendarConfig:initialDate", "ui/CalendarConfig:startDateField", "ui/CalendarConfig:titleField", "ui/ChartAggregate:field", diff --git a/packages/spec/liveness/state-counts/action.md b/packages/spec/liveness/state-counts/action.md index d5c6f9f8c6f..0ebe1aa66f9 100644 --- a/packages/spec/liveness/state-counts/action.md +++ b/packages/spec/liveness/state-counts/action.md @@ -12,4 +12,4 @@ committed anywhere: `check:liveness` sums the shards when it reads them. | Type | live | exp | elsewhere | dead | planned | classified | |---|---|---|---|---|---|---| -| `action` | 48 | 0 | 0 | 1 | 2 | 51 | +| `action` | 49 | 0 | 0 | 1 | 1 | 51 |