diff --git a/.changeset/action-designer-param-vocabulary.md b/.changeset/action-designer-param-vocabulary.md
new file mode 100644
index 000000000..9b82c424a
--- /dev/null
+++ b/.changeset/action-designer-param-vocabulary.md
@@ -0,0 +1,24 @@
+---
+"@object-ui/app-shell": patch
+---
+
+fix(app-shell): the Action designer's preview draws what the runtime dialog will draw
+
+`ActionDefaultInspector` offers eight param `type` spellings; `ActionPreview`'s
+dialog mock switched on five of them over a private table, so three of the eight
+previewed as a control `ActionParamDialog` does not render — `datetime` and a
+targeted `lookup` as plain text boxes, and a `select` whose options were not
+authored yet as a text box as well. A `text` param that happened to carry
+`options` previewed as a select the runtime never draws, for the same reason in
+the other direction.
+
+The mock now resolves each param through `paramToField`'s
+`resolveParamWidgetType` / `paramDegradesWithoutTarget` — the same adapter the
+dialog itself renders through — so the two panes cannot disagree about a
+spelling again. `datetime` draws a date/time control, a `lookup` with a declared
+`reference` draws a record picker, a targetless one draws the record-id text box
+the dialog degrades to and says why, and a `select` always draws a picker.
+
+The per-param editor also gains an `options` control for `select` params. It had
+none, and `params` is hidden from the collapsed "More fields" form, so the panel
+that offered the type had nowhere to author the choices the type needs.
diff --git a/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx b/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx
new file mode 100644
index 000000000..1d836ff71
--- /dev/null
+++ b/packages/app-shell/src/views/metadata-admin/ActionDesigner.paramVocabulary.test.tsx
@@ -0,0 +1,333 @@
+// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * **The Action designer's two panes agree on the param `type` vocabulary**
+ * (objectui#6538).
+ *
+ * `ActionPreview`'s declared job is "a faux button / dialog rendered so authors
+ * can see the visual weight before they ship it" — it shows what WILL render.
+ * `ActionDefaultInspector`'s `PARAM_TYPE_OPTS` decides what an author can pick.
+ * Until this card the two disagreed, and the preview lost: for three of the
+ * eight offered spellings it drew a control the runtime dialog does not draw.
+ *
+ * ## Where "what the runtime draws" comes from
+ *
+ * `ActionParamDialog` renders every param through the shared form field widgets
+ * (ADR-0059): `paramToField()` resolves the param's `type` to a widget key via
+ * `resolveParamWidgetType`, `paramDegradesWithoutTarget()` decides whether a
+ * picker collapses to a text box for want of a declared target, and
+ * `getLazyFieldWidget(key)` renders it. So the runtime's answer for a spelling
+ * is a pure function this file can call, and does — see {@link runtimeWidgetFor}.
+ *
+ * ## Why the expectations are hand-declared AND cross-checked
+ *
+ * `EXPECTED` below is read out of the runtime by a human: `datetime` renders
+ * `DateTimeField`, so its mock must be a datetime control, not a text box. If
+ * the test DERIVED each expectation from the same resolver the preview calls,
+ * it could never catch the preview calling the resolver wrongly — the pin would
+ * assert the implementation against itself. So the per-case expectation is
+ * literal, and a separate drift guard asserts that the literal table still
+ * matches what the resolver says. The first half is the pin; the second keeps
+ * it honest as the widget map moves.
+ *
+ * ## The population is EIGHT, not forty-nine
+ *
+ * `PARAM_TYPE_OPTS` is the population, imported rather than restated: the
+ * defect is that two panes of ONE designer disagree, not that the preview is
+ * incomplete against the spec's whole 49-member `FieldType`. The coverage
+ * guards below fail if a ninth spelling is offered without a case here, and if a
+ * case names a spelling the inspector does not offer.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import * as React from 'react';
+import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+import { render, screen, fireEvent, cleanup, within } from '@testing-library/react';
+import type { ActionParam, ResolvableParamFieldType } from '@object-ui/types';
+
+// ActionDefaultInspector calls useObjectOptions()/useObjectFields() at mount
+// (objectui#4697) — stub the shared client so mounting escapes to no network.
+const state = vi.hoisted(() => ({
+ metadataClient: { get: vi.fn(async () => undefined), list: vi.fn(async () => [] as unknown[]) },
+}));
+vi.mock('./useMetadata', () => ({
+ useMetadataClient: () => state.metadataClient,
+}));
+
+import { paramDegradesWithoutTarget, resolveParamWidgetType } from '../../utils/paramToField.js';
+import { ActionPreview } from './previews/ActionPreview';
+import { ActionDefaultInspector, PARAM_TYPE_OPTS } from './inspectors/ActionDefaultInspector';
+import { __setCelFormulaLoader } from './celAuthoring';
+
+beforeEach(() => {
+ // Both ConditionBuilders mount unconditionally; give them a loader that
+ // resolves so nothing reaches for the real CEL engine during these renders.
+ __setCelFormulaLoader(() =>
+ Promise.resolve({
+ validateExpression: () => ({ ok: true, errors: [], warnings: [] }),
+ introspectScope: () => ({ fields: [], roots: [], functions: [] }),
+ inferExpressionType: () => 'boolean' as const,
+ }),
+ );
+});
+
+afterEach(() => {
+ cleanup();
+ __setCelFormulaLoader(undefined);
+});
+
+/** The eight spellings the inspector offers — the population, by reference. */
+const OFFERED: readonly string[] = PARAM_TYPE_OPTS.map((o) => o.value);
+
+/**
+ * A control the preview's dialog mock can draw, as a stable spelling.
+ * `input:*` is the DOM `type` of the rendered ``; `record-picker` is the
+ * disabled combobox the preview mocks a `LookupField` with.
+ */
+type MockControl =
+ | 'input:text'
+ | 'input:number'
+ | 'input:date'
+ | 'input:datetime-local'
+ | 'input:checkbox'
+ | 'textarea'
+ | 'select'
+ | 'record-picker';
+
+/**
+ * The faithful mock for each runtime widget key the eight can resolve to.
+ * Hand-written on purpose (see the header): this is a human reading of what
+ * `@object-ui/fields` renders, which is the thing the preview is supposed to
+ * show. The drift guard below re-checks it against the resolver.
+ */
+const MOCK_FOR_RUNTIME_WIDGET: Readonly> = {
+ text: 'input:text',
+ textarea: 'textarea',
+ number: 'input:number',
+ boolean: 'input:checkbox',
+ select: 'select',
+ date: 'input:date',
+ datetime: 'input:datetime-local',
+ lookup: 'record-picker',
+};
+
+interface Case {
+ /** One of the eight `PARAM_TYPE_OPTS` spellings. */
+ readonly spelling: ResolvableParamFieldType;
+ /** Extra authored keys that change what the runtime does with the spelling. */
+ readonly extra?: Partial;
+ /** What the RUNTIME dialog draws for this param — read out of the runtime. */
+ readonly expected: MockControl;
+ readonly why: string;
+}
+
+const CASES: readonly Case[] = [
+ { spelling: 'text', expected: 'input:text', why: 'TextField' },
+ {
+ spelling: 'text',
+ extra: { options: [{ label: 'Duplicate', value: 'dup' }] },
+ expected: 'input:text',
+ why: 'options on a `text` param are ignored by the runtime — it still renders TextField',
+ },
+ { spelling: 'textarea', expected: 'textarea', why: 'TextAreaField' },
+ { spelling: 'number', expected: 'input:number', why: 'NumberField' },
+ { spelling: 'boolean', expected: 'input:checkbox', why: 'BooleanField, widget `checkbox`' },
+ {
+ spelling: 'select',
+ extra: { options: [{ label: 'Duplicate', value: 'dup' }, { label: 'Spam', value: 'spam' }] },
+ expected: 'select',
+ why: 'SelectField',
+ },
+ {
+ spelling: 'select',
+ expected: 'select',
+ why: 'SelectField renders an EMPTY picker with a placeholder — not a text box',
+ },
+ { spelling: 'date', expected: 'input:date', why: 'DateField' },
+ { spelling: 'datetime', expected: 'input:datetime-local', why: 'DateTimeField' },
+ {
+ spelling: 'lookup',
+ extra: { reference: 'account' },
+ expected: 'record-picker',
+ why: 'LookupField — a record picker, once a target is declared',
+ },
+ {
+ spelling: 'lookup',
+ expected: 'input:text',
+ why: 'a targetless lookup DEGRADES to a record-id text box (paramDegradesWithoutTarget)',
+ },
+];
+
+function paramFor(c: Case): ActionParam {
+ return { name: 'p', label: 'P', type: c.spelling, ...c.extra };
+}
+
+function describeCase(c: Case): string {
+ const extra = c.extra ? ` + ${Object.keys(c.extra).join('/')}` : '';
+ return `${c.spelling}${extra}`;
+}
+
+/**
+ * What `ActionParamDialog` would resolve this AUTHORED param to.
+ *
+ * The one rename this crossing needs: the authoring key is `reference`
+ * (`ActionParamSchema`), the resolved key `paramToField` reads is `referenceTo`
+ * — `resolveActionParams()` performs exactly that copy before the dialog sees a
+ * param. Nothing else is restated; the membership tables stay where they are.
+ */
+function runtimeWidgetFor(param: ActionParam): string {
+ const type = param.type as string;
+ const resolved = { name: param.name ?? '', label: '', type, referenceTo: param.reference };
+ return paramDegradesWithoutTarget(resolved) ? 'text' : resolveParamWidgetType(type);
+}
+
+function renderPreviewWith(param: ActionParam): HTMLElement {
+ const { container } = render(
+ ,
+ );
+ return container as HTMLElement;
+}
+
+/**
+ * The one control the dialog mock drew for the one param in the draft.
+ *
+ * Asserting the count is part of the instrument: an empty match would make
+ * every `not.toBe('input:text')` pass vacuously, and two matches would mean the
+ * query is picking up something outside the mock.
+ */
+function drawnControl(container: HTMLElement): MockControl {
+ const controls = Array.from(
+ container.querySelectorAll('input, textarea, select, button[aria-haspopup="dialog"]'),
+ );
+ expect(controls.map((el) => el.outerHTML), 'exactly one control per param mock').toHaveLength(1);
+ const el = controls[0]!;
+ const tag = el.tagName.toLowerCase();
+ if (tag === 'select') return 'select';
+ if (tag === 'textarea') return 'textarea';
+ if (tag === 'button') return 'record-picker';
+ return `input:${(el as HTMLInputElement).type}` as MockControl;
+}
+
+describe('Action designer — the preview draws what the runtime dialog will draw (#6538)', () => {
+ it.each(CASES.map((c) => [describeCase(c), c] as const))(
+ 'previews an offered `%s` param as the control the runtime renders',
+ (_name, c) => {
+ const container = renderPreviewWith(paramFor(c));
+ expect(
+ drawnControl(container),
+ `the inspector offers "${c.spelling}"; the runtime dialog draws ${c.why}`,
+ ).toBe(c.expected);
+ },
+ );
+
+ it('draws no offered spelling as a plain text box unless the runtime does', () => {
+ // The disagreement, stated as one sentence rather than per case: a text box
+ // in the preview is a CLAIM that the runtime shows a text box.
+ const lying = CASES.filter((c) => {
+ const drawn = drawnControl(renderPreviewWith(paramFor(c)));
+ cleanup();
+ return drawn === 'input:text' && MOCK_FOR_RUNTIME_WIDGET[runtimeWidgetFor(paramFor(c))] !== 'input:text';
+ }).map(describeCase);
+ expect(lying, 'these spellings preview as a text box the runtime dialog will not draw').toEqual([]);
+ });
+
+ // ── Guards on the instrument itself ──────────────────────────────────────
+
+ it('covers every spelling the inspector offers, and no other', () => {
+ const covered = [...new Set(CASES.map((c) => c.spelling as string))].sort();
+ expect(covered, 'PARAM_TYPE_OPTS is the population — a ninth entry needs a case here').toEqual(
+ [...OFFERED].sort(),
+ );
+ });
+
+ it('the hand-declared expectations still match what the runtime resolver says', () => {
+ const drifted = CASES.filter((c) => MOCK_FOR_RUNTIME_WIDGET[runtimeWidgetFor(paramFor(c))] !== c.expected).map(
+ (c) => `${describeCase(c)} → ${runtimeWidgetFor(paramFor(c))} (expected mock ${c.expected})`,
+ );
+ expect(drifted, 'the widget map moved under this table — re-read the runtime before editing').toEqual([]);
+ });
+
+ it('every runtime widget the eight resolve to has a declared mock', () => {
+ const unmapped = CASES.map((c) => runtimeWidgetFor(paramFor(c))).filter(
+ (w) => MOCK_FOR_RUNTIME_WIDGET[w] === undefined,
+ );
+ expect([...new Set(unmapped)]).toEqual([]);
+ });
+
+ it('an untyped (field-backed) param keeps inferring from its options', () => {
+ // Not one of the eight: a `{ field: … }` param carries no `type` in the
+ // draft and the designer does not resolve object metadata here, so options
+ // are the only evidence the preview has. Pinned so the type-driven
+ // switch above cannot quietly take this case with it.
+ const withOptions = renderPreviewWith({ field: 'status', options: [{ label: 'Open', value: 'open' }] });
+ expect(drawnControl(withOptions)).toBe('select');
+ cleanup();
+ expect(drawnControl(renderPreviewWith({ field: 'status' }))).toBe('input:text');
+ });
+});
+
+/* ─────────────── The other half of the same seam ─────────────── */
+
+/**
+ * Stateful harness — the inspector is CONTROLLED, so a committed patch has to
+ * round-trip through the draft or the next commit reverts the last one.
+ */
+function InspectorHarness({ onDraft, params }: {
+ onDraft: (d: Record) => void;
+ params: ActionParam[];
+}) {
+ const [draft, setDraft] = React.useState>({
+ name: 'approve',
+ label: 'Approve',
+ type: 'script',
+ params,
+ });
+ React.useEffect(() => onDraft(draft), [draft, onDraft]);
+ return (
+ ) => setDraft((d) => ({ ...d, ...patch }))}
+ readOnly={false}
+ locale={'en-US' as never}
+ />
+ );
+}
+
+function renderInspector(params: ActionParam[]) {
+ const seen: Record[] = [];
+ const onDraft = (d: Record) => { seen.push(d); };
+ render();
+ return { latestParams: () => (seen.at(-1)?.params ?? []) as ActionParam[] };
+}
+
+describe('Action designer — the panel that offers `select` can author its options (#6538)', () => {
+ it('offers an options editor for a select param', () => {
+ renderInspector([{ name: 'reason', label: 'Reason', type: 'select' }]);
+ // Before this card the per-param editor had controls for field, name,
+ // label, type, placeholder, required and defaultFromRow — and no way at all
+ // to author `options`. `params` is in CURATED_FIELDS, so the collapsed
+ // "More fields" SchemaForm hides the whole array too: there was nowhere in
+ // this panel to put them.
+ expect(screen.getByRole('group', { name: 'Options' })).toBeInTheDocument();
+ });
+
+ it('writes an authored option onto the param the preview reads', () => {
+ const { latestParams } = renderInspector([{ name: 'reason', label: 'Reason', type: 'select' }]);
+ const group = () => screen.getByRole('group', { name: 'Options' });
+ fireEvent.click(within(group()).getByRole('button', { name: /add option/i }));
+ fireEvent.change(within(group()).getByLabelText('Label'), { target: { value: 'Duplicate' } });
+ fireEvent.change(within(group()).getByLabelText('Value'), { target: { value: 'dup' } });
+ expect(latestParams()[0]?.options).toEqual([{ label: 'Duplicate', value: 'dup' }]);
+ });
+
+ it('shows no options editor for a spelling the runtime does not read options for', () => {
+ renderInspector([{ name: 'note', label: 'Note', type: 'text' }]);
+ expect(screen.queryByRole('group', { name: 'Options' })).toBeNull();
+ });
+});
diff --git a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx
index 09e4f1715..b40368676 100644
--- a/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx
+++ b/packages/app-shell/src/views/metadata-admin/inspectors/ActionDefaultInspector.tsx
@@ -112,7 +112,15 @@ const BODY_LANG_OPTS = [
* the way `long_text` reached `ActionPreview` while the local `type?: string`
* was still in force (objectui#6329).
*/
-const PARAM_TYPE_OPTS = [
+/*
+ * Exported for objectui#6538's pin, which compares what this dropdown OFFERS
+ * against what `ActionPreview` draws — and reads the eight from here by
+ * reference rather than restating them, so a ninth entry has to fail that pin.
+ * A hand-kept copy of the vocabulary could not. The cost is this panel's fast
+ * refresh, which the directive below accepts by name.
+ */
+// eslint-disable-next-line react-refresh/only-export-components -- see above
+export const PARAM_TYPE_OPTS = [
{ value: 'text', label: 'Text' },
{ value: 'textarea', label: 'Long text' },
{ value: 'number', label: 'Number' },
@@ -300,6 +308,94 @@ function FieldPicker({ label, objectName, value, onCommit, disabled }: {
return ;
}
+/** One entry of `ActionParam.options`, read off the published type. */
+type ActionParamOption = NonNullable[number];
+
+/**
+ * Options editor for a `select` param — the choices the dialog will offer.
+ *
+ * ## Why this exists (objectui#6538)
+ *
+ * `PARAM_TYPE_OPTS` offers `Select`, and `ActionPreview` draws the picker the
+ * dialog will render — but until this card the per-param editor had controls
+ * for `field`, `name`, `label`, `type`, `placeholder`, `required` and
+ * `defaultFromRow`, and none for `options`. The panel that offered the type
+ * could not produce the data the type needs.
+ *
+ * Triage allowed either a real control or a visible POINTER to wherever options
+ * are authored. The pointer lost on measurement: `params` is listed in
+ * {@link CURATED_FIELDS}, so the collapsed "More fields" `SchemaForm` hides the
+ * whole array too. The only surface that could author `options` was the raw
+ * JSON source tab — i.e. the pointer would have had to say "leave the
+ * designer", which is the designer conceding it cannot author its own offered
+ * type. So: a control.
+ *
+ * ## Scoped to `select`, deliberately
+ *
+ * `select` is the only spelling among the eight whose runtime widget reads
+ * `options` (`SelectField`). A `text` param carrying `options` is not an
+ * author's mistake to be enabled here — `TextField` ignores them, which is
+ * exactly what the preview now shows.
+ *
+ * Localised labels: an option label committed here is written as a plain
+ * string, flattening an authored `{ en, fr-FR }` map — the same trade the
+ * param's own "Label" control above has always made. Locale maps stay
+ * authorable through the JSON source tab.
+ */
+function ParamOptionsEditor({ options, onCommit, disabled }: {
+ options: ActionParamOption[] | undefined;
+ onCommit: (next: ActionParamOption[]) => void;
+ disabled?: boolean;
+}) {
+ const opts = Array.isArray(options) ? options : [];
+ return (
+
+
Options
+ {opts.length === 0 ? (
+
+ No choices yet — a Select with no options opens an empty picker in the dialog.
+
+ );
+}
+
/*
* No local `ActionParam` here (objectui#6329). `@object-ui/types` publishes the
* authoring shape, derived from the spec's `ActionParamSchema` input; this
@@ -496,6 +592,16 @@ export function ActionDefaultInspector({
{!p.field && (
patchParam(i, { type: asParamFieldType(v) })} disabled={readOnly} />
)}
+ {/* `select` is the one offered spelling whose runtime widget reads
+ `options` — see ParamOptionsEditor for why this is a control
+ and not a pointer (objectui#6538). */}
+ {!p.field && p.type === 'select' && (
+ patchParam(i, { options: next.length > 0 ? next : undefined })}
+ disabled={readOnly}
+ />
+ )}
patchParam(i, { placeholder: v })} disabled={readOnly} />