Skip to content
13 changes: 9 additions & 4 deletions .changeset/6854-layout-renderer-retired-onclick.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,9 @@ makes `AppActionSchema.onClick`'s retirement message true again (objectui#6854,
maintainer ruling of 2026-09-05, option B2).

`AppAction.items` is `AppMenuItem[]`, and the zod mirror parses it with the legacy
eight-member `MenuItemSchema` — neither declares `onClick` or `shortcut`.
`MenuItemSchema`, which declared neither `onClick` nor `shortcut` when this change was
made. (`shortcut` has since become a declared refusal there — objectui#7719 — while
`onClick` remains undeclared on that mirror and is still dropped in silence.)
`LayoutRenderer` reached both through `as any`, past the type it was handed, and
that left three mutually exclusive signals about the same key: the TypeScript face
said `?: never`, the validator's refusal said "no renderer reads this key, so
Expand All @@ -25,13 +27,16 @@ its sentence true rather than restating it.
- `@object-ui/runner`: `LayoutRenderer` no longer reads `onClick` or `shortcut` on a
`type: 'user'` action's `items`. The `onClick` branch was an empty body and could
never run a JSON value; the `shortcut` read rendered a `DropdownMenuShortcut` from
a key the mirror strips in silence, so no validated document could reach it. A
a key the mirror stripped in silence at the time, so no validated document could
reach it. (objectui#7719 has since replaced that silent strip with a named refusal;
either way the read was unreachable, which is what made deleting it a cleanup.) A
census of every JSON and TypeScript app document in this repository found zero
authors of either key (positive controls recorded on the issue).
- `@object-ui/types`: the rationale comments on `AppAction.onClick` and
`AppActionSchema.onClick` said "nothing reads `AppComponentSchema.actions[]`".
That was false — the runner renders both the `'button'` and the `'user'` arm.
Corrected to what was measured: `actions[]` is read, `onClick` is not.

Whether `shortcut` should become authorable on `AppAction.items` is a separate
contract question and is filed on its own.
Whether `shortcut` should become authorable on `AppAction.items` was a separate
contract question, filed as objectui#7719 and since answered: it does not become
authorable, and the mirror refuses it by name instead of stripping it.
44 changes: 44 additions & 0 deletions .changeset/7719-app-menu-item-shortcut-named-refusal.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
---
'@object-ui/types': minor
---

`AppAction.items` 上的 `shortcut` 由「静默剥掉」改为「具名拒收」

⚠️ **这是一次已发布 mirror 的收窄**:本次改动**之前**,一份在 app action 的菜单项上写了 `shortcut`
的文档 `safeParse` 是**绿**的(键被 `MenuItemSchema` 无声丢弃,作者拿不到任何提示);**之后**在该键上**转红**,
issue 指向 `NavigationItem`。TS 面同步收窄:`AppMenuItem.shortcut?: never`,在编写处就被 `tsc` 拒掉。

零迁移面:本仓**零作者**写过这个键,且 `@object-ui/runner` 不发布库入口,没有绕过校验器塞进来的
路径。⛔ 这里刻意不冻结任何总数 —— objectui#6854 当初普查的那个 JSON 总体此后已经变动,冻进散文
的数字没有东西会去重新求值。判据写成可复跑的规则:**结构化**地 `JSON.parse` 每一个 tracked
`*.json`,无限深度遍历,报出「经由字面名为 `items` 的键抵达的数组、其元素对象自带 `shortcut`」者;
在本次改动的头上跑出 0 命中,注入 fixture 的正控制会开火(含一处嵌套命中),且不会把 action 层的 `shortcut`
兄弟键误计。总体数随树变化,规则不变。

裁决:director seat decision batch #70(objectui#7719,2026-09-07,维护者「同意」)。
⛔ 选项 B(给已弃用的 `AppMenuItem` 长一个真正的 `shortcut` 成员并渲染它)与选项 C(把
`AppAction.items` 改成 overlay 的 `MenuItem`)**均被拒绝**。唯一的改动是**诊断**:

- TS 面 —— `AppMenuItem.shortcut?: never`;
- zod 面 —— `MenuItemSchema.shortcut` 走 `retirementTombstone()`,一条 guidance 同时喂 parse
消息与 `.describe()`(即已发布的 JSON-Schema 描述)。

⛔ `LayoutRenderer` 未恢复任何读点,objectui#6854 的那枚 pin 断言原样保留;本次只修了它与
`LayoutRenderer.tsx` 里已经过期的散文(两处都在把一个已裁决的问题描述成悬而未决)。
`@object-ui/runner` 的改动**仅为注释**,无任何已发布行为变化。

⚠️ 受影响的**不是** `AppAction.shortcut`:那是 header 按钮自己的快捷键,一直声明着、本次不动,
并由控制断言钉住——只有它下面一层的 `items[]` 被收窄。

键盘快捷键若确实需要,它是 `NavigationItem` 那条线的能力,⛔ 不在这条已弃用的 legacy 面上补。

⚠️ 协议侧的现状,实测于**已解析安装**的 `@objectstack/spec@17.4.0`(⛔ 不是读源码推断):
`action.shortcut` 在 17.0.0 的审计收尾里被移除的是**可编写性**,它的 tombstone 是**保留着的** ——
`ActionSchema.safeParse({ name, type, label, shortcut })` 返回 `success: false`,issue 落在
`shortcut` 上、`code` 为 `invalid_type`,消息开头即
「`action.shortcut` was removed in @objectstack/spec 17.0.0 (audit close-out)」。
正控制:换一个协议从未声明过的兄弟键,同样被拒,但**没有**落在 `shortcut` 上的 issue ——
⇒ 上面那条读数是关于这个键的,不是 strict 对象的通用效果。
⚠️ 两边的**终局形状**一致 —— 键不可编写、拒收具名;但**路径不同**,不要把它们说成一回事:
协议那边是先声明、再移除可编写性、留下 tombstone;本仓这个键在 `AppMenuItem` 上**从未声明过**,
它走的是「静默剥掉」→「具名拒收」,⛔ 没有任何可编写性被移除。
14 changes: 10 additions & 4 deletions packages/runner/src/LayoutRenderer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -302,15 +302,21 @@ export const LayoutRenderer = ({ app, children, currentPath, onNavigate }: Layou
* (objectui#6854, maintainer ruling of 2026-09-05, option B2).
*
* `items` is `AppMenuItem[]` (`@object-ui/types` `app.ts`), and the zod
* mirror parses it with the legacy eight-member `MenuItemSchema`.
* Neither declares `onClick` or `shortcut`; this map used to reach both
* mirror parses it with the legacy `MenuItemSchema`. Neither makes
* `onClick` or `shortcut` AUTHORABLE; this map used to reach both
* through `as any`, i.e. past the type it was handed. The `onClick` read
* is also what made the retirement refusal's own sentence — "no renderer
* reads this key, so nothing could ever run it" — false. `type` and
* `label` ARE declared on `AppMenuItem` and stay.
*
* Whether `shortcut` should become authorable on `AppAction.items` is a
* separate contract question; do not re-add either read to answer it.
* `shortcut` is SETTLED, and the answer left this map alone
* (objectui#7719, director seat decision batch #70 of 2026-09-07): it
* does not become authorable on `AppAction.items`. What changed is the
* DIAGNOSTIC on the types side — `shortcut?: never` on `AppMenuItem`
* and a named refusal on `MenuItemSchema`, so an authored value is
* refused instead of stripped in silence. A keyboard shortcut on a
* navigation entry is a capability of the `NavigationItem` line.
* ⛔ No read is re-added here; that is the ruling, not an open question.
*/}
{userAction.items?.map((item, idx) => {
if (item.type === 'separator') {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,9 +12,10 @@
*
* `AppAction.items` is `AppMenuItem[]` — `type` / `label` / `icon` / `path` /
* `href` / `children` / `badge` / `hidden` — and the zod mirror parses it with
* the legacy eight-member `MenuItemSchema`, which drops anything else in
* silence. This map used to reach two keys that are on neither list through
* `as any`: `onClick` and `shortcut`.
* the legacy `MenuItemSchema`, which drops anything else in silence, `shortcut`
* excepted since objectui#7719 (see the note below). This map used to reach two
* keys that are on neither authorable list through `as any`: `onClick` and
* `shortcut`.
*
* Deleting them is what makes `AppActionSchema.onClick`'s refusal message true
* again. It tells an author "no renderer reads this key, so nothing could ever
Expand All @@ -27,9 +28,18 @@
* none of in this repo — and require the renderer to ignore both. Re-adding
* either read turns one of them red.
*
* ⛔ NOT a ruling that `shortcut` must stay unrendered for ever: whether it
* should become AUTHORABLE on `AppAction.items` is its own contract card. This
* pins the contract as it stands, not the answer to that question.
* ⭐ THE RULING THIS NOTE USED TO BE WAITING FOR HAS LANDED. This paragraph read
* "⛔ NOT a ruling that `shortcut` must stay unrendered for ever: whether it
* should become AUTHORABLE on `AppAction.items` is its own contract card" —
* that card is objectui#7719, and director seat decision batch #70 of
* 2026-09-07 answered it: `shortcut` does NOT become authorable here, and ⛔ no
* read is re-added in this renderer. What that card changed is the DIAGNOSTIC on
* the types side — `shortcut?: never` on `AppMenuItem` and a named refusal on
* `MenuItemSchema`, so an authored value is refused by name instead of stripped
* in silence, pinned in
* `packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts`.
* ⇒ The assertions below are unchanged BY THAT RULING, which says the #6854 pin
* stays as is. They now pin a settled contract rather than an interim state.
*
* The `packages/types` half of the same claim — that the refusal message still
* makes it — is pinned in
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -80,22 +80,42 @@ describe('AppActionSchema.onClick — the retirement message states a measured f
});

describe('why the cast could never have been fed by an author (objectui#6854 Zone 2, the premise)', () => {
// `AppAction.items` is parsed by the LEGACY eight-member `MenuItemSchema`, a
// plain `z.object` — so `onClick` and `shortcut` are not refused there, they
// are STRIPPED in silence. An author therefore has no declared route to send
// either key, which is what made deleting the two reads a cleanup rather than
// a behaviour removal. Whether `shortcut` SHOULD become authorable here is a
// separate contract question and deliberately not answered by this file.
const authored = { label: 'Profile', onClick: 'goProfile', shortcut: 'Ctrl+P' };
// `AppAction.items` is parsed by the LEGACY `MenuItemSchema`, a plain
// `z.object` — so `onClick` is not refused there, it is STRIPPED in silence.
// An author therefore has no declared route to send it, which is what made
// deleting the two reads a cleanup rather than a behaviour removal.
//
// ⭐ UPDATED by objectui#7719 (director seat decision batch #70 of 2026-09-07):
// this block used to carry `shortcut` in the same fixture and the same
// sentence, and recorded "whether `shortcut` SHOULD become authorable here is
// a separate contract question" as OPEN. It is answered — `shortcut` does not
// become authorable, and it is no longer stripped here either: it is a named
// `retirementTombstone()` refusal pointing at `NavigationItem`. So the two
// keys no longer share a fate and no longer share a fixture.
// ⛔ The Zone-2 premise this file exists for is UNCHANGED, and in fact
// stronger: an author still has no declared route to send either key, and is
// now told so for one of them. The `shortcut` contract itself is pinned in
// `./app-menu-item-shortcut-refusal-7719.test.ts`, not here.
const authored = { label: 'Profile', onClick: 'goProfile' };

it('the items mirror accepts the document and drops both undeclared keys', () => {
it('the items mirror accepts the document and drops the undeclared `onClick`', () => {
const result = MenuItemSchema.safeParse(authored);
expect(result.success).toBe(true);
if (!result.success) return;
const parsed = result.data as Record<string, unknown>;
expect(parsed.label).toBe('Profile');
expect('onClick' in parsed).toBe(false);
expect('shortcut' in parsed).toBe(false);
});

it('an authored `shortcut` on the same item is REFUSED, not dropped (objectui#7719)', () => {
// Kept HERE, beside the `onClick` row, rather than only in the #7719 pin:
// the two keys were one sentence in objectui#6854 and are two contracts now,
// and this pair is what stops them being conflated again. Swap either
// expectation and the file says which key it is describing.
const result = MenuItemSchema.safeParse({ ...authored, shortcut: 'Ctrl+P' });
expect(result.success).toBe(false);
if (result.success) return;
expect(result.error.issues.some((i) => String(i.path[0]) === 'shortcut')).toBe(true);
});

it('a whole action carrying such an item parses green, with the item scrubbed', () => {
Expand All @@ -110,11 +130,27 @@ describe('why the cast could never have been fed by an author (objectui#6854 Zon
// element mirror `MenuItemSchema` was annotated `z.ZodType<any>` to break its own
// recursion. objectui#7760 gave that mirror its declaration as both type arguments,
// so the element is `AppMenuItem` now and a direct assertion to an index-signature
// type no longer overlaps. ⛔ The assertions below are unchanged and still read the
// RUNTIME object — the point of this file is that both undeclared keys are gone
// from the parsed value, which no static type can answer.
// type no longer overlaps. The assertion below still reads the RUNTIME object,
// which is the point of this file: whether a key survived a parse is not something
// any static type can answer.
//
// ⭐ It used to be a PAIR, asserting `onClick` and `shortcut` both gone, driven by a
// fixture that carried both keys. objectui#7719 split the two contracts, so only the
// `onClick` half belongs here — and `authored` above no longer carries `shortcut` at
// all. `shortcut` is no longer an UNDECLARED key that gets scrubbed; it is a declared
// `retirementTombstone`.
//
// ⛔ Do not restore the second assertion. Restored ALONE it would still pass, because
// this fixture has no `shortcut` for the parse to refuse — a green row asserting the
// absence of a key nobody wrote, which pins nothing. Making it mean anything would
// require putting `shortcut` back into `authored`, and THAT is what turns this block
// red: the parse fails, `result.success` is false, and the early return above fires
// before `first` is ever destructured. So the two halves cannot share one fixture any
// more, which is the whole reason they were split. The refusal is pinned by the row
// named "an authored `shortcut` on the same item is REFUSED, not dropped" — cited by
// NAME, because a positional reference goes stale the moment a row is inserted — and
// the contract itself by `./app-menu-item-shortcut-refusal-7719.test.ts`.
const [first] = (result.data as unknown as { items: Record<string, unknown>[] }).items;
expect('onClick' in first).toBe(false);
expect('shortcut' in first).toBe(false);
});
});
Loading
Loading