From 3ba7da5902480c5cf68cbdedcd561334b3c0ff12 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:26:33 +0000 Subject: [PATCH 1/7] feat(spec)!: a page action row's onSuccess is ActionSchema's own block, and its executor key refuses the 'navigation' alias The rows' onSuccess (action:button, action:icon, and each action:group / action:menu member) takes ActionSchema.onSuccess's block by reference, so navigate is judged by navigation-target.ts's grammar. The executor key refuses 'navigation', the console runner's local alias of 'url', with the rename. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- ...page-action-row-on-success-and-executor.md | 38 ++++ ...ate-component-props-row-on-success.test.ts | 77 +++++++ ...-action-row-navigation-executor-refused.ts | 30 +++ ...page-action-row-on-success-shape-closed.ts | 38 ++++ ...ent-action-row-on-success-executor.test.ts | 203 ++++++++++++++++++ ...omponent-props-unknown-members.pin.test.ts | 4 +- packages/spec/src/ui/component.zod.ts | 119 ++++++++-- 7 files changed, 491 insertions(+), 18 deletions(-) create mode 100644 .changeset/23103-page-action-row-on-success-and-executor.md create mode 100644 packages/lint/src/validate-component-props-row-on-success.test.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-navigation-executor-refused.ts create mode 100644 packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-on-success-shape-closed.ts create mode 100644 packages/spec/src/ui/component-action-row-on-success-executor.test.ts diff --git a/.changeset/23103-page-action-row-on-success-and-executor.md b/.changeset/23103-page-action-row-on-success-and-executor.md new file mode 100644 index 00000000000..b8338d6d2bb --- /dev/null +++ b/.changeset/23103-page-action-row-on-success-and-executor.md @@ -0,0 +1,38 @@ +--- +'@objectstack/spec': minor +--- + +feat(spec)!: a page action row's `onSuccess` is `ActionSchema`'s own post-success block, so its `navigate` is judged by the url scheme set, and the row's executor key refuses `'navigation'`, the runner's alias of `'url'` + +Clause-②: no (narrowing) + + + +**BREAKING**: an accept-set narrowing on two keys of four published page component rows, shipped as `minor` under the launch-window convention for accept-set narrowings. + +The page action rows are `action:button` and `action:icon` (`properties`), and each member of an `action:group` or `action:menu` (`properties.actions[]`). Each forwards its action keys to the console's action runner. Two of those keys still let a row carry a target the runner refuses at the click: + +- **`onSuccess`** was any value (`z.unknown()`). The runner reads only `ActionSchema`'s post-success block, `{ navigate, openIn }`, and classifies `navigate` with the url scheme set. So `onSuccess: { navigate: 'javascript:alert(1)' }` parsed clean, and so did a misspelled key or a callback shape, which the runner gives no reading at all. The rows now hold `ActionSchema.onSuccess`'s own block, the same instance by reference, so a row's verdict on any `onSuccess` value is the action's: the scheme set on `navigate`, the rename prescriptions for `url` / `redirectUrl` / `to` / `route` / `path` / `target` / `navigateTo` / `href`, and the closed `openIn` with its `'self'` default. +- **The executor key** (`actionType` on a node, `type` on a member) is a free string, and the rows judged a url `target` only when it read `'url'`. The runner also dispatches `'navigation'`, its local alias of `'url'`, to the same navigator, so `actionType: 'navigation'` with `target: 'javascript:…'` parsed clean. The url executor now has one spelling on the rows: `'navigation'` is refused at the executor key, with the rename onto `'url'`. The key otherwise stays open: a handler a host registers under another name (`navigate_create`, `navigate_edit`, …) is still dispatched by name. + +A page's `properties` bag is outside the stack parse. `os validate` judges it through its props gate, which parses the bag against these same rows, so it reports each refusal at the row's own path with the same text, as a `component-props-invalid` finding (that gate's one tier, a warning). A stored page still saves and loads. + +### FROM → TO + +| before | what to write instead | +| --- | --- | +| `onSuccess: { navigate: 'javascript:…' }` (or `data:`, `tel:`, any other scheme) | an `https://` page or an in-app route, or drop the `onSuccess` — no hop runs a script or renders content | +| `onSuccess: { navigate: '//cdn.example.com/x' }` | `'https://cdn.example.com/x'`, or `'/x'` for an in-app path | +| `onSuccess: { navigate: 'tasks/${result.id}' }` (a bare relative template) | the rooted route, `'/tasks/${result.id}'`, or a `./` path | +| `onSuccess: { navigate: '' }`, or `onSuccess: {}` | the address the hop was meant to open, or drop the `onSuccess` | +| `onSuccess: { url: '/x' }` (or `redirectUrl`, `to`, `route`, `path`, `target`, `navigateTo`, `href`) | `onSuccess: { navigate: '/x' }` | +| `onSuccess: { navigate: '/x', openIn: 'new-tab' }` | `openIn: 'newTab'` — the block's spelling, not the top-level url switch's | +| `onSuccess: { type: 'notify', … }`, an array of callbacks, or a bare string | drop it: the runner reads only the navigation block, so these never ran | +| `actionType: 'navigation'` on an `action:button` / `action:icon` | `actionType: 'url'`, with the same `target` and `openIn` | +| `type: 'navigation'` on an `action:group` / `action:menu` member | `type: 'url'`, with the same `target` and `openIn` | + +**The one-line fix: give every page action row's `onSuccess` the action's block, `{ navigate, openIn? }`, with a `navigate` that is an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — and spell the url executor `'url'`.** The rename from `'navigation'` is lossless on a row: the runner sends both spellings to one navigator, and the alias's own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. `os migrate meta --from 17` lists the D3 entries `ui-page-action-row-on-success-shape-closed` and `ui-page-action-row-navigation-executor-refused`; the rewrite is by hand. + +**What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way. + +**Measured producers: none refused.** No page action row in this repository or in objectui (at its pin `1fb1ced3`) writes an `onSuccess` outside the block or the executor `'navigation'`. objectui's row tests write `{ navigate: '/app/records/${result.id}' }`, admitted, and every `type: 'navigation'` there is an `element:button` inline action or a runner test, not a page action row. Host-registered executor names are kept: this package's own released guidance prescribes `actionType: 'navigate_edit'` on an `action:button`. diff --git a/packages/lint/src/validate-component-props-row-on-success.test.ts b/packages/lint/src/validate-component-props-row-on-success.test.ts new file mode 100644 index 00000000000..b0e3547925f --- /dev/null +++ b/packages/lint/src/validate-component-props-row-on-success.test.ts @@ -0,0 +1,77 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. +// +// `os validate`'s props gate on a page action row's `onSuccess` and executor key. +// +// A page's `properties` bag is outside the stack parse, so the props gate is +// the door `os validate` judges an `action:button` / `action:icon` / +// `action:group` member / `action:menu` member through. It parses the bag +// against the row's `ComponentPropsMap` schema, whose `onSuccess` is now +// `ActionSchema`'s own post-success block (so `navigate` is judged by the +// shared url-target grammar, `navigation-target.ts` in `@objectstack/spec`) and +// whose executor key refuses `'navigation'`, the runner's alias of `'url'`. +// These pins hold that the gate reports each refusal at the row's own path, +// with the parse's own first sentence, and stays silent on the accepted +// control. The finding is a `component-props-invalid` warning, the gate's one +// tier (its module header says why); no code in this package changed for it. +import { describe, expect, it } from 'vitest'; +import { validateComponentProps, COMPONENT_PROPS_INVALID } from './validate-component-props.js'; + +type AnyRec = Record; + +/** One page, one region, one component. */ +const stackWith = (component: AnyRec): AnyRec => ({ + pages: [ + { + name: 'probe_page', + label: 'Probe', + type: 'home', + object: 'task', + regions: [{ name: 'main', components: [component] }], + }, + ], +}); + +const BASE = 'pages[0].regions[0].components[0].properties'; +const NAVIGATE_REFUSAL = "An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens."; + +/** [row, the node for a given action, the path prefix of the row's own keys, its executor key, its subject]. */ +const ROWS: ReadonlyArray AnyRec, string, 'actionType' | 'type', string]> = [ + ['action:button', (action) => ({ type: 'action:button', properties: { label: 'Clone', ...action } }), + BASE, 'actionType', "An `action:button`'s `actionType: 'navigation'`"], + ['action:icon', (action) => ({ type: 'action:icon', properties: { icon: 'copy', label: 'Clone', ...action } }), + BASE, 'actionType', "An `action:icon`'s `actionType: 'navigation'`"], + ['action:group member', (action) => ({ type: 'action:group', properties: { actions: [{ label: 'Clone', ...action }] } }), + `${BASE}.actions.0`, 'type', "An `action:group` member's `type: 'navigation'`"], + ['action:menu member', (action) => ({ type: 'action:menu', properties: { actions: [{ label: 'Clone', ...action }] } }), + `${BASE}.actions.0`, 'type', "An `action:menu` member's `type: 'navigation'`"], +]; + +describe("the props gate judges a page action row's `onSuccess` as `ActionSchema`'s own block", () => { + it.each(ROWS)('REFUSAL: %s with `onSuccess.navigate: \'javascript:alert(1)\'` is one finding at `onSuccess.navigate`', (_row, node, at, key) => { + const findings = validateComponentProps(stackWith(node({ + [key]: 'api', target: '/api/v1/tasks/clone', onSuccess: { navigate: 'javascript:alert(1)' }, + }))); + expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.onSuccess.navigate`]]); + expect(findings[0].message).toContain(NAVIGATE_REFUSAL); + }); + + it.each(ROWS)('ACCEPTED CONTROL: %s with an in-app route template reports nothing', (_row, node, _at, key) => { + expect(validateComponentProps(stackWith(node({ + [key]: 'api', target: '/api/v1/tasks/clone', onSuccess: { navigate: '/apps/crm/tasks/${result.id}' }, + })))).toEqual([]); + }); +}); + +describe("the props gate refuses `'navigation'` at a page action row's executor key", () => { + it.each(ROWS)("REFUSAL: %s with `'navigation'` is one finding at its executor key", (_row, node, at, key, subject) => { + const findings = validateComponentProps(stackWith(node({ [key]: 'navigation', target: '/apps/crm/tasks' }))); + expect(findings.map((f) => [f.rule, f.path])).toEqual([[COMPONENT_PROPS_INVALID, `${at}.${key}`]]); + expect(findings[0].message).toContain( + `${subject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling.`, + ); + }); + + it.each(ROWS)("ACCEPTED CONTROL: %s with `'url'` and the same target reports nothing", (_row, node, _at, key) => { + expect(validateComponentProps(stackWith(node({ [key]: 'url', target: '/apps/crm/tasks' })))).toEqual([]); + }); +}); diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-navigation-executor-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-navigation-executor-refused.ts new file mode 100644 index 00000000000..ae12a469931 --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-navigation-executor-refused.ts @@ -0,0 +1,30 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-page-action-row-navigation-executor-refused', + surface: 'the executor key of a page action row holding `\'navigation\'` — `actionType: \'navigation\'` on an ' + + '`action:button` or `action:icon`, `type: \'navigation\'` on an `action:group` / `action:menu` member — the ' + + 'console action runner\'s local alias of `\'url\'`', + replacement: '`\'url\'` — `actionType: \'url\'` on a node, `type: \'url\'` on a member — with the same `target` ' + + 'and `openIn`. The rename is lossless on a row: the runner sends both spellings to one navigator, and the ' + + 'alias\'s own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. The url executor\'s ' + + '`target` is then judged by the url scheme set (`ui-page-action-row-url-target-scheme-set-closed`).', + reason: + 'The rows judged a url `target` only when the executor key read `\'url\'`, while the runner dispatches ' + + '`\'navigation\'` to the same navigator — so a row spelled with the alias carried a target the click refuses ' + + 'past the parse. The url executor now has one spelling on the rows, and the alias is refused at the executor ' + + 'key with the rename. The key otherwise stays open: a handler a host registers under another name ' + + '(`navigate_create`, `navigate_edit`, …) is still dispatched by name. A page\'s `properties` is not parsed on ' + + 'the metadata save or load path, so a stored page still saves and loads; `os validate`\'s props gate reports ' + + 'the alias as a `component-props-invalid` finding. No conversion is registered: the authored census finds no ' + + 'page action row writing `\'navigation\'` in this repository or in objectui. The `element:button` inline ' + + 'action keeps its own declared fold of `type: \'navigation\'` onto `\'url\'` for its measured producers. ' + + 'Deployed metadata NOT MEASURED.', + acceptanceCriteria: + 'No `action:button` / `action:icon` `properties.actionType` and no `action:group` / `action:menu` member ' + + '`type` reads `\'navigation\'`; `os validate` reports no `component-props-invalid` finding at ' + + '`properties.actionType` or `properties.actions.N.type`, and each such row navigates to its `target` as ' + + 'before.', +}; diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-on-success-shape-closed.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-on-success-shape-closed.ts new file mode 100644 index 00000000000..b6ad205238d --- /dev/null +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-on-success-shape-closed.ts @@ -0,0 +1,38 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import type { SemanticMigration } from '../../types.js'; + +export const entry: SemanticMigration = { + id: 'ui-page-action-row-on-success-shape-closed', + surface: 'the `onSuccess` of a page action row — `properties.onSuccess` on an `action:button` or ' + + '`action:icon`, and `onSuccess` on each `action:group` / `action:menu` member of `properties.actions` — ' + + 'which took any value: a `navigate` the console\'s action runner does not open (`javascript:`, `data:`, ' + + '`tel:` and every other scheme, a protocol-relative `//host`, a bare relative template such as ' + + '`tasks/${result.id}`, the empty string), a misspelled key (`url`, `redirectUrl`, `to`), `openIn: ' + + '\'new-tab\'`, a block with no `navigate`, or a value that is not a navigation block at all (a callback ' + + 'object or array, a bare string)', + replacement: '`ActionSchema`\'s own post-success block, `{ navigate, openIn? }`: `navigate` is an `http://` or ' + + '`https://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens ' + + 'with `${` (judged after interpolation, at the hop); `openIn` is `\'self\'` (the default) or `\'newTab\'`. ' + + 'Write `redirectUrl` / `url` / `to` / `route` / `path` / `target` / `navigateTo` / `href` as `navigate` and ' + + '`\'new-tab\'` as `\'newTab\'`; drop an `onSuccess` that navigates nowhere. ⛔ NOT mechanically convertible ' + + 'for a refused `navigate`: a bare `tasks/${result.id}` may mean `/tasks/…` or `./tasks/…`, and a `tel:` or ' + + '`javascript:` hop has no admitted equivalent — the author chooses the address they meant.', + reason: + 'Every row forwards `onSuccess` to the console\'s action runner whole, and the runner reads exactly ' + + '`ActionSchema`\'s block: a non-array object with a non-empty string `navigate`, interpolated and classified ' + + 'with the url scheme set, opening a new tab only on `openIn: \'newTab\'`. Anything else gets no reading, and ' + + 'a refused `navigate` performs no navigation. The rows declared `z.unknown()`, so all of it parsed clean and ' + + 'did nothing after the action succeeded. The rows now hold the action\'s own block by reference — one shape, ' + + 'and the one url grammar (`navigation-target.ts`). A page\'s `properties` is not parsed on the metadata save ' + + 'or load path, so a stored page still saves and loads; `os validate`\'s props gate parses the bag against the ' + + 'rows and reports a refused value as a `component-props-invalid` finding. No conversion is registered: the ' + + 'authored census finds no row `onSuccess` outside the block in this repository or in objectui (its row tests ' + + 'write `{ navigate: \'/app/records/${result.id}\' }`, admitted). Deployed metadata NOT MEASURED.', + acceptanceCriteria: + 'Every `action:button` / `action:icon` `properties.onSuccess` and every `action:group` / `action:menu` ' + + 'member `onSuccess` is `{ navigate, openIn? }` with `navigate` in the admitted set and `openIn` `\'self\'` or ' + + '`\'newTab\'`; `os validate` reports no `component-props-invalid` finding at `properties.onSuccess` or ' + + '`properties.actions.N.onSuccess`. Every admitted block parses as it does on an action, `openIn` resolved to ' + + '`\'self\'` when omitted.', +}; diff --git a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts new file mode 100644 index 00000000000..6e53247cb48 --- /dev/null +++ b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts @@ -0,0 +1,203 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * The page action rows — `action:button`, `action:icon` and an `action:group` / + * `action:menu` member — on the two keys that still let a row carry a target the + * console's action runner refuses at the click (ruling A+ on + * objectstack-ai/objectui#12191: the scheme set is stated once in the contract + * and the runtime fulfils exactly that): + * + * - **`onSuccess`** is `ActionSchema`'s own post-success block, by reference — + * so `navigate` is judged by the one grammar in `navigation-target.ts`, and + * the block's closed shape (its rename prescriptions, its closed `openIn`) + * comes with it. No second shape: the block on a row is the SAME instance + * `ActionSchema.onSuccess` declares, and a row's verdict on any `onSuccess` + * value is the action's. + * - **The executor key** (`actionType` on a node, `type` on a member) refuses + * `'navigation'`, the runner's local alias of `'url'`, with the lossless + * rename onto `'url'` — whose `target` the url scheme set judges. Every other + * executor name, a host-registered handler included, stays open. + * + * Each row gets one refusal (its Zod issue path and its first sentence) and one + * accepted control (a full parse). A page's `properties` bag is outside the + * stack parse; `os validate` judges it through its props gate, which parses the + * bag against these same rows — `@objectstack/lint`'s + * `validate-component-props-row-on-success` test pins that door. + */ + +import { describe, it, expect } from 'vitest'; +import type { z } from 'zod'; + +import { ActionSchema } from './action.zod'; +import { ActionButtonPropsSchema, ActionIconPropsSchema, ComponentPropsMap } from './component.zod'; + +const NAVIGATE_SUBJECT = "An action's `onSuccess.navigate`"; +const JAVASCRIPT_REST = ' opens with `javascript:`, a scheme a url target never opens.'; + +/** The message's first sentence: everything up to the first `. ` outside a code span. */ +function firstSentence(message: string): string { + let inCode = false; + for (let i = 0; i < message.length; i += 1) { + if (message[i] === '`') inCode = !inCode; + if (!inCode && message[i] === '.' && (message[i + 1] === ' ' || i === message.length - 1)) { + return message.slice(0, i + 1); + } + } + return message; +} + +type Issue = { code: string; path: PropertyKey[]; message: string }; +const issuesOf = (r: { success: boolean; error?: { issues: unknown[] } }): Issue[] => + (r.success ? [] : (r.error?.issues ?? [])) as Issue[]; + +type Parser = { safeParse: (value: unknown) => { success: boolean; data?: unknown; error?: { issues: unknown[] } } }; +const GROUP = ComponentPropsMap['action:group'] as unknown as Parser; +const MENU = ComponentPropsMap['action:menu'] as unknown as Parser; + +/** + * One row shape: how to write a row with the given action keys, where its keys + * sit in an issue path, and how a refusal names its executor. + */ +interface Row { + readonly name: string; + readonly parse: (action: Record) => ReturnType; + /** The issue path prefix of the row's own keys. */ + readonly at: readonly PropertyKey[]; + readonly executorKey: 'actionType' | 'type'; + readonly navigationSubject: string; + /** The parsed row's action object. */ + readonly parsed: (data: unknown) => Record; +} + +const ROWS: readonly Row[] = [ + { + name: 'action:button', + parse: (action) => ActionButtonPropsSchema.safeParse({ label: 'Clone', ...action }), + at: [], + executorKey: 'actionType', + navigationSubject: "An `action:button`'s `actionType: 'navigation'`", + parsed: (data) => data as Record, + }, + { + name: 'action:icon', + parse: (action) => ActionIconPropsSchema.safeParse({ icon: 'copy', label: 'Clone', ...action }), + at: [], + executorKey: 'actionType', + navigationSubject: "An `action:icon`'s `actionType: 'navigation'`", + parsed: (data) => data as Record, + }, + { + name: 'action:group member', + parse: (action) => GROUP.safeParse({ actions: [{ label: 'Clone', ...action }] }), + at: ['actions', 0], + executorKey: 'type', + navigationSubject: "An `action:group` member's `type: 'navigation'`", + parsed: (data) => (data as { actions: Record[] }).actions[0], + }, + { + name: 'action:menu member', + parse: (action) => MENU.safeParse({ actions: [{ label: 'Clone', ...action }] }), + at: ['actions', 0], + executorKey: 'type', + navigationSubject: "An `action:menu` member's `type: 'navigation'`", + parsed: (data) => (data as { actions: Record[] }).actions[0], + }, +]; + +/** An `api` row with the given `onSuccess`, spelled with the row's executor key. */ +const apiRow = (row: Row, onSuccess: unknown) => + row.parse({ [row.executorKey]: 'api', target: '/api/v1/tasks/clone', onSuccess }); + +// =========================================================================== +// 1. onSuccess — ActionSchema's own block +// =========================================================================== + +describe("a page action row's `onSuccess` is `ActionSchema`'s own post-success block", () => { + it.each(ROWS)('REFUSAL: %s with `navigate: \'javascript:alert(1)\'` is refused at `onSuccess.navigate`', (row) => { + const issues = issuesOf(apiRow(row, { navigate: 'javascript:alert(1)' })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess', 'navigate']]]); + expect(firstSentence(issues[0].message)).toBe(`${NAVIGATE_SUBJECT}${JAVASCRIPT_REST}`); + }); + + it.each(ROWS)('ACCEPTED CONTROL: %s with an in-app route template parses, `openIn` resolved as on the action', (row) => { + const r = apiRow(row, { navigate: '/apps/crm/tasks/${result.id}' }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect(row.parsed(r.data).onSuccess).toEqual({ navigate: '/apps/crm/tasks/${result.id}', openIn: 'self' }); + }); + + /** + * The values a row's verdict is compared on: one per arm of the block's + * judgement — the scheme set, the deferred template, the closed `openIn`, + * the rename prescription, the missing `navigate`, and a value of the wrong + * kind (the retired callback shapes the runner gives no reading). + */ + const VERDICT_PROBES: ReadonlyArray = [ + ['an https URL, new tab', { navigate: 'https://example.com/receipt', openIn: 'newTab' }], + ['a deferred template', { navigate: '${result.redirectUrl}' }], + ['a bare relative template', { navigate: 'tasks/${result.id}' }], + ['a protocol-relative host', { navigate: '//evil.example.com' }], + ["`openIn: 'new-tab'` (the top-level spelling)", { navigate: '/x', openIn: 'new-tab' }], + ['`url` for `navigate`', { url: '/x' }], + ['no `navigate`', {}], + ['a callback object', { type: 'notify', name: 'ping' }], + ['a callback array', [{ type: 'notify' }]], + ['a bare string', '/x'], + ]; + + /** The action's verdict on one `onSuccess` value, paths relative to the block. */ + const actionVerdict = (onSuccess: unknown) => { + const r = ActionSchema.safeParse({ + name: 'clone_task', label: 'Clone', type: 'api', target: '/api/v1/tasks/clone', onSuccess, + }); + return r.success + ? { ok: true, onSuccess: (r.data as { onSuccess: unknown }).onSuccess } + : { ok: false, issues: issuesOf(r).map((i) => [i.code, i.path.slice(1), i.message]) }; + }; + + it.each(ROWS)('NO SECOND SHAPE: %s gives every probe the verdict `ActionSchema` gives it', (row) => { + for (const [label, onSuccess] of VERDICT_PROBES) { + const r = apiRow(row, onSuccess); + const rowVerdict = r.success + ? { ok: true, onSuccess: row.parsed(r.data).onSuccess } + : { ok: false, issues: issuesOf(r).map((i) => [i.code, i.path.slice(row.at.length + 1), i.message]) }; + expect(rowVerdict, label).toEqual(actionVerdict(onSuccess)); + } + }); + + it('NO SECOND SHAPE: every row holds the very block instance `ActionSchema.onSuccess` declares', () => { + const block = ActionSchema.in.shape.onSuccess.unwrap(); + type Shape = { shape: Record> }; + const memberOf = (props: unknown) => + ((props as Shape).shape.actions.unwrap() as unknown as z.ZodArray).element as unknown as Shape; + expect((ActionButtonPropsSchema as unknown as Shape).shape.onSuccess.unwrap()).toBe(block); + expect((ActionIconPropsSchema as unknown as Shape).shape.onSuccess.unwrap()).toBe(block); + expect(memberOf(GROUP).shape.onSuccess.unwrap()).toBe(block); + expect(memberOf(MENU).shape.onSuccess.unwrap()).toBe(block); + }); +}); + +// =========================================================================== +// 2. The executor key — one spelling for the url executor +// =========================================================================== + +describe("a page action row's executor key refuses `'navigation'`, the runner's alias of `'url'`", () => { + it.each(ROWS)("REFUSAL: %s with the alias is refused at its executor key, with the rename onto `'url'`", (row) => { + const issues = issuesOf(row.parse({ [row.executorKey]: 'navigation', target: '/apps/crm/tasks' })); + expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, row.executorKey]]]); + expect(firstSentence(issues[0].message)).toBe( + `${row.navigationSubject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling.`, + ); + expect(issues[0].message).toContain(`Write \`${row.executorKey}: 'url'\``); + }); + + it.each(ROWS)("ACCEPTED CONTROL: %s with `'url'` and the same target parses unchanged", (row) => { + const r = row.parse({ [row.executorKey]: 'url', target: '/apps/crm/tasks' }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + expect(row.parsed(r.data)[row.executorKey]).toBe('url'); + }); + + it.each(ROWS)('CONTROL: %s keeps its executor key open — a host-registered handler name parses', (row) => { + const r = row.parse({ [row.executorKey]: 'navigate_edit' }); + expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); + }); +}); diff --git a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts index c4c152136a8..36a153e3a3f 100644 --- a/packages/spec/src/ui/component-props-unknown-members.pin.test.ts +++ b/packages/spec/src/ui/component-props-unknown-members.pin.test.ts @@ -241,7 +241,9 @@ const on = (types: readonly string[], paths: readonly string[], reason: Reason): for (const type of types) for (const path of paths) LEDGER.set(`${type} ${path}`, reason); }; -const ACTION_RUNNER_PATHS = ['params', 'bodyExtra', 'bodyShape', 'operation', 'patch', 'toast', 'resultDialog', 'onSuccess']; +// `onSuccess` left this list when the rows took `ActionSchema`'s own +// post-success block (`component-action-row-on-success-executor.test.ts`). +const ACTION_RUNNER_PATHS = ['params', 'bodyExtra', 'bodyShape', 'operation', 'patch', 'toast', 'resultDialog']; const VIEW_DATA_TYPES = ['object-grid', 'object-map', 'object-gantt', 'object-tree']; // Composition slots. diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index eb783670960..efa8864a80e 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -57,7 +57,7 @@ import { AggregationFunction } from '../data/query.zod'; // `{ formats }` while the grid reads `.formats` and lifts nothing. Declared // outside the `ui` barrel so the two carriers share it without publishing it. import { ListViewExportOptionsSchema } from './list-view-export-options'; -import { InlineActionSchema, ActionLocationSchema } from './action.zod'; +import { ActionSchema, InlineActionSchema, ActionLocationSchema } from './action.zod'; import { ACTION_TARGET_ALIASES } from './action-target-aliases'; import { navigationTargetRefusal } from './navigation-target'; import { I18nLabelSchema, AriaPropsSchema } from './i18n.zod'; @@ -3971,8 +3971,11 @@ const ACTION_NODE_ALIASES = { * executor. ⛔ No second URL grammar here. * * On every other executor `target` names a script, flow, page, endpoint or - * form and is untouched. A row with no `target` is not judged here: nothing - * was written for the set to judge. Because `os validate`'s props gate parses + * form and is untouched; the url executor's alias spelling `'navigation'` is + * refused at the executor key ({@link refuseRowUrlExecutorAlias}), so no row + * reaches the url navigator with its `target` unjudged. A row with no + * `target` is not judged here: nothing was written for the set to judge. + * Because `os validate`'s props gate parses * `properties` against these same rows (`ComponentPropsMap`), it reports this * refusal at the same path with the same text — one rule, two doors. */ @@ -3984,6 +3987,75 @@ function refuseUnopenableRowUrlTarget(executorKey: 'actionType' | 'type', subjec }; } +/** + * The url executor's one spelling on a page action row. The console's action + * runner keeps `'navigation'` as a local alias of `'url'`: its built-in table + * sends the alias to `executeNavigation`, which reads `to || target || + * redirect` and hands off to `navigateTo`, the navigator `executeUrl` calls + * with `target || redirect` (`core/src/actions/ActionRunner.ts` at the pin). + * On a row the two are the same navigation: the alias's own keys (`to`, + * `redirect`, `external`, `newTab`, `replace`) are not row keys, so the strict + * row refuses them, and both read the row's `target` and `openIn`. But + * {@link refuseUnopenableRowUrlTarget} judges `target` only on `'url'`, so the + * alias spelling carried a target the click refuses past the parse. The + * executor gets one spelling: the alias is refused at the executor key, with + * the lossless rename onto `'url'`, whose `target` the url scheme set judges. + * + * The key otherwise stays open, as its `describe` says: the runner dispatches a + * handler a host registers under any other name before its built-ins, and the + * console registers several (`navigate_create`, `navigate_edit`, …), so every + * other value is the host's and is not judged here. The `element:button` + * inline action keeps its own declared fold of `type: 'navigation'` onto + * `'url'` (`normalizeInlineAction`, `action.zod.ts`), a bridge for its measured + * producers; no page action row writes the alias. + */ +function refuseRowUrlExecutorAlias(executorKey: 'actionType' | 'type', subject: string) { + return (row: { actionType?: string; type?: string }, ctx: z.RefinementCtx): void => { + if (row[executorKey] !== 'navigation') return; + ctx.addIssue({ + code: 'custom', + path: [executorKey], + message: + `${subject} is the console runner's local alias of \`'url'\`, and the url executor has one spelling. ` + + `Write \`${executorKey}: 'url'\`: on a page action row the alias runs the same navigator on the same ` + + '`target` and `openIn`, so nothing else changes, and a `\'url\'` row\'s `target` is judged by the url ' + + 'scheme set.', + }); + }; +} + +/** + * A page action row's `onSuccess`: `ActionSchema`'s own post-success block, by + * reference — the one `strictObject` `{ navigate, openIn }` that + * `ActionSchema.onSuccess` declares, read off the object half of the action's + * pipe (the way {@link buildObjectFormSectionFieldEntry} reads + * `FormFieldSchema`'s). ⛔ No second shape and no second URL grammar. + * + * Every row forwards `onSuccess` to the console's action runner whole + * (`action-button.tsx`, `action-icon.tsx`, `action-group.tsx` and + * `action-menu.tsx` at the pin), and the runner reads exactly this block: + * `handlePostExecution` → `readOnSuccessNavigation` (a non-array object with a + * non-empty string `navigate`, and nothing else gets a reading) → + * `navigateOnSuccess` (interpolates `navigate`, classifies the result with + * `classifyNavigationTarget` and performs no navigation for a refused one, + * opens a new tab only on `openIn: 'newTab'`). It reads no key the block does + * not declare. So the block brings its whole judgement along: + * `refuseUnopenableOnSuccessNavigate` judges `navigate` with + * `navigation-target.ts`'s grammar, an unknown key gets the block's rename + * prescriptions, and `openIn` is the closed `'self' | 'newTab'` enum with its + * materialised `'self'` default, which the runner relies on by reading only + * `=== 'newTab'`. Until this, the rows declared `z.unknown()`, and a + * `navigate` the runner refuses, a misspelled key or a retired callback shape + * parsed clean and did nothing after the action succeeded. + * + * What the block does not bring: `ActionSchema`'s rules ABOUT `onSuccess` — it + * is refused beside a `type` other than `api` / `script`, and beside + * `opensInNewTab: true` — are refinements of the action, not of the block. The + * rows' executor set is open and the runner runs the hop after every executor, + * a host handler included, so those rules do not carry over as written. + */ +const actionOnSuccessBlock = () => ActionSchema.in.shape.onSuccess.unwrap(); + /** * Read-but-not-authorable keys shared by `action:button` and `action:icon`, * each read by the renderer and each refused here with what to write instead. @@ -4077,7 +4149,7 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ icon: z.string().optional() .describe('Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon)'), actionType: z.string().optional() - .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type`: on a page component `type` is the component itself'), + .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner\'s alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself'), variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() .describe('Button variant — the Button primitive\'s vocabulary, plus `primary`, which renders as `default` (renderer default: `default`)'), size: z.enum([...BUTTON_PRIMITIVE_SIZES, 'md']).optional() @@ -4109,16 +4181,19 @@ export const ActionButtonPropsSchema = lazySchema(() => strictObject({ .describe('Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one'), toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), - onSuccess: z.unknown().optional().describe('Declared post-success navigation, forwarded to the runner'), + onSuccess: actionOnSuccessBlock().optional() + .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), -}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:button`'s `target` (`actionType: 'url'`)"))); +}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:button`'s `target` (`actionType: 'url'`)")) + .superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:button`'s `actionType: 'navigation'`"))); /** Author state (ADR-0122: the bare name is the author state). */ export type ActionButtonProps = z.input; /** * ADR-0122: the parsed state differs from the authored state on `visible` and `disabled` — the * bare-string arm of the condition union normalizes to the canonical - * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform). + * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform) — and on + * `onSuccess`, whose `openIn` the action's own block resolves to `'self'` when it is omitted. */ export type ActionButtonPropsParsed = z.infer; @@ -4176,7 +4251,7 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ icon: z.string().optional() .describe('Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`)'), actionType: z.string().optional() - .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type`'), + .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner\'s alias of `url`, is refused: write `url`. Write it here, not as `type`'), variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() .describe('Button variant — the Button primitive\'s vocabulary, plus `primary`, which renders as `default` (renderer default: `ghost`)'), visible: actionCondition().optional() @@ -4204,16 +4279,19 @@ export const ActionIconPropsSchema = lazySchema(() => strictObject({ .describe('Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one'), toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), - onSuccess: z.unknown().optional().describe('Declared post-success navigation, forwarded to the runner'), + onSuccess: actionOnSuccessBlock().optional() + .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), -}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:icon`'s `target` (`actionType: 'url'`)"))); +}).superRefine(refuseUnopenableRowUrlTarget('actionType', "An `action:icon`'s `target` (`actionType: 'url'`)")) + .superRefine(refuseRowUrlExecutorAlias('actionType', "An `action:icon`'s `actionType: 'navigation'`"))); /** Author state (ADR-0122: the bare name is the author state). */ export type ActionIconProps = z.input; /** * ADR-0122: the parsed state differs from the authored state on `visible` and `disabled` — the * bare-string arm of the condition union normalizes to the canonical - * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform). + * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform) — and on + * `onSuccess`, whose `openIn` the action's own block resolves to `'self'` when it is omitted. */ export type ActionIconPropsParsed = z.infer; @@ -4360,7 +4438,7 @@ function actionContainerMemberShape() { icon: z.string().optional() .describe('Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon)'), type: z.string().optional() - .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`)'), + .describe('Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner\'s alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`)'), variant: z.enum([...BUTTON_PRIMITIVE_VARIANTS, 'primary']).optional() .describe('Item variant — the Button primitive\'s vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group\'s `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly'), visible: actionCondition().optional() @@ -4390,7 +4468,8 @@ function actionContainerMemberShape() { .describe('Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one'), toast: z.unknown().optional().describe('Toast behaviour, forwarded to the runner'), resultDialog: z.unknown().optional().describe('One-shot result dialog for a value the response shows exactly once, forwarded to the runner'), - onSuccess: z.unknown().optional().describe('Declared post-success navigation, forwarded to the runner'), + onSuccess: actionOnSuccessBlock().optional() + .describe('Post-success navigation, forwarded to the runner: `ActionSchema`\'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab`'), objectName: z.string().optional() .describe('Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page\'s object. Omit to act on the page\'s object'), }; @@ -4478,7 +4557,8 @@ function buildActionGroupMember() { size: z.enum([...BUTTON_PRIMITIVE_SIZES, 'md']).optional() .describe('Inline button size — the Button primitive\'s vocabulary, plus `md` (drawn as `default`), falling back to the group\'s `size`. A dropdown item reads no size'), }).superRefine(actionContainerMemberParamsFitType('action:group')) - .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:group` member's `target` (`type: 'url'`)")); + .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:group` member's `target` (`type: 'url'`)")) + .superRefine(refuseRowUrlExecutorAlias('type', "An `action:group` member's `type: 'navigation'`")); } let actionGroupMemberOnce: ReturnType | undefined; /** The one {@link buildActionGroupMember} instance. */ @@ -4498,7 +4578,8 @@ function buildActionMenuMember() { + 'read a member\'s `size`.', }, }, actionContainerMemberShape()).superRefine(actionContainerMemberParamsFitType('action:menu')) - .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's `target` (`type: 'url'`)")); + .superRefine(refuseUnopenableRowUrlTarget('type', "An `action:menu` member's `target` (`type: 'url'`)")) + .superRefine(refuseRowUrlExecutorAlias('type', "An `action:menu` member's `type: 'navigation'`")); } let actionMenuMemberOnce: ReturnType | undefined; /** The one {@link buildActionMenuMember} instance. */ @@ -4585,7 +4666,9 @@ export type ActionGroupProps = z.input; /** * ADR-0122: the parsed state differs from the authored state on `visible` — the * bare-string arm of the condition union normalizes to the canonical - * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform). + * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform) — and on + * a member's `onSuccess`, whose `openIn` the action's own block resolves to `'self'` when it + * is omitted. */ export type ActionGroupPropsParsed = z.infer; @@ -4642,7 +4725,9 @@ export type ActionMenuProps = z.input; /** * ADR-0122: the parsed state differs from the authored state on `visible` — the * bare-string arm of the condition union normalizes to the canonical - * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform). + * `{ dialect, source }` envelope (`EvaluatedExpressionInputSchema`'s transform) — and on + * a member's `onSuccess`, whose `openIn` the action's own block resolves to `'self'` when it + * is omitted. */ export type ActionMenuPropsParsed = z.infer; From 87804c129556082660293fddd1c34077fd686b76 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 02:40:48 +0000 Subject: [PATCH 2/7] chore(spec): declare the four dropped-refinement sites the rows' onSuccess block adds, as build-schemas named them Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/spec/dropped-refinements.baseline.json | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/packages/spec/dropped-refinements.baseline.json b/packages/spec/dropped-refinements.baseline.json index 55687c29af4..45bb2987219 100644 --- a/packages/spec/dropped-refinements.baseline.json +++ b/packages/spec/dropped-refinements.baseline.json @@ -3,7 +3,7 @@ "measured": { "zod": "4.4.3", "publishedSchemasWithDroppedRefinements": 232, - "droppedRefinementSites": 752, + "droppedRefinementSites": 756, "refinementSitesThatDidProject": 369, "refinementSitesWithNoJsonFormToCompare": 0 }, @@ -1308,22 +1308,26 @@ }, "ui/ActionButtonProps": { "sites": [ - "" + "", + "onSuccess" ] }, "ui/ActionGroupProps": { "sites": [ - "actions.element" + "actions.element", + "actions.element.onSuccess" ] }, "ui/ActionIconProps": { "sites": [ - "" + "", + "onSuccess" ] }, "ui/ActionMenuProps": { "sites": [ - "actions.element" + "actions.element", + "actions.element.onSuccess" ] }, "ui/ActionParam": { From db448fa119ac9e7cfd69d4ce08b9bba067783f21 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:09:08 +0000 Subject: [PATCH 3/7] docs(references): regenerate component.mdx for the rows' onSuccess block and executor describe (gen:docs) Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 30 +++++++++++++++++------- 1 file changed, 22 insertions(+), 8 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 419714591b6..892b3747036 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -31,7 +31,7 @@ const result = ActionButtonPropsSchema.parse(data); | **name** | `string` | optional | Action name, forwarded to the action runner. Optional: an inline page button is not a registered object action, and without a name the runner dispatches on `actionType` alone | | **label** | `string` | optional | Button text. A literal string, placed as-is — localize through the translation bundle entry for this component id, not an inline locale map | | **icon** | `string` | optional | Lucide icon name drawn left of the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | -| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type`: on a page component `type` is the component itself | +| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type`: on a page component `type` is the component itself | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary, plus `primary`, which renders as `default` (renderer default: `default`) | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Button size — the Button primitive's vocabulary, plus `md`, which renders as `default` (renderer default: `default`) | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the button is not rendered when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | @@ -54,9 +54,16 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | +### Nested Shape: `ActionButtonProps.onSuccess` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | + --- @@ -82,7 +89,7 @@ const result = ActionButtonPropsSchema.parse(data); | **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | -| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | +| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | @@ -103,7 +110,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | | **size** | `Enum<'default' \| 'sm' \| 'lg' \| 'icon' \| 'md'>` | optional | Inline button size — the Button primitive's vocabulary, plus `md` (drawn as `default`), falling back to the group's `size`. A dropdown item reads no size | @@ -119,7 +126,7 @@ const result = ActionButtonPropsSchema.parse(data); | **name** | `string` | optional | Action name, forwarded to the action runner; also the `aria-label` when there is no `label`. Optional, as for `action:button` | | **label** | `string` | optional | Accessible label and tooltip text (and its first letter stands in when no icon resolves). A literal string — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name, resolved through the shared action-icon resolver (renderer registration default `play`) | -| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. Write it here, not as `type` | +| **actionType** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. Write it here, not as `type` | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Button variant — the Button primitive's vocabulary, plus `primary`, which renders as `default` (renderer default: `ghost`) | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the icon is not rendered when it is FALSE. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the icon is shown but cannot be pressed while it is TRUE. Omit for never-disabled | @@ -139,9 +146,16 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| 'record_related' \| 'record_section'>[]` | optional | Action locations, forwarded to the runner — the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | +### Nested Shape: `ActionIconProps.onSuccess` + +| Property | Type | Required | Description | +| :--- | :--- | :--- | :--- | +| **navigate** | `string` | ✅ | Route/URL template navigated to after the action succeeds. Interpolates $`{param.*}` (params-dialog values), $`{ctx.*}` (origin/apiBase/user/org/recordId/selection) and $`{result.*}` (the action's server response payload — NEW with this key, e.g. $`{result.id}`). An http:// or https:// URL, a mailto: address, or an in-app path starting with / or ./ (an SPA route hop); a template that opens with ${ is judged after interpolation. Every other scheme and a protocol-relative //host are refused. Renderers MUST encodeURIComponent values in query positions. | +| **openIn** | `Enum<'self' \| 'newTab'>` | optional (default: `"self"`) | Where to perform the post-success navigation: 'self' (default — in-place SPA navigation, immune to popup blocking) or 'newTab'. Closed enum — no general navigation DSL. | + --- @@ -165,7 +179,7 @@ const result = ActionButtonPropsSchema.parse(data); | **name** | `string` | optional | Action name, forwarded to the action runner, and the item's text when there is no `label`. Optional: a member is not a registered object action, and the runner dispatches a nameless one on its `type` | | **label** | `string` | optional | The item's text. A literal string, placed as-is — localize through the translation bundle entry for this component id | | **icon** | `string` | optional | Lucide icon name drawn before the label, resolved through the shared action-icon resolver (an unknown name draws no icon) | -| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | +| **type** | `string` | optional | Executor the action runner dispatches to — built in: `script`, `url`, `modal`, `flow`, `api`, `form`; a handler registered under another name is dispatched too. `navigation`, the runner's alias of `url`, is refused: write `url`. A member is an action entry, so its executor is `type` (on an `action:button` node it is `actionType`) | | **variant** | `Enum<'default' \| 'destructive' \| 'outline' \| 'secondary' \| 'ghost' \| 'link' \| 'primary'>` | optional | Item variant — the Button primitive's vocabulary, plus `primary` (drawn as `default`). An inline `action:group` button draws it (falling back to the group's `variant`); a dropdown or `action:menu` item draws `destructive` red and every other variant plainly | | **visible** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope, evaluated against the row the host binds; the item is not drawn when it is FALSE, and a predicate that fails to evaluate hides it. Omit for always-visible | | **disabled** | `boolean \| string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Disabled predicate — a boolean, a CEL string, or a `{ dialect, source }` envelope; the item is drawn but cannot be pressed while it is TRUE, and a predicate that fails to evaluate disables it. Omit for never-disabled | @@ -186,7 +200,7 @@ const result = ActionButtonPropsSchema.parse(data); | **locations** | `Enum<'list_toolbar' \| 'list_item' \| 'record_header' \| 'record_more' \| …>[]` | optional | Action locations, forwarded to the runner — an `action:group` with a `location` draws only the members that list it, and the console uses them to tell a record-scoped action from an object-level one | | **toast** | `any` | optional | Toast behaviour, forwarded to the runner | | **resultDialog** | `any` | optional | One-shot result dialog for a value the response shows exactly once, forwarded to the runner | -| **onSuccess** | `any` | optional | Declared post-success navigation, forwarded to the runner | +| **onSuccess** | `{ navigate: string; openIn?: Enum<'self' \| 'newTab'> }` | optional | Post-success navigation, forwarded to the runner: `ActionSchema`'s own `{ navigate, openIn }` block. `navigate` is an http:// or https:// URL, a mailto: address, an in-app path starting with / or ./, or a template that opens with ${, judged after interpolation — every other scheme and a protocol-relative //host are refused; `openIn` is `self` (default) or `newTab` | | **objectName** | `string` | optional | Object the action acts on, forwarded to the runner — the console dispatches to it instead of the page's object. Omit to act on the page's object | From 2ce79299d66f89d075e7bec220bd121672fa3c9e Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:27:10 +0000 Subject: [PATCH 4/7] docs(spec): page:tabs item visibleWhen TSDoc says the component-node evaluator mounts no data root, citing containers.tsx at the current pin Declared rider from the domain:spec seat (TSDoc only). Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- packages/spec/src/ui/component.zod.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index efa8864a80e..fd59b06cce9 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -992,8 +992,8 @@ export const PageTabsProps = strictObject({ * evaluator, and that evaluator differs on two points — both renderer * behaviour, NOT contract-guaranteed: * - * * **`data` is the record ROW here**, where the component-node evaluator - * binds it to the data-source ADAPTER. Same key, two meanings. + * * **`data` is the record ROW here**; the component-node evaluator + * mounts no `data` root at all (see `PageComponentSchema.visibleWhen`). * * **The row's bare fields are spread flat**, so `status` resolves as * well as `record.status`. The ambient scope is spread AFTER the row, * so an ambient root (`features`, `user`, …) wins over a record @@ -1003,8 +1003,8 @@ export const PageTabsProps = strictObject({ * `features` / `os.user` roots, which no ADR rules for a UI predicate * (ADR-0068's Non-goals: "only the user object is in scope here"). * - * Measured at the `.objectui-sha` pin `190fbd01d061`: - * `components/src/renderers/layout/containers.tsx:450-457`. + * Measured at the `.objectui-sha` pin `1fb1ced36243`: + * `components/src/renderers/layout/containers.tsx:642-649`. * * Canonical `*When` name per ADR-0089 — this key is new, so the deprecated * `visibility` / `visibleOn` aliases are NOT ACCEPTED on tab items: unlike From d9115182b14f2df365a1258629ba7c24cf0b4508 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:32:20 +0000 Subject: [PATCH 5/7] test(spec): name each row in the row onSuccess/executor pin titles Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- .../component-action-row-on-success-executor.test.ts | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts index 6e53247cb48..e2084ef0129 100644 --- a/packages/spec/src/ui/component-action-row-on-success-executor.test.ts +++ b/packages/spec/src/ui/component-action-row-on-success-executor.test.ts @@ -113,13 +113,13 @@ const apiRow = (row: Row, onSuccess: unknown) => // =========================================================================== describe("a page action row's `onSuccess` is `ActionSchema`'s own post-success block", () => { - it.each(ROWS)('REFUSAL: %s with `navigate: \'javascript:alert(1)\'` is refused at `onSuccess.navigate`', (row) => { + it.each(ROWS)('REFUSAL: $name with `navigate: \'javascript:alert(1)\'` is refused at `onSuccess.navigate`', (row) => { const issues = issuesOf(apiRow(row, { navigate: 'javascript:alert(1)' })); expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, 'onSuccess', 'navigate']]]); expect(firstSentence(issues[0].message)).toBe(`${NAVIGATE_SUBJECT}${JAVASCRIPT_REST}`); }); - it.each(ROWS)('ACCEPTED CONTROL: %s with an in-app route template parses, `openIn` resolved as on the action', (row) => { + it.each(ROWS)('ACCEPTED CONTROL: $name with an in-app route template parses, `openIn` resolved as on the action', (row) => { const r = apiRow(row, { navigate: '/apps/crm/tasks/${result.id}' }); expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); expect(row.parsed(r.data).onSuccess).toEqual({ navigate: '/apps/crm/tasks/${result.id}', openIn: 'self' }); @@ -154,7 +154,7 @@ describe("a page action row's `onSuccess` is `ActionSchema`'s own post-success b : { ok: false, issues: issuesOf(r).map((i) => [i.code, i.path.slice(1), i.message]) }; }; - it.each(ROWS)('NO SECOND SHAPE: %s gives every probe the verdict `ActionSchema` gives it', (row) => { + it.each(ROWS)('NO SECOND SHAPE: $name gives every probe the verdict `ActionSchema` gives it', (row) => { for (const [label, onSuccess] of VERDICT_PROBES) { const r = apiRow(row, onSuccess); const rowVerdict = r.success @@ -181,7 +181,7 @@ describe("a page action row's `onSuccess` is `ActionSchema`'s own post-success b // =========================================================================== describe("a page action row's executor key refuses `'navigation'`, the runner's alias of `'url'`", () => { - it.each(ROWS)("REFUSAL: %s with the alias is refused at its executor key, with the rename onto `'url'`", (row) => { + it.each(ROWS)("REFUSAL: $name with the alias is refused at its executor key, with the rename onto `'url'`", (row) => { const issues = issuesOf(row.parse({ [row.executorKey]: 'navigation', target: '/apps/crm/tasks' })); expect(issues.map((i) => [i.code, i.path])).toEqual([['custom', [...row.at, row.executorKey]]]); expect(firstSentence(issues[0].message)).toBe( @@ -190,13 +190,13 @@ describe("a page action row's executor key refuses `'navigation'`, the runner's expect(issues[0].message).toContain(`Write \`${row.executorKey}: 'url'\``); }); - it.each(ROWS)("ACCEPTED CONTROL: %s with `'url'` and the same target parses unchanged", (row) => { + it.each(ROWS)("ACCEPTED CONTROL: $name with `'url'` and the same target parses unchanged", (row) => { const r = row.parse({ [row.executorKey]: 'url', target: '/apps/crm/tasks' }); expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); expect(row.parsed(r.data)[row.executorKey]).toBe('url'); }); - it.each(ROWS)('CONTROL: %s keeps its executor key open — a host-registered handler name parses', (row) => { + it.each(ROWS)('CONTROL: $name keeps its executor key open — a host-registered handler name parses', (row) => { const r = row.parse({ [row.executorKey]: 'navigate_edit' }); expect(r.success, JSON.stringify(issuesOf(r))).toBe(true); }); From f8a6626f7f8ae22db294379a443644e0787ddb50 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 03:40:04 +0000 Subject: [PATCH 6/7] docs(spec): page:tabs item visibleWhen describe no longer implies an adapter-bound data root elsewhere; reference regenerated Declared rider addendum from the domain:spec seat. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- content/docs/references/ui/component.mdx | 2 +- packages/spec/src/ui/component.zod.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/content/docs/references/ui/component.mdx b/content/docs/references/ui/component.mdx index 892b3747036..5e2101fe914 100644 --- a/content/docs/references/ui/component.mdx +++ b/content/docs/references/ui/component.mdx @@ -1575,7 +1575,7 @@ View filter rule | :--- | :--- | :--- | :--- | | **label** | `string \| Record` | ✅ | Display label — the default-language string, or an inline locale map (`{ en, "zh-CN" }`) resolved at render time | | **icon** | `string` | optional | Lucide icon name rendered in the tab trigger, left of the label. Read on this component — the renderer draws it via `LazyIcon`; contrast the item `key` beside it, which no read point takes and which the alias table answers with `value`. | -| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface's own evaluator binds `data` to the record ROW (not the data-source adapter) and also spreads the row's bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key. | +| **visibleWhen** | `string \| { dialect: Enum<'cel' \| 'cron' \| 'template'>; source: string; ast?: any; meta?: object }` | optional | Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface's own evaluator binds `data` to the record ROW (the component-node evaluator mounts no `data` root) and also spreads the row's bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key. | | **value** | `string` | optional | Stable `?tab=` URL token for this tab (default: index-derived `tab-`, which is not durable across item-list changes) | | **count** | `integer` | optional | Badge count shown next to the tab label (default: derived from `record:related_list` descendants) | | **children** | `any[]` | ✅ | Child components | diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index fd59b06cce9..17ca35a798b 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -1015,7 +1015,7 @@ export const PageTabsProps = strictObject({ * being pointed AT `visibleWhen` is not the same as being accepted). */ visibleWhen: EvaluatedExpressionInputSchema.optional().describe( - 'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface\'s own evaluator binds `data` to the record ROW (not the data-source adapter) and also spreads the row\'s bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key.', + 'Visibility predicate (CEL) — the whole tab (header + panel) is omitted when FALSE; the renderer falls back to the first visible tab when the active one is hidden. Contract-bound roots: `record`, `current_user` (ADR-0068 aliases `user` / `ctx.user`), `page.`. ⚠️ NOT the same environment as page-component `visibleWhen`: this surface\'s own evaluator binds `data` to the record ROW (the component-node evaluator mounts no `data` root) and also spreads the row\'s bare fields — renderer behaviour, NOT contract-guaranteed. ADR-0089 canonical name — `visible`/`showWhen`/`visibility`/`visibleOn` are all rejected here (not folded in), each with a pointer at this key.', ), /** * Stable URL token for this tab — the value `?tab=` carries and the From de990e9bfd4464614b3bc9e7cf0e933b075de931 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 13 Oct 2026 05:32:04 +0000 Subject: [PATCH 7/7] feat(spec): the page action rows' 'navigation' executor is rewritten to 'url' by a retired-from-load-path D2 conversion; docblock qualified to the executor key page-action-row-navigation-executor-to-url renames the executor value on action:button / action:icon properties.actionType and on action:group / action:menu properties.actions[].type, through mapPageComponents, copy-on-write and idempotent. The D3 entry names it, the changeset names os migrate meta --write as the rewrite, and step 18's rationale gains its fragment. refuseUnopenableRowUrlTarget's docblock no longer claims the absent-key name leg. The page:tabs twin gains its TSDoc-face assertion. Claude-Session: https://claude.ai/code/session_019G5ZnRrTMiwcHG6iZM19zf Co-authored-by: Claude --- ...page-action-row-on-success-and-executor.md | 6 +- ...ion-row-navigation-executor-to-url.test.ts | 139 +++++++++++++ packages/spec/src/conversions/registry.ts | 191 ++++++++++++++++++ ...-action-row-navigation-executor-refused.ts | 14 +- .../spec/src/migrations/registry.ts.template | 16 ++ packages/spec/src/ui/component.zod.ts | 19 +- .../ui/expression-scope-data-root.pin.test.ts | 14 ++ 7 files changed, 385 insertions(+), 14 deletions(-) create mode 100644 packages/spec/src/conversions/page-action-row-navigation-executor-to-url.test.ts diff --git a/.changeset/23103-page-action-row-on-success-and-executor.md b/.changeset/23103-page-action-row-on-success-and-executor.md index b8338d6d2bb..e965ac35926 100644 --- a/.changeset/23103-page-action-row-on-success-and-executor.md +++ b/.changeset/23103-page-action-row-on-success-and-executor.md @@ -28,10 +28,10 @@ A page's `properties` bag is outside the stack parse. `os validate` judges it th | `onSuccess: { url: '/x' }` (or `redirectUrl`, `to`, `route`, `path`, `target`, `navigateTo`, `href`) | `onSuccess: { navigate: '/x' }` | | `onSuccess: { navigate: '/x', openIn: 'new-tab' }` | `openIn: 'newTab'` — the block's spelling, not the top-level url switch's | | `onSuccess: { type: 'notify', … }`, an array of callbacks, or a bare string | drop it: the runner reads only the navigation block, so these never ran | -| `actionType: 'navigation'` on an `action:button` / `action:icon` | `actionType: 'url'`, with the same `target` and `openIn` | -| `type: 'navigation'` on an `action:group` / `action:menu` member | `type: 'url'`, with the same `target` and `openIn` | +| `actionType: 'navigation'` on an `action:button` / `action:icon` | `actionType: 'url'`, with the same `target` and `openIn` — `os migrate meta --write` rewrites it, and a stored row is rewritten when it is read | +| `type: 'navigation'` on an `action:group` / `action:menu` member | `type: 'url'`, with the same `target` and `openIn` — `os migrate meta --write` rewrites it, and a stored row is rewritten when it is read | -**The one-line fix: give every page action row's `onSuccess` the action's block, `{ navigate, openIn? }`, with a `navigate` that is an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — and spell the url executor `'url'`.** The rename from `'navigation'` is lossless on a row: the runner sends both spellings to one navigator, and the alias's own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. `os migrate meta --from 17` lists the D3 entries `ui-page-action-row-on-success-shape-closed` and `ui-page-action-row-navigation-executor-refused`; the rewrite is by hand. +**The one-line fix: give every page action row's `onSuccess` the action's block, `{ navigate, openIn? }`, with a `navigate` that is an `http(s)://` URL, a `mailto:` address, an in-app path that starts with `/` or `./`, or a template that opens with `${` — and spell the url executor `'url'`.** The rename from `'navigation'` is lossless on a row: the runner sends both spellings to one navigator, and the alias's own keys (`to`, `redirect`, `external`, `newTab`, `replace`) are not row keys. The executor rename is mechanical: `os migrate meta --from 17 --write` performs it through the ADR-0087 D2 conversion `page-action-row-navigation-executor-to-url`, which is retired from the load path, so an author meets the refusal while stored rows and migrated sources are rewritten. It lists the D3 entries `ui-page-action-row-navigation-executor-refused` (which names that conversion) and `ui-page-action-row-on-success-shape-closed`. An `onSuccess` rewrite stays by hand, because which address a refused `navigate` meant is the author's call. **What an author now sees.** `os validate`'s props gate reports a refused `navigate` at `properties.onSuccess.navigate` (or `properties.actions.N.onSuccess.navigate` for a member) with the action's own refusal: ``An action's `onSuccess.navigate` opens with `javascript:`, a scheme a url target never opens.``, followed by the reason and the admitted set. The alias is reported at `properties.actionType` (or `properties.actions.N.type`): ``An `action:button`'s `actionType: 'navigation'` is the console runner's local alias of `'url'`, and the url executor has one spelling.``, followed by the rename. A row's parsed `onSuccess` now carries `openIn: 'self'` when it was omitted, as an action's does; the runner reads an omitted `openIn` the same way. diff --git a/packages/spec/src/conversions/page-action-row-navigation-executor-to-url.test.ts b/packages/spec/src/conversions/page-action-row-navigation-executor-to-url.test.ts new file mode 100644 index 00000000000..3655cef5d81 --- /dev/null +++ b/packages/spec/src/conversions/page-action-row-navigation-executor-to-url.test.ts @@ -0,0 +1,139 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +import { describe, expect, it } from 'vitest'; + +import { ActionButtonPropsSchema, ActionIconPropsSchema, ComponentPropsMap } from '../ui/component.zod.js'; +import { applyConversions } from './apply.js'; +import { ALL_CONVERSIONS } from './registry.js'; +import { applyConversionsToStoredItem } from './stored.js'; +import type { ConversionNotice } from './types.js'; + +/** + * `page-action-row-navigation-executor-to-url` — the D2 half of refusing the + * executor value `'navigation'` on the page action rows. + * + * The fixture pair in `conversions.test.ts` already proves before → after over + * the whole table. What this file pins: + * + * - the stored-row seam REWRITES the alias to `'url'` on all four row shapes + * (the entry is `retiredFromLoadPath`, so only data-at-rest seams and + * `os migrate meta` apply it), and the result parses against the row that + * now refuses the alias; + * - the authoring funnel does NOT replay it — an author meets the rows' + * refusal instead; + * - every other executor value, and another block's `'navigation'`, is left + * byte-identical with no notice; + * - idempotence, and copy-on-write identity for a page with nothing to do. + */ +const ID = 'page-action-row-navigation-executor-to-url'; + +type Parser = { safeParse: (value: unknown) => { success: boolean } }; +const GROUP = ComponentPropsMap['action:group'] as unknown as Parser; +const MENU = ComponentPropsMap['action:menu'] as unknown as Parser; + +/** A stored `page` row carrying one block in a region. */ +const pageWith = (component: Record) => ({ + name: 'ops_links', + regions: [{ name: 'main', components: [component] }], +}); + +/** The one block of a converted {@link pageWith} row. */ +const blockOf = (page: unknown) => + (page as { regions: { components: Record[] }[] }).regions[0]!.components[0]!; + +/** Replay the stored chain over `page`, collecting this entry's notices. */ +function replayStored(page: Record) { + const notices: ConversionNotice[] = []; + const out = applyConversionsToStoredItem('page', page, { onNotice: (n) => notices.push(n) }); + return { out, notices: notices.filter((n) => n.conversionId === ID) }; +} + +const AT = 'pages[0].regions[0].components[0].properties'; + +describe('page-action-row-navigation-executor-to-url (ADR-0087 D2)', () => { + it('is registered for protocol 18 and retired from the authoring load path', () => { + const entry = ALL_CONVERSIONS.find((c) => c.id === ID); + expect(entry, 'the conversion is registered').toBeDefined(); + expect(entry!.toMajor).toBe(18); + expect(entry!.retiredFromLoadPath).toBe(true); + }); + + it.each([ + ['action:button', ActionButtonPropsSchema, { label: 'Runbook' }], + ['action:icon', ActionIconPropsSchema, { icon: 'book' }], + ] as const)('the stored-row seam rewrites `actionType: \'navigation\'` to `\'url\'` on `%s`', (type, schema, rest) => { + const props = { ...rest, actionType: 'navigation', target: '/apps/ops/runbook', openIn: 'new-tab' }; + // Before: the row refuses the stored alias. + expect(schema.safeParse(props).success).toBe(false); + const { out, notices } = replayStored(pageWith({ type, properties: props })); + const block = blockOf(out); + expect(block.properties).toEqual({ ...rest, actionType: 'url', target: '/apps/ops/runbook', openIn: 'new-tab' }); + expect(schema.safeParse(block.properties).success).toBe(true); + expect(notices.map((n) => [n.from, n.to, n.path])).toEqual([['navigation', 'url', `${AT}.actionType`]]); + }); + + it.each([ + ['action:group', GROUP], + ['action:menu', MENU], + ] as const)('the stored-row seam rewrites a `%s` member\'s `type: \'navigation\'` to `\'url\'`, and only that member', (type, schema) => { + const props = { + actions: [ + { label: 'Run', type: 'api', target: '/api/v1/ops/run' }, + { label: 'Runs', type: 'navigation', target: '/apps/ops/runs' }, + ], + }; + expect(schema.safeParse(props).success).toBe(false); + const { out, notices } = replayStored(pageWith({ type, properties: props })); + const block = blockOf(out); + expect(block.properties).toEqual({ + actions: [ + { label: 'Run', type: 'api', target: '/api/v1/ops/run' }, + { label: 'Runs', type: 'url', target: '/apps/ops/runs' }, + ], + }); + expect(schema.safeParse(block.properties).success).toBe(true); + // The untouched member keeps its identity (copy-on-write). + expect((block.properties as { actions: unknown[] }).actions[0]).toBe(props.actions[0]); + expect(notices.map((n) => [n.from, n.to, n.path])).toEqual([['navigation', 'url', `${AT}.actions[1].type`]]); + }); + + it.each([ + ['the canonical `url`', { type: 'action:button', properties: { label: 'Home', actionType: 'url', target: '/apps/ops' } }], + ['a host-registered handler name', { type: 'action:button', properties: { label: 'Edit', actionType: 'navigate_edit' } }], + ['an `element:button` inline action (the parse\'s own fold)', { type: 'element:button', properties: { label: 'Pricing', action: { type: 'navigation', to: '/pricing' } } }], + ['a member that is not an object', { type: 'action:menu', properties: { actions: ['navigation'] } }], + ['`actions` that is not a list', { type: 'action:group', properties: { actions: { type: 'navigation' } } }], + ])('control: %s is left as stored, with no notice', (_label, component) => { + const page = pageWith(component); + const { out, notices } = replayStored(page); + expect(out).toEqual(page); + expect(notices).toEqual([]); + }); + + it('control: a canonical page comes back as the SAME reference, with no notice', () => { + const clean = { pages: [pageWith({ type: 'action:icon', properties: { icon: 'play', actionType: 'url', target: '/apps/ops' } })] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(clean, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(out, 'nothing to rename ⇒ copy-on-write returns the input').toBe(clean); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); + + it('is idempotent — the converted result replays to itself with no second notice', () => { + const once = applyConversions( + { pages: [pageWith({ type: 'action:button', properties: { label: 'Go', actionType: 'navigation', target: '/apps/ops' } })] }, + { includeRetired: true }, + ); + const notices: ConversionNotice[] = []; + const twice = applyConversions(once, { includeRetired: true, onNotice: (n) => notices.push(n) }); + expect(twice).toBe(once); + expect(notices).toEqual([]); + }); + + it('the authoring funnel does not replay it — an author meets the rows\' refusal instead', () => { + const authored = { pages: [pageWith({ type: 'action:button', properties: { label: 'Go', actionType: 'navigation', target: '/apps/ops' } })] }; + const notices: ConversionNotice[] = []; + const out = applyConversions(authored, { onNotice: (n) => notices.push(n) }); + expect(out).toBe(authored); + expect(notices.filter((n) => n.conversionId === ID)).toEqual([]); + }); +}); diff --git a/packages/spec/src/conversions/registry.ts b/packages/spec/src/conversions/registry.ts index 96d69e4b513..5795af42753 100644 --- a/packages/spec/src/conversions/registry.ts +++ b/packages/spec/src/conversions/registry.ts @@ -13146,6 +13146,196 @@ const listViewSortStringClauseToArray: MetadataConversion = { }, }; +/** + * The two containers whose members a page action row's executor `type` + * lives on — the members {@link pageActionRowNavigationExecutorToUrl} + * rewrites, beside the {@link ACTION_RUNNER_BLOCK_TYPES} nodes. + */ +const ACTION_CONTAINER_BLOCK_TYPES = new Set(['action:group', 'action:menu']); + +/** + * A page action row's executor value `'navigation'` → `'url'` (protocol 18). + * + * objectui's action runner keeps `'navigation'` as a local alias of the url + * executor: its built-in table sends the alias to `executeNavigation`, which + * reads `to || target || redirect` and hands off to `navigateTo`, the + * navigator `executeUrl` calls with `target || redirect`. On a page action row + * the two spellings are the same navigation — the alias's own keys (`to`, + * `redirect`, `external`, `newTab`, `replace`) are not row keys, and both read + * the row's `target` and `openIn`. The rows judged a url `target` only when + * the executor read `'url'`, so the alias spelling carried a target the click + * refuses past the parse. The rows now refuse the alias at the executor key + * (`refuseRowUrlExecutorAlias`, `ui/component.zod.ts`) with the rename onto + * `'url'`, and this entry carries the sources and stored rows that wrote it. + * + * **The rewrite is lossless, so the chain owes it** (ADR-0087's launch-window + * section: a lossless break lands as a `retiredFromLoadPath` conversion beside + * its D3 entry, `ui-page-action-row-navigation-executor-refused`). A page's + * `properties` is not parsed on the metadata load path, so a stored row + * written with the alias keeps working only while the runner keeps it; the + * stored-row seam and `os migrate meta --write` carry it across. + * + * Two surfaces, both value renames and nothing else: `properties.actionType` + * on an `action:button` / `action:icon` node, and `type` on each + * `action:group` / `action:menu` member of `properties.actions`. Every other + * executor value is left alone — the key stays open, and a host-registered + * handler name (`navigate_edit`, …) is not this entry's. A member that is not + * an object, or a list that is not an array, is left as stored. The reach is + * every position `mapPageComponents` visits — regions, slots, nested + * containers — which is the props gate's own reach. Idempotent by + * construction: a rewritten value reads `'url'`, which this entry never + * touches. + * + * **Retired from the load path from day one**, as + * {@link actionBlockEndpointToTarget} is: the authoring door keeps teaching + * with the rows' refusal, and this entry serves the two paths that rewrite + * EXISTING data, stored-row rehydration and `os migrate meta`. + */ +const pageActionRowNavigationExecutorToUrl: MetadataConversion = { + id: 'page-action-row-navigation-executor-to-url', + toMajor: 18, + retiredFromLoadPath: true, + retiredAfter: '17.7.0', + surface: 'page.component.action:button.actionType / page.component.action:icon.actionType / ' + + 'page.component.action:group.actions[].type / page.component.action:menu.actions[].type', + summary: + "page action row executor 'navigation' → 'url' — objectui's runner-local alias of the url executor; " + + 'on a row both spellings run one navigator on the same `target`, so the rename is lossless and the ' + + 'url scheme set then judges `target`', + apply(stack, emit) { + return mapPageComponents(stack, (component, path) => { + const type = component.type; + if (typeof type !== 'string') return component; + const properties = component.properties; + if (!isDict(properties)) return component; + if (ACTION_RUNNER_BLOCK_TYPES.has(type)) { + if (properties.actionType !== 'navigation') return component; + emit({ from: 'navigation', to: 'url', path: `${path}.properties.actionType` }); + return { ...component, properties: { ...properties, actionType: 'url' } }; + } + if (!ACTION_CONTAINER_BLOCK_TYPES.has(type)) return component; + const actions = properties.actions; + if (!Array.isArray(actions)) return component; + let changed = false; + const nextActions = actions.map((member, i) => { + if (!isDict(member) || member.type !== 'navigation') return member; + changed = true; + emit({ from: 'navigation', to: 'url', path: `${path}.properties.actions[${i}].type` }); + return { ...member, type: 'url' }; + }); + return changed ? { ...component, properties: { ...properties, actions: nextActions } } : component; + }); + }, + fixture: { + before: { + pages: [ + { + name: 'ops_links', + regions: [ + { + name: 'main', + components: [ + // The measured shape: a url button spelled with the runner's alias. + { + type: 'action:button', + properties: { label: 'Runbook', actionType: 'navigation', target: '/apps/ops/runbook', openIn: 'new-tab' }, + }, + { type: 'action:icon', properties: { icon: 'external-link', actionType: 'navigation', target: 'https://status.example.com' } }, + // Canonical already: untouched. + { type: 'action:button', properties: { label: 'Home', actionType: 'url', target: '/apps/ops' } }, + // A host-registered handler name: the key stays open, untouched. + { type: 'action:button', properties: { label: 'Edit', actionType: 'navigate_edit' } }, + // An `element:button` inline action keeps the parse's own fold — not this entry's. + { type: 'element:button', properties: { label: 'Pricing', action: { type: 'navigation', to: '/pricing' } } }, + // A container member: only the alias member is renamed. + { + type: 'action:group', + properties: { + actions: [ + { label: 'Runs', type: 'navigation', target: '/apps/ops/runs' }, + { label: 'Run', type: 'api', target: '/api/v1/ops/run' }, + ], + }, + }, + // Nested one container down. + { + type: 'page:card', + properties: { + title: 'Links', + children: [ + { type: 'action:menu', properties: { actions: [{ label: 'Wiki', type: 'navigation', target: 'https://wiki.example.com' }] } }, + ], + }, + }, + ], + }, + ], + }, + // A slotted record page's named slot. + { + name: 'ops_link_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'action:icon', properties: { icon: 'book', actionType: 'navigation', target: './docs' } }, + }, + }, + ], + }, + after: { + pages: [ + { + name: 'ops_links', + regions: [ + { + name: 'main', + components: [ + { + type: 'action:button', + properties: { label: 'Runbook', actionType: 'url', target: '/apps/ops/runbook', openIn: 'new-tab' }, + }, + { type: 'action:icon', properties: { icon: 'external-link', actionType: 'url', target: 'https://status.example.com' } }, + { type: 'action:button', properties: { label: 'Home', actionType: 'url', target: '/apps/ops' } }, + { type: 'action:button', properties: { label: 'Edit', actionType: 'navigate_edit' } }, + { type: 'element:button', properties: { label: 'Pricing', action: { type: 'navigation', to: '/pricing' } } }, + { + type: 'action:group', + properties: { + actions: [ + { label: 'Runs', type: 'url', target: '/apps/ops/runs' }, + { label: 'Run', type: 'api', target: '/api/v1/ops/run' }, + ], + }, + }, + { + type: 'page:card', + properties: { + title: 'Links', + children: [ + { type: 'action:menu', properties: { actions: [{ label: 'Wiki', type: 'url', target: 'https://wiki.example.com' }] } }, + ], + }, + }, + ], + }, + ], + }, + { + name: 'ops_link_detail', + kind: 'slotted', + regions: [], + slots: { + header: { type: 'action:icon', properties: { icon: 'book', actionType: 'url', target: './docs' } }, + }, + }, + ], + }, + // The button, the icon, the group member, the nested menu member and the + // slotted header icon. + expectedNotices: 5, + }, +}; + /** * `page.assignedProfiles` removed (protocol 18, ADR-0090 D2 / ADR-0049). * @@ -15880,6 +16070,7 @@ const MAJOR_18_CONVERSIONS: readonly OrderedConversion[] = [ { conversion: objectKanbanQuickAddRemoved, order: 15 }, { conversion: objectMasterDetailFormDetailSortFieldRemoved, order: 60 }, { conversion: objectTenancyOrganizationFieldRemoved, order: 35 }, + { conversion: pageActionRowNavigationExecutorToUrl, order: 63 }, { conversion: pageAssignedProfilesRemoved, order: 31 }, { conversion: pageComponentFilterRecordToRuleArray, order: 36 }, { conversion: pageComponentResponsiveRemoved, order: 13 }, diff --git a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-navigation-executor-refused.ts b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-navigation-executor-refused.ts index ae12a469931..90dc5a7c085 100644 --- a/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-navigation-executor-refused.ts +++ b/packages/spec/src/migrations/entries/semantic/18.ui-page-action-row-navigation-executor-refused.ts @@ -17,14 +17,18 @@ export const entry: SemanticMigration = { + 'past the parse. The url executor now has one spelling on the rows, and the alias is refused at the executor ' + 'key with the rename. The key otherwise stays open: a handler a host registers under another name ' + '(`navigate_create`, `navigate_edit`, …) is still dispatched by name. A page\'s `properties` is not parsed on ' - + 'the metadata save or load path, so a stored page still saves and loads; `os validate`\'s props gate reports ' - + 'the alias as a `component-props-invalid` finding. No conversion is registered: the authored census finds no ' - + 'page action row writing `\'navigation\'` in this repository or in objectui. The `element:button` inline ' - + 'action keeps its own declared fold of `type: \'navigation\'` onto `\'url\'` for its measured producers. ' - + 'Deployed metadata NOT MEASURED.', + + 'the metadata save or load path, so a stored row written with the alias keeps working only while the runner ' + + 'keeps it; `os validate`\'s props gate reports the alias as a `component-props-invalid` finding. The rename ' + + 'is lossless, so the D2 conversion `page-action-row-navigation-executor-to-url` performs it — retired from ' + + 'the load path, so an author meets the refusal while stored rows and `os migrate meta --write` are rewritten. ' + + 'What is left to judge is only whether the address the row opens is the one meant: after the rename its ' + + '`target` is judged by the url scheme set, and a refused target has no lossless rewrite ' + + '(`ui-page-action-row-url-target-scheme-set-closed`). The `element:button` inline action keeps its own ' + + 'declared fold of `type: \'navigation\'` onto `\'url\'`, which this entry does not touch.', acceptanceCriteria: 'No `action:button` / `action:icon` `properties.actionType` and no `action:group` / `action:menu` member ' + '`type` reads `\'navigation\'`; `os validate` reports no `component-props-invalid` finding at ' + '`properties.actionType` or `properties.actions.N.type`, and each such row navigates to its `target` as ' + 'before.', + conversionIds: ['page-action-row-navigation-executor-to-url'], }; diff --git a/packages/spec/src/migrations/registry.ts.template b/packages/spec/src/migrations/registry.ts.template index 25737669141..03042cfaa2d 100644 --- a/packages/spec/src/migrations/registry.ts.template +++ b/packages/spec/src/migrations/registry.ts.template @@ -3064,6 +3064,22 @@ const STEP18_RATIONALE: readonly RationaleFragment[] = [ + 'decides yet. Read by the component-props gate (advisory); a stored page still saves and loads, so no ' + 'conversion is registered. Its D3 record is the semantic entry `ui-object-timeline-mapping-typed`.', }, + { + id: 'ui-page-action-row-navigation-executor-refused', + order: 95, + text: + 'It also closes the fourth door of the url-target family on the page action rows (`action:button`, ' + + '`action:icon`, and each `action:group` / `action:menu` member). A row\'s `onSuccess` was any value; it is ' + + 'now `ActionSchema`\'s own post-success block, by reference, so its `navigate` is judged by the one url ' + + 'grammar and the block\'s renames and closed `openIn` come with it (the semantic entry ' + + '`ui-page-action-row-on-success-shape-closed`; a refused address has no lossless rewrite). The row\'s ' + + 'executor key stays open to host-registered handler names, but refuses `\'navigation\'`, the console ' + + 'runner\'s local alias of `\'url\'`, whose `target` the rows never judged. On a row the two spellings run ' + + 'one navigator on the same `target`, so the D2 conversion `page-action-row-navigation-executor-to-url` ' + + 'renames the value, retired from the load path so authors are refused at the door while stored rows and ' + + '`os migrate meta` replay it. Its D3 record is the semantic entry ' + + '`ui-page-action-row-navigation-executor-refused`.', + }, { id: 'ui-record-line-items-props-closed', order: 57, diff --git a/packages/spec/src/ui/component.zod.ts b/packages/spec/src/ui/component.zod.ts index 17ca35a798b..391f07db734 100644 --- a/packages/spec/src/ui/component.zod.ts +++ b/packages/spec/src/ui/component.zod.ts @@ -3973,8 +3973,12 @@ const ACTION_NODE_ALIASES = { * On every other executor `target` names a script, flow, page, endpoint or * form and is untouched; the url executor's alias spelling `'navigation'` is * refused at the executor key ({@link refuseRowUrlExecutorAlias}), so no row - * reaches the url navigator with its `target` unjudged. A row with no - * `target` is not judged here: nothing was written for the set to judge. + * whose EXECUTOR KEY names the url navigator reaches it with its `target` + * unjudged. A row with no executor key at all is outside both rules: the + * runner then dispatches on the row's `name`, so `name: 'url'` reaches the + * same navigator with `target` judged only at the click — that leg is #23168's + * question, not settled here. A row with no `target` is not judged here: + * nothing was written for the set to judge. * Because `os validate`'s props gate parses * `properties` against these same rows (`ComponentPropsMap`), it reports this * refusal at the same path with the same text — one rule, two doors. @@ -4004,10 +4008,13 @@ function refuseUnopenableRowUrlTarget(executorKey: 'actionType' | 'type', subjec * The key otherwise stays open, as its `describe` says: the runner dispatches a * handler a host registers under any other name before its built-ins, and the * console registers several (`navigate_create`, `navigate_edit`, …), so every - * other value is the host's and is not judged here. The `element:button` - * inline action keeps its own declared fold of `type: 'navigation'` onto - * `'url'` (`normalizeInlineAction`, `action.zod.ts`), a bridge for its measured - * producers; no page action row writes the alias. + * other value is the host's and is not judged here. The rename is lossless, + * so the ADR-0087 D2 conversion `page-action-row-navigation-executor-to-url` + * (`conversions/registry.ts`, retired from the load path) rewrites a stored row + * or a source that wrote the alias; the authoring door teaches with this + * refusal. The `element:button` inline action keeps its own declared fold of + * `type: 'navigation'` onto `'url'` (`normalizeInlineAction`, `action.zod.ts`), + * a bridge for its measured producers. */ function refuseRowUrlExecutorAlias(executorKey: 'actionType' | 'type', subject: string) { return (row: { actionType?: string; type?: string }, ctx: z.RefinementCtx): void => { diff --git a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts index 11da96ae2cc..b2966ff7d11 100644 --- a/packages/spec/src/ui/expression-scope-data-root.pin.test.ts +++ b/packages/spec/src/ui/expression-scope-data-root.pin.test.ts @@ -46,6 +46,7 @@ import { ElementApiDataSourceSchema, PageComponentSchema } from './page.zod'; const HERE = path.dirname(url.fileURLToPath(import.meta.url)); const pageSource = fs.readFileSync(path.resolve(HERE, 'page.zod.ts'), 'utf8'); +const componentSource = fs.readFileSync(path.resolve(HERE, 'component.zod.ts'), 'utf8'); /** * The published description of `PageComponentSchema.visibleWhen`. @@ -157,6 +158,19 @@ describe('a page component `visibleWhen` names no `data` root — the shipping r }); }); + describe('the `page:tabs` item twin — the TSDoc above its `visibleWhen` in `component.zod.ts`', () => { + it('says the component-node evaluator mounts no `data` root, in neither shape the retired claim was written in', () => { + const start = componentSource.indexOf('Conditional tab (CEL, #2606)'); + const end = componentSource.indexOf('visibleWhen: EvaluatedExpressionInputSchema.optional()', start); + expect(start, 'docblock anchor not found — the pin has drifted off its site').toBeGreaterThan(-1); + expect(end, 'property anchor not found after the docblock').toBeGreaterThan(start); + const doc = componentSource.slice(start, end).replace(/\s*\n\s*\*\s*/g, ' '); + expect(doc).toContain('the component-node evaluator mounts no `data` root'); + expect(doc).not.toMatch(/binds it to the data-source ADAPTER/i); + for (const shape of RETIRED_CLAIM_SHAPES) expect(doc).not.toMatch(shape); + }); + }); + describe('the two texts in this file agree — the PLANNED API binding is the only `data` carrier', () => { it('`visibleWhen` names the PLANNED API binding as where `data` is declared', () => { const s = sentences(visibleWhenDescription()).find((x) => x.includes('PLANNED'));