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) => (
-
-
-
-
-
-
-
-
-
-
- {/*
- * 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(
+
+