From 53433aa2f73ad8b9df1899626f27757b8d920021 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:29:57 +0000 Subject: [PATCH 1/2] feat(types,runner)!: retire the app node's actions array, AppAction and AppActionSchema Maintainer ruling C: app-level actions are navigation items of type 'action'. The app node's actions member becomes a named refusal on both faces (?: never + retirementTombstone, since BaseSchema's passthrough would otherwise keep an authored array), AppAction and AppActionSchema leave the published exports, and the standalone runner's header stops reading the array: no per-action buttons, no avatar menu, and the Bell is unconditional. Pins that read the retired surface are retired or re-pointed, the AppActionSchema pair leaves the mirror-parity ledger, and the docs teach navigation action items. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- .changeset/7469-app-actions-retired.md | 46 ++++ content/docs/core/app-schema.mdx | 104 ++++------ content/docs/guide/schema-overview.md | 16 +- packages/runner/src/LayoutRenderer.tsx | 121 ++--------- ...ayoutRenderer.appActionItems-6854.test.tsx | 132 ------------ .../LayoutRenderer.chrome-7469.test.tsx | 113 ++++++++++ .../app-action-onclick-refusal-6854.test.ts | 156 -------------- .../app-actions-retired-7469.test.ts | 196 ++++++++++++++++++ ...pp-menu-item-shortcut-refusal-7719.test.ts | 91 ++++---- ...ent-docs-retired-handler-keys-7340.test.ts | 10 +- ...ndler-keys-string-any-mirrors-7344.test.ts | 146 ++++++------- .../src/__tests__/phase2-schemas.test.ts | 18 +- .../src/__tests__/zod-mirror-parity.test.ts | 8 +- packages/types/src/app.ts | 115 ++++------ packages/types/src/index.ts | 1 - packages/types/src/zod/app.zod.ts | 97 ++++----- packages/types/src/zod/index.zod.ts | 1 - 17 files changed, 614 insertions(+), 757 deletions(-) create mode 100644 .changeset/7469-app-actions-retired.md delete mode 100644 packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx create mode 100644 packages/runner/src/__tests__/LayoutRenderer.chrome-7469.test.tsx delete mode 100644 packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts create mode 100644 packages/types/src/__tests__/app-actions-retired-7469.test.ts diff --git a/.changeset/7469-app-actions-retired.md b/.changeset/7469-app-actions-retired.md new file mode 100644 index 0000000000..439ddb808b --- /dev/null +++ b/.changeset/7469-app-actions-retired.md @@ -0,0 +1,46 @@ +--- +'@object-ui/types': minor +'@object-ui/runner': minor +--- + +feat(types,runner)!: the app node's `actions` array, `AppAction` and `AppActionSchema` are retired; app-level actions are `navigation` items of `type: 'action'` + +⚠️ Breaking, marked `minor` under this repo's version-alignment rule (a `major` +in the fixed group would move all of it off the `@objectstack` major). + +**`@object-ui/types`.** An `app` node that authors `actions` now FAILS to +validate, with a named refusal at `actions` that points at the replacement, and +a TypeScript literal typed as `AppComponentSchema` that sets `actions` no longer +compiles. The `AppAction` type and the `AppActionSchema` mirror are no longer +exported. `AppMenuItem` and its mirror stay, because the legacy `menu` still +uses them. + +The array was an objectui-only shape: free-form header buttons and a user +avatar menu. `@objectstack/spec`'s `AppSchema` is strict and never declared the +key, so the same app document parsed green here and was refused by the +platform. The console loads apps only from the platform, so it could never +receive the array. Only the standalone runner drew it, and its buttons declared +no behaviour to run. The maintainer's ruling (objectui#7469, option C) keeps +one channel for app-level actions: + +```ts +navigation: [ + { id: 'quick_create', type: 'action', label: 'Quick Create', actionDef: { actionName: 'quick_create' } }, +] +``` + +The console sidebar dispatches that item by action name. The signed-in user's +menu belongs to the host shell, not to app metadata. `actions` stays declared +as a `?: never` / `retirementTombstone()` pair (ADR-0049), because +`BaseSchema`'s `.passthrough()` would otherwise keep an authored array in +silence. + +**`@object-ui/runner`.** The header no longer reads the app's `actions`. It +draws no toolbar button per `'button'` entry and no avatar menu per `'user'` +entry. The notification Bell is now always drawn. Before, it was hidden when a +`'button'` action was authored. + +Changeset entries from objectui#6854, objectui#7344, objectui#7719 and +objectui#7721 describe `AppAction` as it stood before this retirement. The +`shortcut` refusal from objectui#7719 still applies to the legacy `menu` items. Pinned in `packages/types/src/__tests__/app-actions-retired-7469.test.ts` +and `packages/runner/src/__tests__/LayoutRenderer.chrome-7469.test.tsx`. diff --git a/content/docs/core/app-schema.mdx b/content/docs/core/app-schema.mdx index e6de620c0e..53bb21227a 100644 --- a/content/docs/core/app-schema.mdx +++ b/content/docs/core/app-schema.mdx @@ -7,7 +7,7 @@ import { SchemaExample } from '@/app/components/ComponentDemo'; # Application Schema -The `AppComponentSchema` defines the top-level configuration for your entire ObjectUI application, including navigation menus, branding, layout strategies, and global actions. +The `AppComponentSchema` defines the top-level configuration for your entire ObjectUI application, including navigation menus, branding, layout strategies, and app-level actions. ## Overview @@ -15,7 +15,7 @@ AppComponentSchema provides a declarative way to configure: - **Navigation menus** - Hierarchical menu structures with icons and badges - **Branding** - Logo, title, favicon - **Layout strategies** - Sidebar, header, or empty layout -- **Global actions** - User menu, global toolbar buttons +- **App-level actions** - `navigation` items of type `action` that run a declared action ## Interactive Examples @@ -47,14 +47,6 @@ const app: AppComponentSchema = { icon: 'layout-dashboard', path: '/dashboard' } - ], - - actions: [ - { - type: 'user', - label: 'John Doe', - avatar: '/avatar.jpg' - } ] }; ``` @@ -139,28 +131,47 @@ interface AppMenuItem { ### Global Actions -The `actions` property defines global toolbar buttons: +App-level actions — a "Quick Create" or "Log a Call" entry that is not tied to +one record — are `navigation` items of type `action`. The item names an action +declared in your metadata by `actionDef.actionName`; it does not carry the +action's definition. This is the same item shape `@objectstack/spec` accepts in +an app's `navigation`, so the document validates on both sides: ```ts -// `AppAction.items` uses the navigation-item shape documented under "Navigation -// Menu" above — declared and published as `AppMenuItem`. The bare `MenuItem` -// export is the unrelated overlay menu type used by `ui:dropdown-menu`. -import type { AppMenuItem } from '@object-ui/types'; +import type { AppComponentSchema } from '@object-ui/types'; -interface AppAction { - type: 'button' | 'dropdown' | 'user'; - label?: string; - icon?: string; - onClick?: never; // RETIRED (objectui#7344): the handler-expression string is refused by name — author an action:button node instead - avatar?: string; // For type='user' - description?: string; // For type='user' - items?: AppMenuItem[]; // For type='dropdown' or 'user' - shortcut?: string; // Keyboard shortcut - variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; - size?: 'default' | 'sm' | 'lg' | 'icon'; -} +const app: AppComponentSchema = { + type: 'app', + name: 'acme_crm', + title: 'Acme CRM', + navigation: [ + { + id: 'quick_create', + type: 'action', + label: 'Quick Create', + icon: 'zap', + actionDef: { actionName: 'quick_create' } + } + ] +}; ``` +The console renders the item in the app's sidebar. On click it looks up the +action named in `actionDef.actionName` among the declared `action` metadata and +runs it through the same action runner as a toolbar button, so confirmations, +parameter dialogs and permission checks apply. `actionDef.params` passes +arguments to that action. An item that names no declared action shows an error +when clicked instead of doing nothing. The standalone runner renders only the +legacy `menu` and does not render `navigation` items. + + +The free-form `actions` array (`AppAction`: header buttons and a user avatar +menu) is retired, and an app that authors `actions` fails validation with a +message pointing here (objectui#7469). `@objectstack/spec`'s `AppSchema` never +declared the key, so the platform rejected those apps too. The signed-in user's +menu belongs to the host shell, not to app metadata. + + ## Complete Example ```ts @@ -238,44 +249,6 @@ const crm: AppComponentSchema = { path: '/settings', hidden: '${user.role !== "admin"}' } - ], - - actions: [ - { - type: 'button', - label: 'Quick Actions', - icon: 'zap', - variant: 'outline' - }, - { - type: 'user', - label: 'John Doe', - avatar: '/avatars/john.jpg', - description: 'john@acme.com', - items: [ - { - type: 'item', - label: 'Profile', - icon: 'user', - path: '/profile' - }, - { - type: 'item', - label: 'Settings', - icon: 'settings', - path: '/settings' - }, - { - type: 'separator' - }, - { - type: 'item', - label: 'Logout', - icon: 'log-out', - path: '/logout' - } - ] - } ] }; ``` @@ -332,7 +305,6 @@ AppComponentSchema is ideal for: 3. **Limit top-level items** - Keep the main menu concise (5-7 items) 4. **Add badges for notifications** - Show counts or status indicators 5. **Hide admin features** - Use conditional visibility for role-based access -6. **Provide keyboard shortcuts** - Add shortcuts for frequently used actions ## Related diff --git a/content/docs/guide/schema-overview.md b/content/docs/guide/schema-overview.md index a0c0ee1623..78936a1d49 100644 --- a/content/docs/guide/schema-overview.md +++ b/content/docs/guide/schema-overview.md @@ -23,7 +23,7 @@ ObjectUI includes enterprise-grade capabilities to build production-ready applic #### [App Schema](/docs/core/app-schema) Define your entire application structure with navigation, branding, and global settings. - + ```typescript const app: AppComponentSchema = { @@ -31,7 +31,7 @@ const app: AppComponentSchema = { title: 'My Application', layout: 'sidebar', menu: [...], - actions: [...] + navigation: [...] }; ``` @@ -232,17 +232,6 @@ const app: AppComponentSchema = { { type: 'item', label: 'Deals', path: '/deals' } ] } - ], - - actions: [ - { - type: 'user', - label: 'User Name', - items: [ - { type: 'item', label: 'Profile', path: '/profile' }, - { type: 'item', label: 'Logout', path: '/logout' } - ] - } ] }; ``` @@ -250,7 +239,6 @@ const app: AppComponentSchema = { This creates a professional-looking CRM application with: - A sidebar layout with navigation menu - Sales section with leads and deals -- User menu with profile and logout options Theming is configured separately, as a theme document handed to `ThemeProvider` — see [Theme Schema](/docs/core/theme-schema). diff --git a/packages/runner/src/LayoutRenderer.tsx b/packages/runner/src/LayoutRenderer.tsx index d7c8cba1cb..ed0cc548d9 100644 --- a/packages/runner/src/LayoutRenderer.tsx +++ b/packages/runner/src/LayoutRenderer.tsx @@ -18,16 +18,6 @@ import { Sun, } from 'lucide-react'; import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, - Avatar, - AvatarImage, - AvatarFallback, Collapsible, CollapsibleContent, CollapsibleTrigger, @@ -236,102 +226,21 @@ export const LayoutRenderer = ({ app, children, currentPath, onNavigate }: Layou {theme === 'dark' ? : } - {/* Global Actions */} - {app.actions?.filter(a => a.type === 'button').map((action, i) => { - const Icon = action.icon ? getIcon(action.icon) : null; - return ( - - ); - })} - - {/* Fallback Bell if no actions defined, or keep it as specific logic? - The original code hardcoded a Bell button. - The app.json defines a 'Bell' button action. - So I should iterate app.actions for buttons as well. - */} - - {/* Original Bell Logic (Hardcoded in user request? No, it was hardcoded in my previous edit, but app.json has it too) - Let's check app.json. It has: - { "type": "button", "variant": "ghost", "size": "icon", "icon": "Bell" } - - If I render actions generically, I don't need the hardcoded Bell. - */} - - {(!app.actions || !app.actions.some(a => a.type === 'button')) && ( - - )} - - {app.actions?.filter(a => a.type === 'user').map((userAction, i) => ( - - - - - - -
-

{userAction.label || 'User'}

-

- {userAction.description || 'user@example.com'} -

-
-
- - - {/* - * Renders `AppAction.items` from its DECLARED type and nothing else - * (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 `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. - * - * `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') { - return ; - } - return ( - - {item.label} - - ); - })} - -
-
- ))} + {/* + * The Bell is unconditional (objectui#7469, maintainer ruling C). It used + * to be a fallback drawn only when the app's `actions` array authored no + * `'button'` entry; that free-form array and its `'user'` avatar menu are + * retired on both faces of `@object-ui/types`, so this chrome reads no + * app metadata here any more and keeps one rule. The contract's one + * channel for app-level actions is a `navigation` item of + * `type: 'action'`, which the console sidebar dispatches; this runner + * draws the legacy `menu` only and renders no `navigation` item. Pinned + * in `__tests__/LayoutRenderer.chrome-7469.test.tsx`. + */} + diff --git a/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx b/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx deleted file mode 100644 index 00f1ddae9e..0000000000 --- a/packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx +++ /dev/null @@ -1,132 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * `LayoutRenderer` renders `AppAction.items` from the DECLARED element type and - * nothing else (objectui#6854, maintainer ruling of 2026-09-05, option B2). - * - * `AppAction.items` is `AppMenuItem[]` — `type` / `label` / `icon` / `path` / - * `href` / `children` / `badge` / `hidden` — and the zod mirror parses it with - * 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 - * run it", and until this ruling one did — the three-layer contradiction the - * card was filed for (`?: never` on the type, "nobody reads it" from the - * validator, a renderer reading it). - * - * These assertions author BOTH undeclared keys anyway — exactly what a host - * bypassing the validator would hand in, which the card's Zone-2 census found - * none of in this repo — and require the renderer to ignore both. Re-adding - * either read turns one of them red. - * - * ⭐ 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 - * `packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts`. - */ - -import { describe, it, expect, afterEach, vi } from 'vitest'; -import { render, screen, cleanup } from '@testing-library/react'; -import userEvent from '@testing-library/user-event'; -import type { AppComponentSchema } from '@object-ui/types'; - -// Static, module-scope import: `LayoutRenderer` pulls `@object-ui/components` -// in for the dropdown primitives, and that cost must not land inside a test's -// timeout budget (AGENTS.md 测试纪律). -import { LayoutRenderer } from '../LayoutRenderer'; - -afterEach(() => cleanup()); - -const SHORTCUT = 'Ctrl+P'; - -/** - * A user action whose items carry the two keys the declared type does not have. - * `label` and `type: 'separator'` ARE declared on `AppMenuItem`, so they are the - * control: they must keep rendering, or this file would pass by rendering - * nothing at all. - */ -function appWith(onClick: () => void): AppComponentSchema { - return { - type: 'app', - name: 'pin_app', - title: 'Pin App', - layout: 'header', - actions: [ - { - type: 'user', - label: 'Ada Lovelace', - description: 'ada@example.com', - items: [ - // `onClick` / `shortcut` are NOT on `AppMenuItem`; authored here on - // purpose, through the same cast the renderer used to read them with. - { label: 'Profile', onClick, shortcut: SHORTCUT } as never, - { type: 'separator' }, - { label: 'Sign out' }, - ], - }, - ], - } as AppComponentSchema; -} - -async function openUserMenu(app: AppComponentSchema) { - const user = userEvent.setup(); - render( - -
page body
-
, - ); - // The trigger is the avatar button; with no `avatar` URL the Radix fallback - // renders the label's initials, which is the stable accessible name here. - await user.click(screen.getByRole('button', { name: 'AD' })); - return user; -} - -describe('LayoutRenderer renders AppAction.items without reading onClick or shortcut (objectui#6854)', () => { - it('renders the declared `label` of each item — the control for the two negatives below', async () => { - await openUserMenu(appWith(vi.fn())); - expect(await screen.findByText('Profile')).toBeTruthy(); - expect(screen.getByText('Sign out')).toBeTruthy(); - }); - - it('still renders the declared `type: "separator"` item as a separator', async () => { - await openUserMenu(appWith(vi.fn())); - await screen.findByText('Profile'); - // Two separators: the one above the group, plus the authored divider item. - expect(screen.getAllByRole('separator').length).toBeGreaterThanOrEqual(2); - }); - - it('never invokes an authored `onClick` — the key the refusal message says no renderer reads', async () => { - const onClick = vi.fn(); - const user = await openUserMenu(appWith(onClick)); - await user.click(await screen.findByText('Profile')); - expect(onClick).not.toHaveBeenCalled(); - }); - - it('never puts an authored `shortcut` into the DOM', async () => { - await openUserMenu(appWith(vi.fn())); - await screen.findByText('Profile'); - expect(screen.queryByText(SHORTCUT)).toBeNull(); - expect(document.body.textContent).not.toContain(SHORTCUT); - }); -}); diff --git a/packages/runner/src/__tests__/LayoutRenderer.chrome-7469.test.tsx b/packages/runner/src/__tests__/LayoutRenderer.chrome-7469.test.tsx new file mode 100644 index 0000000000..be72e87a06 --- /dev/null +++ b/packages/runner/src/__tests__/LayoutRenderer.chrome-7469.test.tsx @@ -0,0 +1,113 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * The runner's header chrome reads nothing off the app's `actions` array + * (objectui#7469, maintainer ruling C: retire the `actions` member of + * `AppComponentSchema`, `AppAction` and `AppActionSchema`, and the runner's + * rendering of them). + * + * Before the ruling `LayoutRenderer` drew three things off that array: a + * toolbar button per `'button'` entry, an avatar dropdown per `'user'` entry, + * and — the rule that made the chrome depend on metadata at all — the hardcoded + * Bell ONLY when no `'button'` entry was authored. The array is now refused by + * name on both faces of `@object-ui/types`, so the chrome keeps one rule: the + * Bell is always drawn, exactly as it was for an app with no `'button'` action. + * + * The documents below carry the retired array anyway, cast past the type — + * what a stale `app.json` handed straight to the runner would hold, since the + * runner loads its document without the validator. Each assertion compares + * against the SAME app without the array, so a row cannot pass by the header + * rendering nothing at all. + */ + +import { describe, it, expect, afterEach } from 'vitest'; +import { render, screen, cleanup, within } from '@testing-library/react'; +import type { AppComponentSchema } from '@object-ui/types'; + +// Static, module-scope import: `LayoutRenderer` pulls `@object-ui/components` +// in, and that cost must not land inside a test's timeout budget (AGENTS.md +// 测试纪律). +import { LayoutRenderer } from '../LayoutRenderer'; + +afterEach(() => cleanup()); + +const BASE_APP: AppComponentSchema = { + type: 'app', + name: 'pin_app', + title: 'Pin App', + layout: 'header', +}; + +/** Every arm the retired renderer honoured: a `'button'` (which also used to + * suppress the Bell) and a `'user'` avatar menu with items. */ +const RETIRED_ACTIONS = [ + { type: 'button', label: 'Quick Actions', icon: 'zap', variant: 'outline' }, + { + type: 'user', + label: 'Ada Lovelace', + description: 'ada@example.com', + items: [{ type: 'item', label: 'Profile', path: '/profile' }], + }, +]; + +const withRetiredActions = (): AppComponentSchema => + ({ ...BASE_APP, actions: RETIRED_ACTIONS }) as unknown as AppComponentSchema; + +function header(app: AppComponentSchema): HTMLElement { + const { container } = render( + +
page body
+
, + ); + const el = container.querySelector('header'); + if (!el) throw new Error('LayoutRenderer drew no
for a `header` layout'); + return el; +} + +const bellsIn = (el: HTMLElement) => el.querySelectorAll('svg.lucide-bell').length; + +describe('LayoutRenderer chrome keeps one rule: the Bell is unconditional (objectui#7469)', () => { + it('CONTROL — an app with no `actions` draws the header and exactly one Bell', () => { + const el = header(BASE_APP); + // The theme toggle is the header's own control: it proves the header + // rendered, so a zero below cannot be an empty render. + expect(within(el).getByTitle('Switch to dark mode')).toBeTruthy(); + expect(bellsIn(el)).toBe(1); + }); + + it('an authored `\'button\'` action no longer suppresses the Bell', () => { + expect(bellsIn(header(withRetiredActions()))).toBe(1); + }); + + it('no retired `\'button\'` entry is drawn as a toolbar button', () => { + const el = header(withRetiredActions()); + expect(within(el).queryByTitle('Quick Actions')).toBeNull(); + expect(el.textContent).not.toContain('Quick Actions'); + }); + + it('no retired `\'user\'` entry is drawn as an avatar menu', () => { + const el = header(withRetiredActions()); + // The old trigger's accessible name was the label's initials. + expect(within(el).queryByRole('button', { name: 'AD' })).toBeNull(); + expect(el.textContent).not.toContain('Ada Lovelace'); + }); + + it('the header with the retired array is button-for-button the header without it', () => { + const without = within(header(BASE_APP)).getAllByRole('button').length; + cleanup(); + const withArray = within(header(withRetiredActions())).getAllByRole('button').length; + expect(without).toBeGreaterThan(0); + expect(withArray).toBe(without); + }); + + it('the page body still renders beside the chrome', () => { + header(withRetiredActions()); + expect(screen.getByText('page body')).toBeTruthy(); + }); +}); diff --git a/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts b/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts deleted file mode 100644 index f395aaa622..0000000000 --- a/packages/types/src/__tests__/app-action-onclick-refusal-6854.test.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * ObjectUI - * Copyright (c) 2024-present ObjectStack Inc. - * - * This source code is licensed under the MIT license found in the - * LICENSE file in the root directory of this source tree. - */ - -/** - * `AppActionSchema.onClick`'s retirement message says something FALSIFIABLE - * about this tree, so it is pinned here (objectui#6854, maintainer ruling of - * 2026-09-05, option B2). - * - * The message `handlerKeyRefusal('onClick', 'retired', …)` generates ends: - * - * > … and no renderer reads this key, so nothing could ever run it. - * - * That sentence was FALSE when this card was filed. `@object-ui/runner`'s - * `LayoutRenderer` mapped `AppAction.items` and reached `(item as any).onClick` - * — past the declared element type, which is `AppMenuItem` and has no such key - * — leaving three mutually exclusive signals for a reader to pick from: the - * TypeScript face said `?: never`, the validator said nobody reads it, and a - * renderer read it. The ruling closed that by deleting the cast rather than by - * softening the sentence, so the sentence is true again and this file is what - * keeps it that way from the `packages/types` side: edit the shared template in - * `zod/tombstone.zod.ts` and this test names what the edit costs. - * - * The renderer side is pinned where the renderer lives — - * `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx` - * drives a real menu and requires an authored `onClick` never to be invoked. - * Neither pin can see the other's package, which is the point: the claim is - * about both, so it takes an assertion on each side. - * - * ⛔ NOT a pin on the template's exact prose in general — 22 other retired keys - * share it and their own message assertions live with them. This one asserts - * the clause whose truth this card measured. - */ - -import { describe, it, expect } from 'vitest'; -import { AppActionSchema, MenuItemSchema } from '../zod/app.zod'; - -/** The clause this card measured. Spelled out, not built from the template. */ -const MEASURED_CLAUSE = 'no renderer reads this key, so nothing could ever run it'; - -const onClickArm = AppActionSchema.shape.onClick; -const describeText = (onClickArm as { description?: string }).description; - -describe('AppActionSchema.onClick — the retirement message states a measured fact (objectui#6854)', () => { - it('claims, verbatim, that no renderer reads the key', () => { - expect(describeText).toContain(MEASURED_CLAUSE); - }); - - it('names the key and marks it RETIRED, not a runtime slot', () => { - expect(describeText).toContain('`onClick`'); - expect(describeText).toContain('RETIRED (objectui#6124'); - expect(describeText).not.toContain('RUNTIME SLOT'); - }); - - it('an authored value is refused BY NAME, carrying that same sentence to the author', () => { - const result = AppActionSchema.safeParse({ - type: 'button', - label: 'Quick actions', - onClick: 'openQuickActions', - }); - expect(result.success).toBe(false); - if (result.success) return; - const issue = result.error.issues.find((i) => String(i.path[0]) === 'onClick'); - expect(issue, 'no issue addressed to `onClick`').toBeDefined(); - expect(issue!.code).toBe('custom'); - // ONE string feeds both author-facing channels (the `handlerKeyRefusal` - // invariant): the parse-time message an author reads cannot drift away from - // the `.describe()` metadata the docs surface publishes. - expect(issue!.message).toBe(describeText); - expect(issue!.message).toContain(MEASURED_CLAUSE); - }); - - it('the action without the key parses green — the refusal is about the key, not the action', () => { - expect(AppActionSchema.safeParse({ type: 'button', label: 'Quick actions' }).success).toBe(true); - }); -}); - -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 `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 the undeclared `onClick`', () => { - const result = MenuItemSchema.safeParse(authored); - expect(result.success).toBe(true); - if (!result.success) return; - const parsed = result.data as Record; - expect(parsed.label).toBe('Profile'); - expect('onClick' 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', () => { - const result = AppActionSchema.safeParse({ - type: 'user', - label: 'Ada Lovelace', - items: [authored, { type: 'separator' }], - }); - expect(result.success).toBe(true); - if (!result.success) return; - // Through `unknown`: `AppActionSchema.items` used to infer as `any[]`, because its - // element mirror `MenuItemSchema` was annotated `z.ZodType` 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 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[] }).items; - expect('onClick' in first).toBe(false); - }); -}); diff --git a/packages/types/src/__tests__/app-actions-retired-7469.test.ts b/packages/types/src/__tests__/app-actions-retired-7469.test.ts new file mode 100644 index 0000000000..a8ed84b8cf --- /dev/null +++ b/packages/types/src/__tests__/app-actions-retired-7469.test.ts @@ -0,0 +1,196 @@ +/** + * ObjectUI + * Copyright (c) 2024-present ObjectStack Inc. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + */ + +/** + * Retirement pin — `AppComponentSchema.actions`, `AppAction` and + * `AppActionSchema` are RETIRED (objectui#7469, maintainer ruling C, ADR-0049 + * enforce-or-remove). App-level actions are `navigation` items of + * `type: 'action'`. + * + * ## What the ruling closed + * + * `actions` was an objectui-only array of free-form header buttons and a user + * menu. The platform's `@objectstack/spec` `AppSchema` is strict and never + * declared it, so the same app document parsed green here and was refused at + * the platform door — two validators, two answers. The console reads apps only + * from the platform, so it could never receive the array; only the standalone + * runner drew it, and the buttons declared no behaviour to run. The maintainer + * chose, verbatim: 「C 退役 actions[] (Recommended)」 — one channel, one + * contract: `navigation` items of `type: 'action'`, which the console sidebar + * dispatches by action name. + * + * ## Why a tombstone and not a deletion + * + * `BaseSchema` is `.passthrough()` on the zod side and carries an index + * signature on the TS side, so an UNDECLARED key is not refused, it is KEPT. + * Block (d) takes that reading on this very mirror, so the reason is a + * measurement and not prose. + * + * ## How refusals are asserted + * + * By the issue ENVELOPE — `code` and `path`, and that it is the ONLY issue — + * at the public door `safeValidateSchema` (what `objectui validate` runs) and + * on the mirror. The message is asserted to carry the remedy's own spelling + * (`navigation`, `type: 'action'`, `actionDef`), which is the contract here; + * nothing else of its wording is pinned. + * + * The `@ts-expect-error` directives in block (f) are REAL enforcement: this + * package type-checks its tests through `tsconfig.test.json`, so re-widening + * the member or re-exporting `AppAction` fails the build on the unused + * directive (TS2578). A green `vitest` run is NOT evidence about them. + */ + +import { describe, it, expect } from 'vitest'; +import { AppSchema as SpecAppSchema } from '@objectstack/spec/ui'; +import type { AppComponentSchema } from '../app'; +// @ts-expect-error — `AppAction` is retired (objectui#7469); re-exporting it turns this directive unused +import type { AppAction } from '../index'; +import { AppComponentSchema as AppMirror } from '../zod/app.zod'; +import * as zodFace from '../zod/index.zod'; + +const APP = { type: 'app', name: 'pin_app', title: 'Pin App' } as const; +const app = (extra: Record) => ({ ...APP, ...extra }); + +/** Every arm the retired element declared, plus the empty array: a tombstone + * refuses the KEY, so no value of it may slip through. */ +const RETIRED_VALUES: ReadonlyArray = [ + ['a `button` action', [{ type: 'button', label: 'Quick Actions', icon: 'zap', variant: 'outline' }]], + ['a `dropdown` action', [{ type: 'dropdown', label: 'More', items: [{ type: 'item', label: 'Help', path: '/help' }] }]], + ['a `user` action', [{ type: 'user', label: 'Ada Lovelace', avatar: '/a.png', description: 'ada@example.com' }]], + ['an empty array', []], +]; + +/** The remedy, spelled the way the spec's `ActionNavItemSchema` accepts it. */ +const ACTION_NAV_ITEM = { + id: 'quick_create', + type: 'action', + label: 'Quick Create', + icon: 'zap', + actionDef: { actionName: 'quick_create' }, +} as const; + +type Issue = { code: string; path: PropertyKey[]; message: string }; +const issuesOf = (r: { success: boolean; error?: { issues: Issue[] } }): Issue[] => r.error?.issues ?? []; + +const mirrorShape = (AppMirror as unknown as { shape: Record }).shape; + +/* ── (a) the key is refused by name, with the navigation remedy ──────────── */ + +describe('objectui#7469 (a) — `actions` is RETIRED on the `app` node', () => { + it.each(RETIRED_VALUES)( + 'refuses %s at the public door and on the mirror: ONE `invalid_type` issue at `actions`, naming the remedy', + (_label, value) => { + for (const r of [zodFace.safeValidateSchema(app({ actions: value })), AppMirror.safeParse(app({ actions: value }))]) { + expect(r.success, 'an authored `actions` array was ACCEPTED').toBe(false); + const issues = issuesOf(r); + expect(issues.map(({ code, path }) => ({ code, path }))).toEqual([{ code: 'invalid_type', path: ['actions'] }]); + const message = issues[0]!.message; + expect(message).toContain('`actions`'); + expect(message).toContain('navigation'); + expect(message).toContain("type: 'action'"); + expect(message).toContain('actionDef'); + } + }, + ); + + it('the refusal message and the published `.describe()` metadata are ONE string', () => { + const r = AppMirror.safeParse(app({ actions: RETIRED_VALUES[0]![1] })); + expect(issuesOf(r)[0]!.message).toBe(mirrorShape.actions!.description); + }); + + it('stays DECLARED on the mirror — a tombstone, not a deletion (see block d)', () => { + expect(Object.keys(mirrorShape)).toContain('actions'); + }); +}); + +/* ── (b) LIT CONTROL: the remedy parses, on both doors ───────────────────── */ + +describe('objectui#7469 (b) — LIT CONTROL: a `navigation` item of `type: \'action\'` parses', () => { + it('on this package\'s public door and mirror', () => { + const doc = app({ navigation: [ACTION_NAV_ITEM] }); + expect(issuesOf(zodFace.safeValidateSchema(doc))).toEqual([]); + expect(issuesOf(AppMirror.safeParse(doc))).toEqual([]); + }); + + it('on the platform\'s strict `AppSchema` too — the remedy is the spec\'s own spelling', () => { + const r = SpecAppSchema.safeParse({ name: 'pin_app', label: 'Pin App', navigation: [ACTION_NAV_ITEM] }); + expect(issuesOf(r as never)).toEqual([]); + }); +}); + +/* ── (c) the two doors now give ONE answer ───────────────────────────────── */ + +describe('objectui#7469 (c) — the platform\'s `AppSchema` refuses the key too', () => { + it('the spec refuses `actions` with `unrecognized_keys` naming it — the answer this mirror now agrees with', () => { + const r = SpecAppSchema.safeParse({ name: 'pin_app', label: 'Pin App', actions: RETIRED_VALUES[0]![1] }); + expect(r.success).toBe(false); + const issues = (r.error?.issues ?? []) as Array<{ code: string; keys?: string[] }>; + expect(issues.map((i) => ({ code: i.code, keys: i.keys }))).toEqual([{ code: 'unrecognized_keys', keys: ['actions'] }]); + }); + + it('CONTROL — the same spec document parses without the key', () => { + expect(SpecAppSchema.safeParse({ name: 'pin_app', label: 'Pin App' }).success).toBe(true); + }); +}); + +/* ── (d) why a tombstone: an undeclared key would be KEPT, not refused ──── */ + +describe('objectui#7469 (d) — CONTROL: a deleted arm would KEEP the array under `.passthrough()`', () => { + it('the mirror with the arm omitted parses the retired array green and keeps it — what a deletion would have left', () => { + const value = RETIRED_VALUES[0]![1]; + const r = AppMirror.omit({ actions: true }).safeParse(app({ actions: value })); + expect(r.success).toBe(true); + expect((r.data as Record).actions).toEqual(value); + }); +}); + +/* ── (e) the element schema left the published face ──────────────────────── */ + +describe('objectui#7469 (e) — `AppActionSchema` is no longer exported', () => { + it('the `./zod` entry exports no `AppActionSchema`, while its siblings stay', () => { + expect('AppActionSchema' in zodFace).toBe(false); + // CONTROL: the same namespace read finds the app mirror and the legacy + // menu-item mirror, so the `false` above is a reading, not a dead probe. + expect('AppComponentSchema' in zodFace).toBe(true); + expect('AppMenuItemSchema' in zodFace).toBe(true); + }); +}); + +/* ── (f) the TS twin carries the same contract ───────────────────────────── */ + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Expect = T; + +/** `Equal`, not `extends`: an UNDECLARED member reads `any` through the index signature. */ +export type assertionActionsRetired = Expect>; +/** The helper can FAIL — synthetic control (an undeclared key reads `any`). */ +export type assertionEqualCanFail = Expect, false>>; +/** Keeps the retired-import directive above honest: the name is USED, so a + * re-exported `AppAction` cannot leave the directive satisfied by an + * unused-import diagnostic instead of the missing-export one. */ +export type RetiredAppActionImport = AppAction; + +describe('objectui#7469 (f) — the TS twin refuses what the mirror refuses', () => { + it('authoring `actions` is a compile error — checked by `tsc -p tsconfig.test.json`', () => { + const refused: AppComponentSchema = { + ...APP, + // @ts-expect-error — retired: author `navigation` items of `type: 'action'` + actions: [{ type: 'button', label: 'Quick Actions' }], + }; + expect(refused.type).toBe('app'); + }); + + it('CONTROL — the navigation remedy compiles on the same node', () => { + const node: AppComponentSchema = { + ...APP, + navigation: [{ id: 'quick_create', type: 'action', label: 'Quick Create', actionDef: { actionName: 'quick_create' } }], + }; + expect(node.navigation?.[0]?.actionDef?.actionName).toBe('quick_create'); + }); +}); diff --git a/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts b/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts index a3efcd8a8c..af6a3edaf0 100644 --- a/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts +++ b/packages/types/src/__tests__/app-menu-item-shortcut-refusal-7719.test.ts @@ -7,8 +7,16 @@ */ /** - * objectui#7719 — `shortcut` is REFUSED BY NAME on an app action ITEM - * (`AppAction.items`, i.e. the legacy `AppMenuItem`), on BOTH faces. + * objectui#7719 — `shortcut` is REFUSED BY NAME on the legacy `AppMenuItem`, on + * BOTH faces. + * + * ⭐ objectui#7469 (maintainer ruling C) retired the app node's `actions` array, + * `AppAction` and `AppActionSchema`. The ruling below was taken on the app action + * ITEMS (`AppAction.items`), which were one of the two places this type was + * authored; the other, `AppComponentSchema.menu`, stays, and so does this + * refusal. The rows that read the retired action (a non-target `shortcut` on the + * action itself, and the `items[]` authoring position) went with it; block (d) + * now takes the refusal through `menu[]`. * * ## The ruling, quoted rather than paraphrased * @@ -46,8 +54,9 @@ * * `retirementTombstone()` — `z.never({ error }).optional().describe()`. * - * - ⛔ NOT `handlerKeyRefusal()`, despite the in-file precedent on the sibling - * `AppActionSchema.onClick`. Two independent reasons, the second decisive: + * - ⛔ NOT `handlerKeyRefusal()`, despite the in-file precedent the sibling + * `AppActionSchema.onClick` then carried (retired with its schema by + * objectui#7469). Two independent reasons, the second decisive: * its message says JSON has no function value, which is FALSE of a * string-valued key an author can perfectly well write; and its `z.custom` * primitive makes `z.toJSONSchema` THROW. Measured before this change: @@ -82,8 +91,8 @@ import { describe, it, expect } from 'vitest'; import { z } from 'zod'; -import type { AppAction, AppMenuItem } from '../app'; -import { AppActionSchema, MenuItemSchema } from '../zod/app.zod'; +import type { AppMenuItem } from '../app'; +import { AppComponentSchema, MenuItemSchema } from '../zod/app.zod'; /** Mutual assignability, the standard invariant `Eq` — not `extends`. */ type Eq = (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) @@ -112,7 +121,7 @@ describe('objectui#7719 — the TypeScript face refuses `shortcut` on `AppMenuIt // worth guarding: this row is what turns a future option-B patch red. const item: AppMenuItem = { label: 'Profile', - // @ts-expect-error `shortcut` is refused on an app action item — author navigation as `NavigationItem` (objectui#7719) + // @ts-expect-error `shortcut` is refused on a legacy menu item — author navigation as `NavigationItem` (objectui#7719) shortcut: 'Ctrl+P', }; expect(item.label).toBe('Profile'); @@ -130,19 +139,6 @@ describe('objectui#7719 — the TypeScript face refuses `shortcut` on `AppMenuIt expect(item.path).toBe('/profile'); }); - it('CONTROL — `AppAction.shortcut` is a DIFFERENT key, still declared and still a `string`', () => { - // ⭐ The non-target control, and the reason it is in this file: the first - // reading of a `grep` for `shortcut` over `app.ts` finds THIS member and - // reads it as "option B is already landed". It is the header BUTTON's own - // shortcut, one level up from `items[]`, and the ruling does not touch it. - // - // PROVING REMOVAL: retire `AppAction.shortcut` too — over-applying this card - // to every `shortcut` in the file — and this row reds in both halves. - const _stillAString: Eq = true; - expect(_stillAString).toBe(true); - const action: AppAction = { type: 'button', label: 'Search', shortcut: 'Ctrl+K' }; - expect(action.shortcut).toBe('Ctrl+K'); - }); }); /* ── (b) the zod mirror ───────────────────────────────────────────────────── */ @@ -168,10 +164,12 @@ describe('objectui#7719 — the JSON face refuses `shortcut` by name', () => { expect(issue, 'no issue was addressed to `shortcut`').toBeDefined(); expect(issue!.message).toContain('NavigationItem'); expect(issue!.message).toContain('objectui#7719'); - // ⚠️ The message must not read as though `shortcut` is refused on app - // actions generally — it is refused on the ITEMS. It says so by naming the - // surviving sibling explicitly. - expect(issue!.message).toContain('AppAction.shortcut'); + // The message names the surface it refuses on. It used to name the header + // button's own `AppAction.shortcut` as the surviving sibling; objectui#7469 + // retired that type, and a message naming it would send an author to a key + // that no longer exists. + expect(issue!.message).toContain('AppMenuItem'); + expect(issue!.message).not.toContain('AppAction'); }); it('the refusal reports `invalid_type` at the key path — the `z.never` code, not `custom`', () => { @@ -244,41 +242,30 @@ describe('objectui#7719 — a bare non-declaration strips in silence; it refuses }); }); -/* ── (d) the real authoring position: `AppAction.items[]` ─────────────────── */ +/* ── (d) the real authoring position: an app's `menu[]` ──────────────────── */ describe('objectui#7719 — the refusal holds where an author actually writes it', () => { - const action = { - type: 'user' as const, - label: 'Ada Lovelace', - items: [{ label: 'Profile', shortcut: 'Ctrl+P' }], - }; + // Block (b) parses `MenuItemSchema` directly; this one goes through the + // declaration an author actually writes, so a refusal that existed only on + // the standalone const would show up here as green. Until objectui#7469 that + // position was `AppAction.items[]`; with the action array retired, the app's + // legacy `menu[]` is the one that remains. + const appWith = (item: Record) => ({ + type: 'app' as const, + name: 'pin_app', + menu: [item], + }); - it('an app action whose item carries `shortcut` is refused at `items.0.shortcut`', () => { - // Block (b) parses `MenuItemSchema` directly; this one goes through the - // declaration an author actually writes, so a refusal that existed only on - // the standalone const would show up here as green. - const r = AppActionSchema.safeParse(action); + it('an app whose menu item carries `shortcut` is refused at `menu.0.shortcut`', () => { + const r = AppComponentSchema.safeParse(appWith({ type: 'item', label: 'Profile', path: '/profile', shortcut: 'Ctrl+P' })); expect(r.success).toBe(false); if (r.success) return; - expect(r.error.issues.some((i) => i.path.join('.') === 'items.0.shortcut')).toBe(true); + expect(r.error.issues.map((i) => i.path.join('.'))).toEqual(['menu.0.shortcut']); }); - it('CONTROL — the SAME action parses green carrying `shortcut` on the ACTION itself', () => { - // ⭐ The mirror half of block (a)'s non-target control, and the row that - // stops this card from being read as "shortcut is refused on app actions". - // The header button's own shortcut is declared, untouched, and still green. - // - // PROVING REMOVAL: move the tombstone one declaration up — onto - // `AppActionSchema` instead of `MenuItemSchema` — and this row reds while - // block (d)'s first row goes green. The pair localises the change to the - // right declaration, which a single row could not do. - const r = AppActionSchema.safeParse({ - type: 'user' as const, - label: 'Ada Lovelace', - shortcut: 'Ctrl+K', - items: [{ label: 'Profile' }], - }); - expect(r.success).toBe(true); + it('CONTROL — the SAME app parses green with the item\'s `shortcut` removed', () => { + // Localises the refusal to the key, not to the fixture or to the node. + expect(AppComponentSchema.safeParse(appWith({ type: 'item', label: 'Profile', path: '/profile' })).success).toBe(true); }); }); diff --git a/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts index da877f3b4c..bcc16f9578 100644 --- a/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts +++ b/packages/types/src/__tests__/component-docs-retired-handler-keys-7340.test.ts @@ -299,12 +299,18 @@ describe('the retired population is measured off the shipped tree (objectui#7340 // mover; what objectui#9342 supplied is the missing precondition, moving // `KanbanRenderer`'s read onto an explicit React prop so that // `check:handler-key-reads` would accept the tombstone at all. + // + // ⭐ 27 → 26, and `app.ts` leaves this census: objectui#7469 (maintainer + // ruling C) retired `AppAction` WHOLE, taking its `onClick?: never` out of + // the shipped tree — the #8802 kind of shrink, not a key dropped from a live + // interface. The array that held the type, the app node's `actions`, is + // itself a named refusal (`./app-actions-retired-7469.test.ts`), so an + // authored action with an `onClick` is refused before any member is read. const split: Record = {}; for (const m of RETIRED) split[m.file] = (split[m.file] ?? 0) + 1; expect({ total: RETIRED.length, split }).toEqual({ - total: 27, + total: 26, split: { - 'app.ts': 1, 'complex.ts': 2, 'crud.ts': 3, 'data-display.ts': 4, diff --git a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts index 813d273bd4..c674ecd0e6 100644 --- a/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts +++ b/packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts @@ -84,20 +84,15 @@ * "nothing reads `AppComponentSchema.actions[]`" that the objectui#7344 * changeset was about to publish as `@object-ui/types` CHANGELOG copy * (objectui#7721). Re-measured whole-tree: - * - `app.zod.ts#AppActionSchema.onClick` — `AppComponentSchema.actions[]` IS - * read, by exactly ONE package: `@object-ui/runner`, whose `LayoutRenderer` - * renders the `'button'` arm as toolbar buttons and the `'user'` arm as an - * avatar dropdown. What no reader touches is `onClick` itself — not on the - * action, and no longer on `AppAction.items`, where that same renderer - * reached one through an `as any` cast until objectui#6854 deleted it - * (guarded from the renderer side by - * `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx`). - * The reader set is now ASSERTED rather than narrated: the census below - * reads its population off the tree, so a reader appearing in a package - * nobody thought to list fails loudly instead of narrowing the claim in - * silence. `AppAction` is still imported nowhere outside `packages/types` - * — the runner reaches the array structurally, through - * `AppComponentSchema`. + * - `app.zod.ts#AppActionSchema.onClick` — LEFT THIS LEDGER with objectui#7469 + * (maintainer ruling C), which retired `AppComponentSchema.actions`, + * `AppAction` and `AppActionSchema` on both faces and deleted the runner's + * rendering of the array. The key went with the whole element schema, so + * this is not a reclassification in place. What this entry measured before + * that: the array WAS read, by exactly one package (`@object-ui/runner`'s + * `LayoutRenderer`), and `onClick` was not. The reader census below now + * asserts the retirement's other half: NOTHING reads the array, with the + * same population controls. * - `reports.zod.ts#ReportBuilderSchema.onSave` / `.onCancel` — no renderer is * registered for `report-builder` (control on the same tree: * `register('detail-view'` and `register('report-designer'` both resolve). @@ -160,7 +155,6 @@ import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; import { z } from 'zod'; -import { AppActionSchema as AppActionZod } from '../zod/app.zod'; import { CalendarViewSchema as CalendarViewZod } from '../zod/complex.zod'; import { ActionSchema as ActionZod, @@ -175,7 +169,6 @@ import { ViewSwitcherSchema as ViewSwitcherZod, } from '../zod/views.zod'; -import type { AppAction } from '../app'; import type { CalendarViewSchema } from '../complex'; import type { ActionSchema, CRUDDialogSchema, DetailSchema } from '../crud'; import type { ReportBuilderSchema } from '../reports'; @@ -209,9 +202,9 @@ const RUNTIME_SLOT: readonly Site[] = [ ['complex.zod.ts', 'CalendarViewSchema', 'onEventClick', CalendarViewZod], ]; -/** The four keys NO renderer reads. */ +/** The keys NO renderer reads. (`app.zod.ts#AppActionSchema.onClick` left with + * its whole schema — objectui#7469; see the docblock.) */ const RETIRED: readonly Site[] = [ - ['app.zod.ts', 'AppActionSchema', 'onClick', AppActionZod], ['reports.zod.ts', 'ReportBuilderSchema', 'onSave', ReportBuilderZod], ['reports.zod.ts', 'ReportBuilderSchema', 'onCancel', ReportBuilderZod], ['crud.zod.ts', 'CRUDDialogSchema', 'onClose', CRUDDialogZod], @@ -311,15 +304,19 @@ describe('census: the only on*: z.(function|string|any) lines left in packages/t expect(MIRROR_FILES.length).toBeGreaterThanOrEqual(12); }); - it('10 sites are ledgered, 6 runtime slots + 4 retired, with no key filed twice', () => { + it('9 sites are ledgered, 6 runtime slots + 3 retired, with no key filed twice', () => { // 8 at objectui#7344; 10 since objectui#7804 declared the two keys - // `DetailView` reads off a `'detail'` document undeclared. ⛔ A ledger - // GROWS here by a declaration landing, never by a key being reclassified - // in place — the shrink direction is the failure this family guards. + // `DetailView` reads off a `'detail'` document undeclared; 9 since + // objectui#7469 retired `AppActionSchema` WHOLE (maintainer ruling C). + // ⛔ A ledger GROWS here by a declaration landing, never by a key being + // reclassified in place — the shrink direction is the failure this family + // guards. The objectui#7469 shrink is the other kind: the declaring schema + // left both faces, and `AppComponentSchema.actions`, the array that held it, + // is a named refusal (`./app-actions-retired-7469.test.ts`). expect(RUNTIME_SLOT).toHaveLength(6); - expect(RETIRED).toHaveLength(4); + expect(RETIRED).toHaveLength(3); const ids = ALL_SITES.map(([file, schema, key]) => `${file}#${schema}.${key}`); - expect(new Set(ids).size).toBe(10); + expect(new Set(ids).size).toBe(9); }); it.each(ALL_SITES)('%s %s.%s is DECLARED on the mirror shape, with the objectui#6124 guidance as its description', (_file, _schema, key, mirror) => { @@ -331,26 +328,32 @@ describe('census: the only on*: z.(function|string|any) lines left in packages/t }); }); -/* ── Census: WHO reads `AppComponentSchema.actions[]` (objectui#7721) ────── */ +/* ── Census: WHO reads `AppComponentSchema.actions[]` (objectui#7721 → #7469) ── */ -/** The whole-tree reader set, measured on `origin/main` @ `951fa8e0d`: one file, - * one package. The entry above used to carry this as a sentence naming three - * packages, which is why it went stale silently — a hand-written scope cannot - * fail when the tree grows a fourth package. Held as DATA so the failure names - * the newcomer instead of leaving the next reader to re-derive the set. */ -const ACTIONS_READER_FILES = ['packages/runner/src/LayoutRenderer.tsx'] as const; -const ACTIONS_READER_PACKAGES = ['@object-ui/runner'] as const; +/** The whole-tree reader set. objectui#7721 measured ONE file in ONE package + * (`@object-ui/runner`'s `LayoutRenderer`, on `origin/main` @ `951fa8e0d`) and + * held it here as DATA, because a hand-written scope cannot fail when the tree + * grows a reader nobody listed. objectui#7469 (maintainer ruling C) retired the + * array on both faces and deleted that reader, so the set is now EMPTY — and + * the same instrument, with the same population controls, is what keeps it + * empty: a renderer that starts reading the refused key again names itself. */ +const ACTIONS_READER_FILES = [] as const; +/** The former reader: still in the population (it names `AppComponentSchema`), + * no longer a reader. The positive control for the empty set above. */ +const FORMER_READER_FILE = 'packages/runner/src/LayoutRenderer.tsx'; /** `packages/types` DECLARES the member and cannot render it (zero deps, no * React — AGENTS.md §3), so it is outside the reader population by * construction, not by convenience. `git grep` reads TRACKED files, so a - * literal anchor also matches the two files that merely DESCRIBE the census; + * literal anchor also matches the two files that merely DESCRIBE the key; * those two are pinned below, so a third matching file inside `packages/types` - * still turns this red rather than slipping through the exclusion. */ + * still turns this red rather than slipping through the exclusion. Since + * objectui#7469 they are this census and the retirement's own pin — `app.ts` + * left the set with the `AppAction` docblock that described the old reader. */ const DECLARING_PACKAGE_PREFIX = 'packages/types/'; const DECLARING_PACKAGE_PROSE = [ + 'packages/types/src/__tests__/app-actions-retired-7469.test.ts', 'packages/types/src/__tests__/handler-keys-string-any-mirrors-7344.test.ts', - 'packages/types/src/app.ts', ] as const; /** Two independent anchors, because a reader can reach the array two ways. @@ -576,11 +579,11 @@ describe('engine self-test: the git regex flag the census runs through has `\\b` }); }); -describe('census: `AppComponentSchema.actions[]` IS read, by exactly one package, with the scope in the assertion (objectui#7721)', () => { +describe('census: NOTHING reads the retired `AppComponentSchema.actions[]`, with the scope in the assertion (objectui#7721 instrument, objectui#7469 retirement)', () => { it('the scan is ALIVE and SELECTIVE — a large population that the anchors narrow, not an empty grep', () => { const { namesType, readsActions, anchorA, anchorB } = scan(); - // A filter over an empty scan passes vacuously, and "exactly one reader" - // is what a dead pattern renders as. Both directions get a counter-probe. + // A filter over an empty scan passes vacuously, and "no reader" is exactly + // what a dead pattern renders as. Both directions get a counter-probe. expect( readsActions.length, 'nothing in the tree reads `.actions` — the anchor is dead. Before hunting for a tree ' @@ -594,7 +597,7 @@ describe('census: `AppComponentSchema.actions[]` IS read, by exactly one package .toBeGreaterThan(5); expect(anchorA.length, 'anchor A matched nothing').toBeGreaterThan(0); expect(anchorB.length, 'anchor B matched nothing').toBeGreaterThan(0); - // …and they must actually narrow, or the one-reader result is an artefact. + // …and they must actually narrow, or the empty reader set is an artefact. expect(anchorA.length).toBeLessThan(readsActions.length); expect(anchorB.length).toBeLessThan(readsActions.length); }); @@ -612,22 +615,24 @@ describe('census: `AppComponentSchema.actions[]` IS read, by exactly one package ).toBe(true); expect( readers.filter((f) => f.startsWith(prefix)), - `${prefix} now reads the array — the retirement rationale needs re-measuring`, + `${prefix} now reads the array — it is RETIRED and refused by name (objectui#7469)`, ).toEqual([]); } }); - it('exactly one file reads the array, and its package is `@object-ui/runner`', () => { + it('no file outside `packages/types` reads the array — the retired key has no reader', () => { const { readers } = scan(); expect( readers, - 'the reader set moved — widen ACTIONS_READER_FILES and re-check the ' - + '`onClick` rationale before editing this expectation', + 'a file reads `actions` off an app again — the key is RETIRED on both faces and ' + + 'refused by name (objectui#7469, maintainer ruling C); app-level actions are ' + + '`navigation` items of `type: \'action\'`. Do not widen ACTIONS_READER_FILES', ).toEqual([...ACTIONS_READER_FILES]); - expect([...new Set(readers.map(packageNameOf))].sort()).toEqual([...ACTIONS_READER_PACKAGES]); + // Named for the diagnostic above, so a failure also says WHICH package grew one. + expect([...new Set(readers.map(packageNameOf))]).toEqual([]); }); - it('`packages/types` matches only as PROSE — the declaration and this census, and no third file', () => { + it('`packages/types` matches only as PROSE — the retirement pin and this census, and no third file', () => { const { anchorA, anchorB } = scan(); const inTypes = [...new Set([...anchorA, ...anchorB])] .filter((f) => f.startsWith(DECLARING_PACKAGE_PREFIX)) @@ -635,17 +640,19 @@ describe('census: `AppComponentSchema.actions[]` IS read, by exactly one package expect(inTypes).toEqual([...DECLARING_PACKAGE_PROSE]); }); - it('the one reader renders BOTH arms and reads no `onClick` off an action — the real ground for the retirement', () => { - const src = readFileSync(join(REPO_ROOT, ACTIONS_READER_FILES[0]), 'utf8'); - expect(src).toContain("app.actions?.filter(a => a.type === 'button')"); - expect(src).toContain("app.actions?.filter(a => a.type === 'user')"); - // The array is read; the KEY is not. That distinction is the whole card: - // `onClick?: never` is right, the reason given for it was wrong. - expect(src, 'a renderer now reads `onClick` off an action — `?: never` is no longer true') - .not.toMatch(/\b(?:action|userAction|a)\??\.onClick\b/); + it('the FORMER reader is still in the population and is no longer a reader — the empty set is a reading', () => { + // The control for an empty reader set has to be the file that USED to be + // in it: it still names `AppComponentSchema` (so the scan reached it), and + // the anchors now reject it. Its two filters are gone from the source. + const { namesType, readers } = scan(); + expect(namesType.has(FORMER_READER_FILE), `${FORMER_READER_FILE} left the population — re-derive this control`) + .toBe(true); + expect(readers).not.toContain(FORMER_READER_FILE); + const src = readFileSync(join(REPO_ROOT, FORMER_READER_FILE), 'utf8'); + expect(src).not.toMatch(/\bapp\??\.actions\b/); }); - it('`AppAction` is imported nowhere outside `packages/types` — the runner reaches the array through `AppComponentSchema`', () => { + it('`AppAction` is imported nowhere outside `packages/types` — it is retired, and the runner never imported it', () => { const importers = gitGrepFiles(['-l', GREP_ENGINE_FLAG, ANCHOR_IMPORTS_APP_ACTION]) .filter((f) => !f.startsWith(DECLARING_PACKAGE_PREFIX)); expect(importers).toEqual([]); @@ -658,10 +665,13 @@ describe('census: `AppComponentSchema.actions[]` IS read, by exactly one package 'the import anchor found nothing at all — if this host\'s git has no PCRE the engine ' + 'self-test above names it; otherwise the tree moved', ).toBeGreaterThan(0); - // The symbol survives only as prose, and only in the reader's own package. + // The retired symbol survives outside `packages/types` only as PROSE, in + // the runner's own retirement pin (objectui#7469), which names what it + // retired. A second file mentioning it is new code reaching for a type + // that no longer exists. const mentions = gitGrepFiles(['-l', GREP_ENGINE_FLAG, ANCHOR_MENTIONS_APP_ACTION]) .filter((f) => !f.startsWith(DECLARING_PACKAGE_PREFIX)); - expect([...new Set(mentions.map(packageNameOf))]).toEqual([...ACTIONS_READER_PACKAGES]); + expect(mentions).toEqual(['packages/runner/src/__tests__/LayoutRenderer.chrome-7469.test.tsx']); }); }); @@ -737,19 +747,12 @@ describe('counter-probe: deleting the key instead is a SILENT accept on either b expect((result.data as Record).onBack).toBe('goBack'); }); - it('the deletion, simulated on a plain `z.object` mirror: parses GREEN and DROPS the string — the objectui#4453 silence, from the other side', () => { - // `AppActionSchema` is not `.passthrough()`: an undeclared key is stripped, - // so the author is told green and the value vanishes. Two base shapes, two - // different silences; the named refusal is the only outcome that is loud on - // both. - const authored = { type: 'button', label: 'Quick Actions', onClick: 'openQuickActions' }; - const withArm = AppActionZod.safeParse(authored); - expect(withArm.success).toBe(false); - expect(withArm.error?.issues.map((i) => i.path)).toEqual([['onClick']]); - const deleted = AppActionZod.omit({ onClick: true }).safeParse(authored); - expect(deleted.success).toBe(true); - expect('onClick' in (deleted.data as Record)).toBe(false); - }); + // The plain-`z.object` half of this pair ran on `AppActionSchema.onClick`, the + // only ledgered site on that base shape, and left with the schema + // (objectui#7469, maintainer ruling C). The other silence it showed — an + // undeclared key STRIPPED rather than kept — is still measured on a plain + // `z.object` mirror, `MenuItemSchema`, in + // `./app-menu-item-shortcut-refusal-7719.test.ts` (its STRIP block). }); /* ── The TypeScript face, judged by `tsc -p tsconfig.test.json` ──────────── */ @@ -774,7 +777,6 @@ type KeepsFunction = [Extract, (...args: never[]) => unknown>] type StringIsGone = [Extract, string>] extends [never] ? true : false; export type assertionRetiredKeysAreTombstoned = [ - Expect>, Expect>, Expect>, Expect>, @@ -796,9 +798,9 @@ export type assertionRuntimeSlotsKeepTheirFunctionType = [ Expect>, ]; -/** The four former `string` twins — `app.ts`, `reports.ts` ×2, `views.ts`. */ +/** The former `string` twins — `reports.ts` ×2, `views.ts` (the fourth, `app.ts`'s + * `AppAction.onClick`, left with its type: objectui#7469). */ export type assertionStringTwinsStopDeclaringString = [ - Expect>, Expect>, Expect>, Expect>, diff --git a/packages/types/src/__tests__/phase2-schemas.test.ts b/packages/types/src/__tests__/phase2-schemas.test.ts index d1f44a0ae1..170bb473bd 100644 --- a/packages/types/src/__tests__/phase2-schemas.test.ts +++ b/packages/types/src/__tests__/phase2-schemas.test.ts @@ -54,19 +54,11 @@ describe('Phase 2: AppComponentSchema Zod Validation', () => { ], }, ], - actions: [ - { - type: 'user', - label: 'John Doe', - avatar: '/avatar.jpg', - description: 'john@example.com', - items: [ - { type: 'item', label: 'Profile', path: '/profile' }, - { type: 'item', label: 'Settings', path: '/settings' }, - { type: 'separator' }, - { type: 'item', label: 'Logout', path: '/logout' }, - ], - }, + // `actions` is retired on the app node (objectui#7469) and refused by + // name — see `app-actions-retired-7469.test.ts`. App-level actions are + // `navigation` items of `type: 'action'`. + navigation: [ + { id: 'quick_create', type: 'action', label: 'Quick Create', actionDef: { actionName: 'quick_create' } }, ], }; diff --git a/packages/types/src/__tests__/zod-mirror-parity.test.ts b/packages/types/src/__tests__/zod-mirror-parity.test.ts index bd52625f77..4b6690163d 100644 --- a/packages/types/src/__tests__/zod-mirror-parity.test.ts +++ b/packages/types/src/__tests__/zod-mirror-parity.test.ts @@ -675,7 +675,7 @@ import { dirname, join } from 'node:path'; import ts from 'typescript'; import type { z } from 'zod'; -import { AppActionSchema, AppComponentSchema, MenuItemSchema as AppMenuItemSchema, NavigationAreaSchema, NavigationItemSchema } from '../zod/app.zod.js'; +import { AppComponentSchema, MenuItemSchema as AppMenuItemSchema, NavigationAreaSchema, NavigationItemSchema } from '../zod/app.zod.js'; import { BaseSchema, ComponentConfigSchema, ComponentInputSchema, ComponentMetaSchema, KeyedI18nLabelSchema, SchemaNodeSchema } from '../zod/base.zod.js'; import { CalendarEventSchema, CalendarViewSchema, CarouselItemSchema, CarouselSchema, ChatbotSchema, ChatbotEnhancedSchema, ChatbotFloatingSchema, ChatMessageSchema, ChatMessageSourceSchema, ChatToolInvocationSchema, DashboardComponentSchema, DashboardConfigSchema, DashboardWidgetConfigSchema, DashboardWidgetLayoutSchema, DashboardWidgetSchema, FilterBuilderSchema, FilterFieldSchema, KanbanCardSchema, KanbanColumnSchema, CardTemplateSchema, FilterBuilderConditionSchema, FilterGroupSchema } from '../zod/complex.zod.js'; import { ActionSchema, CRUDDialogSchema, DetailSchema } from '../zod/crud.zod.js'; @@ -690,7 +690,7 @@ import { AlertDialogSchema, ContextMenuSchema, DialogSchema, DrawerSchema, Dropd import { ReportBuilderSchema, ReportComponentSchema, ReportExportConfigSchema, ReportFieldSchema, ReportFilterSchema, ReportGroupBySchema, ReportSectionSchema, ReportViewerSchema } from '../zod/reports.zod.js'; import { DetailViewFieldSchema, DetailViewSchema, DetailViewSectionSchema, DetailViewTabSchema, FilterUISchema, SortUISchema, ViewSwitcherSchema } from '../zod/views.zod.js'; -import type { AppAction as Ts_AppAction, AppComponentSchema as Ts_AppComponentSchema, NavigationArea as Ts_NavigationArea } from '../app'; +import type { AppComponentSchema as Ts_AppComponentSchema, NavigationArea as Ts_NavigationArea } from '../app'; import type { BaseSchema as Ts_BaseSchema, ComponentConfig as Ts_ComponentConfig, ComponentInput as Ts_ComponentInput, ComponentMeta as Ts_ComponentMeta, KeyedI18nLabel as Ts_KeyedI18nLabel } from '../base'; import type { CalendarEvent as Ts_CalendarEvent, CalendarViewSchema as Ts_CalendarViewSchema, CarouselItem as Ts_CarouselItem, CarouselSchema as Ts_CarouselSchema, ChatbotSchema as Ts_ChatbotSchema, ChatbotEnhancedSchema as Ts_ChatbotEnhancedSchema, ChatbotFloatingSchema as Ts_ChatbotFloatingSchema, ChatMessage as Ts_ChatMessage, ChatMessageSource as Ts_ChatMessageSource, ChatToolInvocation as Ts_ChatToolInvocation, DashboardComponentSchema as Ts_DashboardComponentSchema, DashboardWidgetLayout as Ts_DashboardWidgetLayout, DashboardWidgetSchema as Ts_DashboardWidgetSchema, FilterBuilderSchema as Ts_FilterBuilderSchema, FilterField as Ts_FilterField, KanbanCard as Ts_KanbanCard, KanbanColumn as Ts_KanbanColumn, CardTemplate as Ts_CardTemplate } from '../complex'; import type { DashboardConfig as Ts_DashboardConfig, DashboardWidgetConfig as Ts_DashboardWidgetConfig } from '../designer'; @@ -1507,7 +1507,6 @@ export type assertionNarrowerOperatorIsBlindToAMirroredOnlyKey = /** Mirror VALUES, keyed `#`. Runtime, so the census below can read the keys. */ const MIRRORS = { - 'app.zod.ts#AppActionSchema': AppActionSchema, 'app.zod.ts#AppComponentSchema': AppComponentSchema, 'app.zod.ts#NavigationAreaSchema': NavigationAreaSchema, 'base.zod.ts#BaseSchema': BaseSchema, @@ -1671,7 +1670,6 @@ const MIRRORS = { /** The declaration each mirror restates. Same keys as `MIRRORS` — pinned below. */ interface Declared { - 'app.zod.ts#AppActionSchema': Ts_AppAction; 'app.zod.ts#AppComponentSchema': Ts_AppComponentSchema; 'app.zod.ts#NavigationAreaSchema': Ts_NavigationArea; 'base.zod.ts#BaseSchema': Ts_BaseSchema; @@ -4222,7 +4220,7 @@ const ZOD_DIR = join(dirname(fileURLToPath(import.meta.url)), '..', 'zod'); * MINUEND under it had moved. Nothing failed on any of those days, because nothing * compared the registry to a number. objectui#7433 is that absence, not the digits. */ -const EXPECTED_MIRROR_PAIRS = 160; +const EXPECTED_MIRROR_PAIRS = 159; /** * A ledger this file can size from its own AST. `WiderThanDeclared` joined at diff --git a/packages/types/src/app.ts b/packages/types/src/app.ts index f9f026e8f9..90077a3824 100644 --- a/packages/types/src/app.ts +++ b/packages/types/src/app.ts @@ -567,9 +567,30 @@ export interface AppComponentSchema extends BaseSchema, Omit>; /** - * Global Actions (User Profile, Settings, etc) + * ⛔ RETIRED — REFUSED BY NAME (objectui#7469, maintainer ruling C, ADR-0049 + * enforce-or-remove). + * + * `actions` was an objectui-only array of free-form header buttons and a user + * menu (`AppAction`, retired with it). The platform's `@objectstack/spec` + * `AppSchema` is strict and has no `actions` member, so a server-served app — + * the console's only source — could never carry one; only the standalone + * runner drew it, and its buttons declared no behaviour to run. The ruling + * keeps ONE channel for app-level actions: a {@link NavigationItem} of + * `type: 'action'` whose `actionDef.actionName` names a declared `action` + * (the console sidebar dispatches it), e.g. + * `navigation: [{ id: 'quick_create', type: 'action', label: 'Quick Create', + * actionDef: { actionName: 'quick_create' } }]`. The signed-in user's menu is + * the host's, not app metadata. + * + * A tombstone rather than a deletion: `BaseSchema`'s index signature and the + * mirror's `.passthrough()` would otherwise KEEP an authored array in silence. + * `?: never` is the twin of `zod/app.zod.ts`'s `retirementTombstone` arm; the + * pin is `__tests__/app-actions-retired-7469.test.ts`. + * + * @deprecated Not a key of this contract. Author `navigation` items of + * `type: 'action'`. */ - actions?: AppAction[]; + actions?: never; /** * Required permissions (ObjectStack Spec v2.0.1) @@ -631,29 +652,19 @@ export interface AppMenuItem { * REFUSED (objectui#7719, director seat decision batch #70 of 2026-09-07, * maintainer verbatim 「同意」, in the objectui#6124 / ADR-0049 shape). * - * `shortcut` is not authorable on an app action ITEM. The ruling refused BOTH - * widening alternatives — growing this deprecated type a `shortcut` member - * (zero measured pull, and the type is being retired in favour of - * {@link NavigationItem}), and re-typing {@link AppAction.items} to the - * overlay `MenuItem`. What it changed is the DIAGNOSTIC: an authored value - * used to be stripped in silence by the zod mirror, and is now refused by - * name there, with this face's `never` refusing it at the authoring site - * before anything runs. - * - * ⚠️ NOT the same key as {@link AppAction.shortcut}, which is declared, - * authorable and deliberately untouched — that one is the header BUTTON's own - * shortcut, one level up from these items. This file declares `shortcut` TWICE, - * on two different interfaces, and reading one as the other is how the widening - * the ruling refused looks like work already done. ⛔ Resolve which declaration - * OWNS a hit before acting on it; a `grep` reports positions, and a position is - * not an owner. (⚠️ This paragraph deliberately states no ORDER between the two: - * an ordering claim is falsified by the next insertion into this file — including - * the one that introduced this very docblock, which reversed the order a previous - * draft of this sentence asserted.) + * `shortcut` is not authorable on this legacy menu item. The ruling refused + * growing this deprecated type a `shortcut` member (zero measured pull, and + * the type is being retired in favour of {@link NavigationItem}), and no + * renderer reads a shortcut here. What it changed is the DIAGNOSTIC: an + * authored value used to be stripped in silence by the zod mirror, and is now + * refused by name there, with this face's `never` refusing it at the + * authoring site before anything runs. * - * ⛔ No read was re-added in the standalone runner's `LayoutRenderer`; the - * objectui#6854 pin stands. Both halves of this refusal are pinned in - * `__tests__/app-menu-item-shortcut-refusal-7719.test.ts`. + * The ruling was taken on the app action ITEMS (`AppAction.items`), the other + * place this type was authored; objectui#7469 retired `AppAction` and its + * `actions` array, so {@link AppComponentSchema.menu} is where an author meets + * this refusal now. ⛔ The refusal itself is unchanged by that retirement. + * Pinned in `__tests__/app-menu-item-shortcut-refusal-7719.test.ts`. * * @deprecated Not part of this contract — author the menu as a * {@link NavigationItem} and put the shortcut capability there. @@ -862,59 +873,3 @@ export function wizardDraftToAppSchema(draft: AppWizardDraft): AppComponentSchem navigation: draft.navigation, }; } - -// ============================================================================ -// Application Actions -// ============================================================================ - -/** - * Application Header/Toolbar Action - */ -export interface AppAction { - type: 'button' | 'dropdown' | 'user'; - label?: string; - icon?: string; - /** - * RETIRED (objectui#7344; the objectui#6182 ruling of 2026-08-25 — the - * handler-expression string dialect is not a supported authoring form — in - * the objectui#6124 shape). No renderer reads THIS KEY, so no value here - * could ever run. The zod twin refuses the key by name; author behaviour as a - * node type (an `action:button` node with a declared action) instead. - * - * Re-measured at objectui#6854, which corrects the narrower claim this - * comment used to make. `AppComponentSchema.actions[]` IS read — the standalone - * runner's `LayoutRenderer` (`@object-ui/runner`) renders both the `'button'` - * and the `'user'` arm — so "nothing reads `actions[]`" was never the reason - * this key is inert. The reason is that no reader touches `onClick`: not on - * the action, and no longer on {@link AppAction.items}, where that renderer - * reached one through an `as any` cast until the maintainer ruling of - * 2026-09-05 (option B2) deleted it. Guarded from the renderer side by - * `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx`. - * @deprecated Not part of this contract — the value was inert. - */ - onClick?: never; - /** - * User Avatar URL (for type='user') - */ - avatar?: string; - /** - * Additional description (e.g. email for user) - */ - description?: string; - /** - * Dropdown Menu Items (for type='dropdown' or 'user') - */ - items?: AppMenuItem[]; - /** - * Keyboard shortcut - */ - shortcut?: string; - /** - * Button variant - */ - variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; - /** - * Button size - */ - size?: 'default' | 'sm' | 'lg' | 'icon'; -} diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 48f4753cdf..323c2ff4ce 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -52,7 +52,6 @@ // ============================================================================ export type { AppComponentSchema, - AppAction, NavigationItem, NavigationItemType, NavigationArea, diff --git a/packages/types/src/zod/app.zod.ts b/packages/types/src/zod/app.zod.ts index 3020220e92..9f47138efb 100644 --- a/packages/types/src/zod/app.zod.ts +++ b/packages/types/src/zod/app.zod.ts @@ -30,7 +30,7 @@ import { objectNavTargetExclusivity, } from '@objectstack/spec/ui'; import { BaseSchema, specFieldsExcept } from './base.zod.js'; -import { handlerKeyRefusal, retirementTombstone } from './tombstone.zod.js'; +import { retirementTombstone } from './tombstone.zod.js'; import type { AppMenuItem } from '../app.js'; import { stripImportedDefaults } from './imported-defaults.js'; @@ -275,67 +275,29 @@ export const MenuItemSchema: z.ZodType = z.lazy(() => badge: z.union([z.string(), z.number()]).optional().describe('Badge or count'), hidden: z.union([z.boolean(), z.string()]).optional().describe('Visibility condition'), // REFUSED (objectui#7719, director seat decision batch #70 of 2026-09-07, - // maintainer verbatim 「同意」). The ruling refused both widening options — a - // `shortcut` member on this deprecated type, and re-typing `AppAction.items` - // to the overlay `MenuItem` — and changed the DIAGNOSTIC instead: an authored - // value used to be stripped here in silence, and is refused by name now. + // maintainer verbatim 「同意」). The ruling refused a `shortcut` member on this + // deprecated type and changed the DIAGNOSTIC instead: an authored value used + // to be stripped here in silence, and is refused by name now. It was taken on + // the app action items (`AppAction.items`); objectui#7469 retired that array, + // so `AppComponentSchema.menu` is where an author meets it now. // - // WHY `retirementTombstone` and not `handlerKeyRefusal`, which the sibling - // `AppActionSchema.onClick` uses in this same file: that helper's message - // says JSON has no function value, which is false of a string-valued key, and - // its `z.custom` primitive makes `z.toJSONSchema` THROW. Measured: this const - // is representable today and `AppActionSchema` is NOT, precisely because of - // that `onClick` arm. Nor `aliasKeyRefusal`, which would have to name a - // canonical sibling key; the remedy here is a different TYPE. - // - // ⛔ No read was re-added in the standalone runner's `LayoutRenderer`; the - // objectui#6854 pin stands. Pinned in + // WHY `retirementTombstone` and not `handlerKeyRefusal`: that helper's + // message says JSON has no function value, which is false of a string-valued + // key, and its `z.custom` primitive makes `z.toJSONSchema` THROW. Nor + // `aliasKeyRefusal`, which would have to name a canonical sibling key; the + // remedy here is a different TYPE. Pinned in // `../__tests__/app-menu-item-shortcut-refusal-7719.test.ts`. shortcut: retirementTombstone( 'REFUSED (objectui#7719, director seat decision batch #70, 2026-09-07; ADR-0049) — `shortcut` ' - + 'is not authorable on an app action ITEM. `AppAction.items` is this legacy `AppMenuItem` ' - + 'face, deprecated in favour of `NavigationItem`, and no renderer reads a shortcut here: the ' - + 'standalone runner renders an item\'s `label` and its `type: "separator"` and nothing else ' - + '(objectui#6854). Until this refusal an authored value was STRIPPED in silence by this ' - + 'mirror, which is the outcome the ruling closed; growing the deprecated type instead was ' - + 'refused. NOT to be confused with `AppAction.shortcut`, which is declared, authorable and ' - + 'unchanged — that is the header BUTTON\'s own shortcut, one level up from these items. ' - + 'A keyboard shortcut on a navigation entry is a capability of the `NavigationItem` line: ' - + 'author the menu as `NavigationItem`, and file the capability there if it is wanted.', + + 'is not authorable on a legacy `AppMenuItem` (an app\'s `menu` item), which is deprecated in ' + + 'favour of `NavigationItem`, and no renderer reads a shortcut here. Until this refusal an ' + + 'authored value was STRIPPED in silence by this mirror, which is the outcome the ruling ' + + 'closed; growing the deprecated type instead was refused. A keyboard shortcut on a ' + + 'navigation entry is a capability of the `NavigationItem` line: author the menu as ' + + '`NavigationItem`, and file the capability there if it is wanted.', ), })); -// ============================================================================ -// App Action Schema -// ============================================================================ - -/** - * App Action Schema - Application header/toolbar action - */ -export const AppActionSchema = z.object({ - type: z.enum(['button', 'dropdown', 'user']).describe('Action type'), - label: z.string().optional().describe('Action label'), - icon: z.string().optional().describe('Icon name'), - // RETIRED (objectui#7344 — the objectui#6182 ruling: the handler-expression - // string dialect is not an authoring form; the objectui#6124 shape). The - // `'retired'` arm's message tells an author "no renderer reads this key, so - // nothing could ever run it"; objectui#6854 re-measured that sentence rather - // than restating it. `actions[]` itself IS read (`@object-ui/runner`'s - // `LayoutRenderer` renders the `'button'` and `'user'` arms) — the earlier - // "nothing reads `actions[]`" here was wrong — but no reader touches - // `onClick`, on the action or on `items[]`, since the maintainer ruling of - // 2026-09-05 (option B2) deleted the two `as any` reads that did. Text pinned - // in `__tests__/app-action-onclick-refusal-6854.test.ts`; the renderer side in - // `packages/runner/src/__tests__/LayoutRenderer.appActionItems-6854.test.tsx`. - onClick: handlerKeyRefusal('onClick', 'retired', 'Click handler'), - avatar: z.string().optional().describe('User avatar URL (for type="user")'), - description: z.string().optional().describe('Additional description (e.g., email for user)'), - items: z.array(MenuItemSchema).optional().describe('Dropdown menu items (for type="dropdown" or "user")'), - shortcut: z.string().optional().describe('Keyboard shortcut'), - variant: z.enum(['default', 'destructive', 'outline', 'secondary', 'ghost', 'link']).optional().describe('Button variant'), - size: z.enum(['default', 'sm', 'lg', 'icon']).optional().describe('Button size'), -}); - // ============================================================================ // App Schema // ============================================================================ @@ -404,6 +366,28 @@ export const APP_SPEC_EXCLUDED = [ // surface and cannot drift apart again. const SpecAppFields = specFieldsExcept(stripImportedDefaults(SpecAppSchema).shape, APP_SPEC_EXCLUDED); +/** + * The `actions` REFUSAL on the `app` node (objectui#7469, maintainer ruling C, + * ADR-0049 enforce-or-remove) — the free-form header-button / user-menu array + * and its `AppActionSchema` element are retired on both faces. + * + * Kept DECLARED and unwritable rather than deleted: `BaseSchema` is + * `.passthrough()`, so a deleted arm would KEEP an authored array in silence + * instead of refusing it. The platform's strict `@objectstack/spec` `AppSchema` + * has never declared the key and refuses it too (`unrecognized_keys`), so the + * two doors now give one answer. The remedy names the ONE channel the ruling + * keeps for app-level actions — a `navigation` item of `type: 'action'` — with + * a spec-valid spelling. The TS twin is `actions?: never` in `../app.ts`; the + * pin is `../__tests__/app-actions-retired-7469.test.ts`. + */ +const APP_ACTIONS_REFUSAL = + 'RETIRED (objectui#7469, ADR-0049) — `actions` is not a key of the `app` node: the free-form ' + + 'header-button and user-menu array (`AppAction`) is retired, and the platform\'s `AppSchema` ' + + 'never declared it. App-level actions are `navigation` items of `type: \'action\'` that name a ' + + 'declared action — `navigation: [{ id: \'quick_create\', type: \'action\', label: \'Quick Create\', ' + + 'actionDef: { actionName: \'quick_create\' } }]`. The signed-in user\'s menu belongs to the host, ' + + 'not to app metadata.'; + /** * App Schema — the objectui app-shell renderer node, derived from * `@objectstack/spec/ui` `AppSchema` (see {@link SpecAppFields}). The drift @@ -421,7 +405,7 @@ export const AppComponentSchema = BaseSchema.extend(SpecAppFields.shape).extend( navigation: z.array(NavigationItemSchema).optional().describe('Unified navigation tree'), areas: z.array(NavigationAreaSchema).optional().describe('Navigation areas (business-domain partitions)'), contextSelectors: z.array(AppContextSelectorSchema).optional().describe('App-level scope dropdowns injected into nav items as {} vars'), - actions: z.array(AppActionSchema).optional().describe('Global actions (user profile, settings, etc.)'), + actions: retirementTombstone(APP_ACTIONS_REFUSAL), }); /** @@ -430,5 +414,4 @@ export const AppComponentSchema = BaseSchema.extend(SpecAppFields.shape).extend( export type NavigationItemSchemaType = z.infer; export type NavigationAreaSchemaType = z.infer; export type MenuItemSchemaType = z.infer; -export type AppActionSchemaType = z.infer; export type AppSchemaType = z.infer; diff --git a/packages/types/src/zod/index.zod.ts b/packages/types/src/zod/index.zod.ts index fc492507f5..1d86043e46 100644 --- a/packages/types/src/zod/index.zod.ts +++ b/packages/types/src/zod/index.zod.ts @@ -62,7 +62,6 @@ // ============================================================================ export { AppComponentSchema, - AppActionSchema, NavigationItemSchema, NavigationItemTypeSchema, NavigationAreaSchema, From 97749300df3ddcc235e37ae1707ba98e324221f8 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 27 Sep 2026 05:44:38 +0000 Subject: [PATCH 2/2] docs(gate): exempt the navigation `action` item kind on the app schema page The rewritten "Global Actions" section teaches a navigation item of type 'action'. That value is a NavigationItem kind, not a rendered node type, so check:doc-types declares it beside the page's existing `item` / `group` menu-kind exemptions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_014fWVhLzhxR8qrFsJ5o8TYW --- scripts/check-doc-component-types.mjs | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/scripts/check-doc-component-types.mjs b/scripts/check-doc-component-types.mjs index b0f9756f4f..350545af14 100644 --- a/scripts/check-doc-component-types.mjs +++ b/scripts/check-doc-component-types.mjs @@ -778,6 +778,11 @@ const DOC_TYPE_EXEMPTIONS = { 'content/docs/core/app-schema.mdx': { item: 'AppSchema menu entry kind — a navigation item, sibling of `group`. Not a rendered node.', group: 'AppSchema menu entry kind — a navigation group holding `children` items.', + action: + 'NavigationItem kind under an app\'s `navigation` (`NavigationItemTypeSchema`, spec ' + + '`ActionNavItemSchema`) — an entry that names a declared action by `actionDef.actionName`, ' + + 'which the console sidebar dispatches. Not a rendered node. The page teaches it as the one ' + + 'channel for app-level actions since objectui#7469 retired the app node\'s `actions` array.', }, 'content/docs/core/enhanced-actions.mdx': { action: