diff --git a/.changeset/25915-app-no-access-shared-state.md b/.changeset/25915-app-no-access-shared-state.md
new file mode 100644
index 00000000000..671c99b9011
--- /dev/null
+++ b/.changeset/25915-app-no-access-shared-state.md
@@ -0,0 +1,10 @@
+---
+'@object-ui/app-shell': patch
+---
+
+fix(app-shell): the "nothing in this app is available to you" screen is drawn by the shared `DataEmptyState`
+
+Clause-②: no (one block swaps its own markup for the shared components; no exported type change. If a shared component needs a new prop, the thread reports first and the seat amends this line)
+Metadata-protocol: none
+
+The screen an app's root shows when the app serves the caller no navigation, or names only objects the caller may not read (`AppNoAccessEmptyState`), now uses `DataEmptyState` from `@object-ui/components` instead of the `Empty` primitive with a hand-copied icon square. It is announced to assistive technology as `role="status"`. The text, the Open Setup link, the test ids and the `data-reason` attribute (now on the status node) are unchanged; the title takes the shared component's smaller size.
diff --git a/ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx b/ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx
index 9ed5c2061b5..75f26245eb3 100644
--- a/ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx
+++ b/ui/packages/app-shell/src/console/AppNoAccessEmptyState.tsx
@@ -27,10 +27,12 @@
import { Link } from 'react-router-dom';
import { Lock } from 'lucide-react';
-// The three primitives `RouteNotFound` and the app-verdict screens in
-// `AppContent.tsx` already compose, so this screen looks like its siblings and
-// adds nothing to the eager `ui-components` chunk.
-import { Button, Empty, EmptyDescription, EmptyTitle } from '@object-ui/components';
+// The shared empty state (objectstack#25915): it draws the muted icon square
+// this screen used to hand-copy, and it is `role="status"`, so "nothing here
+// for you" is announced instead of being an unlabelled block. `AppContent.tsx`
+// already reaches it through `PreviewDraftEmptyState`, so the eager graph
+// gains no new module.
+import { Button, DataEmptyState } from '@object-ui/components';
import { useObjectLabel, useObjectTranslation } from '@object-ui/i18n';
import { resolveKeyedI18nLabel } from '../utils/index.js';
@@ -56,44 +58,44 @@ export function AppNoAccessEmptyState({ app, setupPath, reason = 'no-navigation'
// The same label the header's app switcher shows for this app.
const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) });
+ const description =
+ reason === 'no-readable-object'
+ ? setupPath
+ ? t('empty.appNoReadableObjectGrantDescription', {
+ defaultValue: 'Your account cannot read any of the records in this app yet. You can grant access in Setup.',
+ })
+ : t('empty.appNoReadableObjectDescription', {
+ defaultValue:
+ 'Your account cannot read any of the records in this app yet. Ask an administrator to assign you a permission set.',
+ })
+ : setupPath
+ ? t('empty.appNothingAvailableGrantDescription', {
+ defaultValue: 'This app has no pages your account can open. You can grant access in Setup.',
+ })
+ : t('empty.appNothingAvailableDescription', {
+ defaultValue: 'This app has no pages your account can open. Ask an administrator to grant you access.',
+ });
+
return (
-
-
-
-
-
- {t('empty.appNothingAvailable', {
- app: label,
- defaultValue: 'Nothing in {{app}} is available to you yet',
- })}
-
-
- {reason === 'no-readable-object'
- ? setupPath
- ? t('empty.appNoReadableObjectGrantDescription', {
- defaultValue: 'Your account cannot read any of the records in this app yet. You can grant access in Setup.',
- })
- : t('empty.appNoReadableObjectDescription', {
- defaultValue:
- 'Your account cannot read any of the records in this app yet. Ask an administrator to assign you a permission set.',
- })
- : setupPath
- ? t('empty.appNothingAvailableGrantDescription', {
- defaultValue: 'This app has no pages your account can open. You can grant access in Setup.',
- })
- : t('empty.appNothingAvailableDescription', {
- defaultValue: 'This app has no pages your account can open. Ask an administrator to grant you access.',
- })}
-
- {setupPath && (
-
- )}
-
+ }
+ title={t('empty.appNothingAvailable', {
+ app: label,
+ defaultValue: 'Nothing in {{app}} is available to you yet',
+ })}
+ description={description}
+ action={
+ setupPath ? (
+
+ ) : undefined
+ }
+ />
);
}
diff --git a/ui/packages/app-shell/src/console/__tests__/AppNoAccessEmptyState.sharedState-25915.test.tsx b/ui/packages/app-shell/src/console/__tests__/AppNoAccessEmptyState.sharedState-25915.test.tsx
new file mode 100644
index 00000000000..6097b97a121
--- /dev/null
+++ b/ui/packages/app-shell/src/console/__tests__/AppNoAccessEmptyState.sharedState-25915.test.tsx
@@ -0,0 +1,76 @@
+/**
+ * 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 app root's "nothing here for you" screen is drawn by the shared
+ * `DataEmptyState` (objectstack#25915), so it is announced as
+ * `role="status"`. Before, it composed the bare `Empty` primitive around a
+ * hand-copied icon square, an unlabelled block to assistive technology.
+ *
+ * The routing that decides WHEN this screen shows is pinned by the
+ * AppContent landing suites; this file pins only what the screen is.
+ *
+ * The `t` mock returns each call site's `defaultValue`, which the i18n
+ * call-site gate holds byte-equal to the `en` pack value.
+ */
+
+import '@testing-library/jest-dom/vitest';
+import { describe, expect, it, vi } from 'vitest';
+import { render, screen } from '@testing-library/react';
+import type { ComponentProps } from 'react';
+import { MemoryRouter } from 'react-router-dom';
+
+vi.mock('@object-ui/i18n', async (importOriginal) => ({
+ ...(await importOriginal()),
+ useObjectTranslation: () => ({
+ t: (_k: string, o?: { defaultValue?: string }) => o?.defaultValue ?? _k,
+ }),
+ useObjectLabel: () => ({
+ appLabel: (app: { name: string; label?: string }) => app.label ?? app.name,
+ }),
+}));
+
+import { AppNoAccessEmptyState } from '../AppNoAccessEmptyState';
+
+function renderState(props: Partial> = {}) {
+ return render(
+
+
+ ,
+ );
+}
+
+describe('AppNoAccessEmptyState draws the shared empty state (objectstack#25915)', () => {
+ it('is a role="status" DataEmptyState inside the test-id wrapper', () => {
+ renderState();
+
+ const wrapper = screen.getByTestId('app-no-access-empty-state');
+ const status = screen.getByRole('status');
+ expect(wrapper).toContainElement(status);
+ expect(status).toHaveAttribute('data-slot', 'data-empty-state');
+ expect(status).toHaveTextContent('Nothing in {{app}} is available to you yet');
+ expect(status).toHaveTextContent('Ask an administrator to grant you access.');
+ });
+
+ it.each([
+ ['no-navigation', undefined],
+ ['no-readable-object', 'no-readable-object' as const],
+ ])('carries data-reason=%s on the status node', (expected, reason) => {
+ renderState(reason ? { reason } : {});
+
+ expect(screen.getByRole('status')).toHaveAttribute('data-reason', expected);
+ });
+
+ it('offers Open Setup as the action only when a setup path is given', () => {
+ renderState({ setupPath: '/setup/permissions' });
+
+ const link = screen.getByTestId('app-no-access-open-setup');
+ expect(screen.getByRole('status')).toContainElement(link);
+ expect(link).toHaveAttribute('href', '/setup/permissions');
+ });
+});