From 23f55767afeeec05e1537fe348c05b7d73db5d28 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 16 Jul 2026 14:56:58 +0900 Subject: [PATCH 1/3] feat(core): add built-in app info page --- .changeset/cool-badgers-type.md | 19 ++++ .../core/src/components/app-info.test.tsx | 92 +++++++++++++++++++ packages/core/src/components/app-info.tsx | 83 +++++++++++++++++ packages/core/src/components/appshell.tsx | 19 +++- .../core/src/components/command-palette.tsx | 12 ++- .../core/src/contexts/appshell-context.tsx | 13 +++ packages/core/src/i18n-labels.ts | 10 ++ packages/core/src/index.ts | 2 + packages/core/src/routing/routes.tsx | 5 + packages/core/tsconfig.json | 1 + 10 files changed, 251 insertions(+), 5 deletions(-) create mode 100644 .changeset/cool-badgers-type.md create mode 100644 packages/core/src/components/app-info.test.tsx create mode 100644 packages/core/src/components/app-info.tsx diff --git a/.changeset/cool-badgers-type.md b/.changeset/cool-badgers-type.md new file mode 100644 index 00000000..22642b30 --- /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 the sidebar, 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.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, From 89cd019d7e9ab34fea76af22f58cb400bf2df965 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 16 Jul 2026 15:00:18 +0900 Subject: [PATCH 2/3] docs(changeset): clarify app info sidebar behavior --- .changeset/cool-badgers-type.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.changeset/cool-badgers-type.md b/.changeset/cool-badgers-type.md index 22642b30..366e12b2 100644 --- a/.changeset/cool-badgers-type.md +++ b/.changeset/cool-badgers-type.md @@ -16,4 +16,4 @@ Add a built-in `/__appinfo` page to `AppShell` for exposing app metadata and the /> ``` -The page stays out of the sidebar, appears in the Command Palette as a page entry, and includes a copy button for the rendered app information. +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. From 8bf98201d5151342e158a5ff7d48afeb5ee26bd6 Mon Sep 17 00:00:00 2001 From: IzumiSy Date: Thu, 16 Jul 2026 15:05:42 +0900 Subject: [PATCH 3/3] test(core): make settings route assertion order-independent --- packages/core/src/routing/routes.test.tsx | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) 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"); });