diff --git a/.changeset/cool-badgers-type.md b/.changeset/cool-badgers-type.md
new file mode 100644
index 00000000..366e12b2
--- /dev/null
+++ b/.changeset/cool-badgers-type.md
@@ -0,0 +1,19 @@
+---
+"@tailor-platform/app-shell": minor
+---
+
+Add a built-in `/__appinfo` page to `AppShell` for exposing app metadata and the current AppShell version.
+
+```tsx
+
+```
+
+The page stays out of AppShell's built-in auto-generated sidebar navigation, appears in the Command Palette as a page entry, and includes a copy button for the rendered app information.
diff --git a/packages/core/src/components/app-info.test.tsx b/packages/core/src/components/app-info.test.tsx
new file mode 100644
index 00000000..67fcb053
--- /dev/null
+++ b/packages/core/src/components/app-info.test.tsx
@@ -0,0 +1,92 @@
+import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
+import { afterEach, describe, expect, it, vi } from "vitest";
+import packageJson from "../../package.json";
+import { BuiltInCommandPalette } from "@/components/command-palette";
+import {
+ AppShellConfigContext,
+ AppShellDataContext,
+ type RootConfiguration,
+} from "@/contexts/appshell-context";
+import { BreadcrumbOverrideProvider } from "@/contexts/breadcrumb-context";
+import { CommandPaletteProvider } from "@/contexts/command-palette-context";
+import { RouterContainer } from "@/routing/router";
+import { Outlet } from "react-router";
+
+afterEach(() => {
+ cleanup();
+ window.history.replaceState({}, "", "/");
+});
+
+const configurations: RootConfiguration = {
+ modules: [],
+ settingsResources: [],
+ locale: "ja",
+ errorBoundary: undefined,
+};
+
+const renderAppShell = (initialEntries: string[]) =>
+ render(
+
+
+
+
+
+
+
+
+
+
+
+ ,
+ );
+
+describe("App info", () => {
+ it("opens /__appinfo from the command palette and renders built-in + app-defined rows", async () => {
+ const writeText = vi.fn().mockResolvedValue(undefined);
+ Object.defineProperty(navigator, "clipboard", {
+ configurable: true,
+ value: { writeText },
+ });
+
+ renderAppShell(["/"]);
+
+ fireEvent.keyDown(document, { key: "k", metaKey: true });
+
+ await screen.findByPlaceholderText("ページを検索...");
+ fireEvent.click(screen.getByText("/__appinfo").closest("button")!);
+
+ await screen.findAllByText("アプリ情報");
+ expect(screen.getByText("受発注システム")).toBeDefined();
+ expect(screen.getByText("staging")).toBeDefined();
+ expect(screen.getByText("2026.07.16")).toBeDefined();
+ expect(screen.getByText(packageJson.version)).toBeDefined();
+
+ fireEvent.click(screen.getByRole("button", { name: "コピー" }));
+
+ await waitFor(() => {
+ expect(writeText).toHaveBeenCalledWith(
+ [
+ "アプリ名: 受発注システム",
+ `AppShell バージョン: ${packageJson.version}`,
+ "環境: staging",
+ "リリース: 2026.07.16",
+ ].join("\n"),
+ );
+ });
+
+ await waitFor(() => {
+ expect(document.title).toBe("アプリ情報 · 受発注システム");
+ });
+ });
+});
diff --git a/packages/core/src/components/app-info.tsx b/packages/core/src/components/app-info.tsx
new file mode 100644
index 00000000..560ba38e
--- /dev/null
+++ b/packages/core/src/components/app-info.tsx
@@ -0,0 +1,83 @@
+import packageJson from "../../package.json";
+import { Button } from "@/components/button";
+import { Card } from "@/components/card";
+import { useAppShellConfig, type AppInfoEntry } from "@/contexts/appshell-context";
+import { useOverrideBreadcrumb } from "@/hooks/use-override-breadcrumb";
+import { useT } from "@/i18n-labels";
+import type { NavigatableRoute } from "@/routing/path";
+
+export const APP_INFO_SLUG = "__appinfo";
+export const APP_INFO_PATH = `/${APP_INFO_SLUG}`;
+
+const APP_SHELL_VERSION = packageJson.version;
+
+const renderValue = (value: AppInfoEntry["value"]) => {
+ if (value === null || value === undefined) return "-";
+ return String(value);
+};
+
+const formatRowsForClipboard = (rows: AppInfoEntry[]) =>
+ rows.map((row) => `${row.label}: ${renderValue(row.value)}`).join("\n");
+
+const AppInfoRow = ({ label, value }: AppInfoEntry) => (
+
+
{label}
+ {renderValue(value)}
+
+);
+
+export const useAppInfoPageRoute = (): NavigatableRoute => {
+ const t = useT();
+
+ return {
+ path: APP_INFO_PATH,
+ title: t("appInfoTitle"),
+ breadcrumb: [t("appInfoTitle")],
+ };
+};
+
+export const AppInfoPage = () => {
+ const t = useT();
+ const { title, appInfo } = useAppShellConfig();
+
+ useOverrideBreadcrumb(t("appInfoTitle"));
+
+ const rows: AppInfoEntry[] = [
+ ...(title ? [{ label: t("appInfoAppName"), value: title }] : []),
+ { label: t("appInfoAppShellVersion"), value: APP_SHELL_VERSION },
+ ...(appInfo?.metadata ?? []),
+ ];
+
+ const handleCopy = async () => {
+ try {
+ await navigator.clipboard.writeText(formatRowsForClipboard(rows));
+ } catch {
+ // Silently fail if clipboard access is denied.
+ }
+ };
+
+ return (
+
+
+
+
{t("appInfoTitle")}
+
+
+
+
+ {rows.map((row, index) => (
+ 0 ? "astw:border-t astw:border-border" : undefined}
+ >
+
+
+ ))}
+
+
+
+
+ );
+};
diff --git a/packages/core/src/components/appshell.tsx b/packages/core/src/components/appshell.tsx
index 7f62e4ba..d2bc214a 100644
--- a/packages/core/src/components/appshell.tsx
+++ b/packages/core/src/components/appshell.tsx
@@ -14,6 +14,7 @@ import {
AppShellConfigContext,
AppShellDataContext,
buildConfigurations,
+ type AppInfo,
type ContextData,
} from "@/contexts/appshell-context";
import { RouterContainer } from "@/routing/router";
@@ -60,6 +61,14 @@ type SharedAppShellProps = React.PropsWithChildren<{
*/
favicon?: string;
+ /**
+ * Additional application metadata shown on the built-in `/__appinfo` page.
+ *
+ * AppShell always includes its own version there; use this prop to append
+ * app-defined rows such as environment, release, or commit.
+ */
+ appInfo?: AppInfo;
+
/**
* Base path for the app shell
*/
@@ -327,9 +336,15 @@ export const AppShell = (props: AppShellProps) => {
const configValue = useMemo(
() =>
configurations
- ? { title: props.title, icon: props.icon, favicon: props.favicon, configurations }
+ ? {
+ title: props.title,
+ icon: props.icon,
+ favicon: props.favicon,
+ appInfo: props.appInfo,
+ configurations,
+ }
: null,
- [props.title, props.icon, props.favicon, configurations],
+ [props.title, props.icon, props.favicon, props.appInfo, configurations],
);
const dataValue = useMemo(
diff --git a/packages/core/src/components/command-palette.tsx b/packages/core/src/components/command-palette.tsx
index a7930cbd..94005e01 100644
--- a/packages/core/src/components/command-palette.tsx
+++ b/packages/core/src/components/command-palette.tsx
@@ -1,5 +1,6 @@
import { useReducer, useEffect, useMemo, useCallback, useRef, Suspense } from "react";
import { useNavigate, Await } from "react-router";
+import { useAppInfoPageRoute } from "@/components/app-info";
import { SearchIcon, LoaderCircleIcon } from "lucide-react";
import { Dialog } from "@/components/dialog";
import { Input } from "@/components/input";
@@ -500,13 +501,17 @@ export function useCommandPalette({
type CommandPaletteContentProps = {
navItems: Array;
+ extraRoutes?: Array;
};
-export function CommandPaletteContent({ navItems }: CommandPaletteContentProps) {
+export function CommandPaletteContent({ navItems, extraRoutes = [] }: CommandPaletteContentProps) {
const t = useT();
const contextualActions = useCommandPaletteActions();
const { searchSources, open, setOpen, openRequest, clearOpenRequest } = useCommandPaletteState();
- const routes = useMemo(() => navItemsToRoutes(navItems), [navItems]);
+ const routes = useMemo(
+ () => [...navItemsToRoutes(navItems), ...extraRoutes],
+ [extraRoutes, navItems],
+ );
const {
open: paletteOpen,
handleOpenChange,
@@ -748,11 +753,12 @@ export function CommandPalette(): React.ReactNode {
*/
export function BuiltInCommandPalette() {
const navItems = useNavItems();
+ const appInfoRoute = useAppInfoPageRoute();
return (
- {(items) => }
+ {(items) => }
);
diff --git a/packages/core/src/contexts/appshell-context.tsx b/packages/core/src/contexts/appshell-context.tsx
index a89777e4..e1141539 100644
--- a/packages/core/src/contexts/appshell-context.tsx
+++ b/packages/core/src/contexts/appshell-context.tsx
@@ -71,6 +71,18 @@ export type RootConfiguration = {
timeZone?: string;
};
+export type AppInfoEntry = {
+ /** Display label shown on the built-in "/__appinfo" page. */
+ label: string;
+ /** Primitive value rendered as text. Format complex values before passing them in. */
+ value: string | number | boolean | null | undefined;
+};
+
+export type AppInfo = {
+ /** Additional app-defined rows shown on the built-in "/__appinfo" page. */
+ metadata?: readonly AppInfoEntry[];
+};
+
export type ConfigurationOptions = {
modules: Modules;
settingsResources?: Resource[];
@@ -105,6 +117,7 @@ type AppShellConfigContextType = {
title?: string;
icon?: ReactNode;
favicon?: string;
+ appInfo?: AppInfo;
configurations: RootConfiguration;
};
diff --git a/packages/core/src/i18n-labels.ts b/packages/core/src/i18n-labels.ts
index 9cf390de..f26e677f 100644
--- a/packages/core/src/i18n-labels.ts
+++ b/packages/core/src/i18n-labels.ts
@@ -25,6 +25,11 @@ export const labels = defineI18nLabels({
commandPalettePages: "Pages",
commandPaletteSearchModes: "Search Modes",
commandPaletteSearching: "Searching...",
+ appInfoTitle: "App info",
+ appInfoDescription: "Information about the current app and AppShell.",
+ appInfoAppName: "App name",
+ appInfoAppShellVersion: "AppShell version",
+ appInfoCopy: "Copy",
},
ja: {
home: "ホーム",
@@ -50,6 +55,11 @@ export const labels = defineI18nLabels({
commandPalettePages: "ページ",
commandPaletteSearchModes: "検索モード",
commandPaletteSearching: "検索中...",
+ appInfoTitle: "アプリ情報",
+ appInfoDescription: "現在のアプリと AppShell の情報です。",
+ appInfoAppName: "アプリ名",
+ appInfoAppShellVersion: "AppShell バージョン",
+ appInfoCopy: "コピー",
},
});
diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts
index 1030c1dd..65b43784 100644
--- a/packages/core/src/index.ts
+++ b/packages/core/src/index.ts
@@ -31,6 +31,8 @@ export {
useAppShellData,
useResolvedLocale,
useTimeZone,
+ type AppInfo,
+ type AppInfoEntry,
type TimeZone,
} from "./contexts/appshell-context";
export { useAppShellScrollContainer } from "./contexts/scroll-container-context";
diff --git a/packages/core/src/routing/routes.test.tsx b/packages/core/src/routing/routes.test.tsx
index 92376a45..796fc922 100644
--- a/packages/core/src/routing/routes.test.tsx
+++ b/packages/core/src/routing/routes.test.tsx
@@ -133,14 +133,14 @@ describe("createContentRoutes", () => {
settingsResources: [settingsResource],
});
- const indexSettingsRoute = routes[2];
- expect(indexSettingsRoute.path).toBe("settings");
- expect(indexSettingsRoute.index).toBe(true);
+ const indexSettingsRoute = routes.find((route) => route.path === "settings" && route.index);
+ expect(indexSettingsRoute?.path).toBe("settings");
+ expect(indexSettingsRoute?.index).toBe(true);
- const settingsWrapperRoute = routes[3];
- expect(settingsWrapperRoute.Component).toBe(SettingsWrapper);
+ const settingsWrapperRoute = routes.find((route) => route.Component === SettingsWrapper);
+ expect(settingsWrapperRoute?.Component).toBe(SettingsWrapper);
- const childRoute = settingsWrapperRoute.children?.[0];
+ const childRoute = settingsWrapperRoute?.children?.[0];
expect(childRoute?.path).toBe("profile");
expect(typeof childRoute?.ErrorBoundary).toBe("function");
});
diff --git a/packages/core/src/routing/routes.tsx b/packages/core/src/routing/routes.tsx
index b20133e4..820408d4 100644
--- a/packages/core/src/routing/routes.tsx
+++ b/packages/core/src/routing/routes.tsx
@@ -1,5 +1,6 @@
import type { ComponentType, ReactNode } from "react";
import { RouteObject, Navigate } from "react-router";
+import { AppInfoPage, APP_INFO_SLUG } from "@/components/app-info";
import { EmptyOutlet, SettingsWrapper } from "@/components/content";
import { DefaultErrorBoundary } from "@/components/default-error-boundary";
import {
@@ -144,6 +145,10 @@ export const createContentRoutes = ({
{
children: routesFromModules(modules),
},
+ {
+ path: APP_INFO_SLUG,
+ Component: AppInfoPage,
+ },
...settingsRoutes,
{
path: "*",
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index cc6fde32..f3eed0b6 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -7,6 +7,7 @@
"lib": ["ES2023", "DOM"],
"module": "ESNext",
"moduleResolution": "Bundler",
+ "resolveJsonModule": true,
"useDefineForClassFields": true,
"skipLibCheck": true,
"declaration": true,