diff --git a/.changeset/6165-home-item-type-label-parity.md b/.changeset/6165-home-item-type-label-parity.md
new file mode 100644
index 0000000000..ea059e0160
--- /dev/null
+++ b/.changeset/6165-home-item-type-label-parity.md
@@ -0,0 +1,17 @@
+---
+'@object-ui/app-shell': patch
+---
+
+Home renders one agreed label for an item kind that has no translation key.
+
+The rail (`HomeContinue`), `RecentApps` and `StarredApps` all resolve the same
+`home.recentApps.itemType.*` label, and each spelled the lookup itself. They had
+drifted: the rail fell back to the bare kind (`report`) where both card surfaces
+fell back to the capitalised one (`Report`) — two spellings of the same word on
+one screen. All three now resolve through a single `recentItemTypeLabel` helper,
+so the fallback cannot drift apart again.
+
+User-visible: the rail's label for an unkeyed kind changes from `report` to
+`Report`. Every kind shipping today carries a key, so no label changes for them;
+this is about the next kind added, and any host passing a kind the locales do
+not carry.
diff --git a/packages/app-shell/src/console/home/HomeRail.tsx b/packages/app-shell/src/console/home/HomeRail.tsx
index 70120c1abd..18c1e559ca 100644
--- a/packages/app-shell/src/console/home/HomeRail.tsx
+++ b/packages/app-shell/src/console/home/HomeRail.tsx
@@ -20,6 +20,7 @@ import { useObjectTranslation } from '@object-ui/i18n';
import type { ActivityItem } from '../../layout/ActivityFeed.js';
import type { HomeInboxStatus, HomeNotification } from '../../hooks/useHomeInbox.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';
+import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import { timeAgo } from '../../utils/relativeTime.js';
type TFn = (key: string, opts?: any) => string;
@@ -248,7 +249,7 @@ export function HomeContinue({ items, onOpen, t }: { items: RecentItem[]; onOpen
icon={RECENT_ICON[it.type] || FileText}
iconClass={RECENT_TONE[it.type] || 'bg-muted text-muted-foreground'}
label={it.label}
- meta={t(`home.recentApps.itemType.${it.type}`, { defaultValue: it.type })}
+ meta={recentItemTypeLabel(t, it.type)}
onClick={() => onOpen(it.href)}
/>
))}
diff --git a/packages/app-shell/src/console/home/RecentApps.tsx b/packages/app-shell/src/console/home/RecentApps.tsx
index 5055dba9f8..ba64b6d290 100644
--- a/packages/app-shell/src/console/home/RecentApps.tsx
+++ b/packages/app-shell/src/console/home/RecentApps.tsx
@@ -10,7 +10,7 @@ import { useNavigate } from 'react-router-dom';
import { useObjectTranslation } from '@object-ui/i18n';
import { Card, CardContent, cn } from '@object-ui/components';
import { Clock, ArrowUpRight, Database, FileText, LayoutDashboard, File } from 'lucide-react';
-import { capitalizeFirst } from '../../utils/index.js';
+import { recentItemTypeLabel } from './recentItemTypeLabel.js';
import type { RecentItem } from '../../hooks/useRecentItems.js';
interface RecentAppsProps {
@@ -52,9 +52,7 @@ export function RecentApps({ items }: RecentAppsProps) {
{items.map((item) => {
const Icon = TYPE_ICONS[item.type] || Database;
- const typeLabel = t(`home.recentApps.itemType.${item.type}`, {
- defaultValue: capitalizeFirst(item.type),
- });
+ const typeLabel = recentItemTypeLabel(t, item.type);
const tone = TYPE_TONES[item.type] || TYPE_TONES.object;
return (
({
+ useNavigate: () => vi.fn(),
+}));
+
+/**
+ * One translator for all three surfaces.
+ *
+ * The keyed entry is spelled in zh (verbatim from
+ * `packages/i18n/src/locales/zh.ts`) on purpose: `capitalizeFirst` could
+ * never produce `报表`, so the keyed control below cannot pass by accidentally
+ * running the fallback. Anything not in this map misses, exactly as i18next
+ * misses an absent key, and the call site's `defaultValue` decides.
+ */
+vi.mock('@object-ui/i18n', async (importOriginal) => {
+ const KEYED: Record = {
+ 'home.recentApps.itemType.report': '报表',
+ };
+ return {
+ ...(await importOriginal>()),
+ useObjectTranslation: () => ({
+ t: (key: string, options?: Record) =>
+ KEYED[key] ?? String(options?.defaultValue ?? key),
+ language: 'zh',
+ }),
+ };
+});
+
+import { useObjectTranslation } from '@object-ui/i18n';
+import { HomeContinue } from '../HomeRail.js';
+import { RecentApps } from '../RecentApps.js';
+import { StarredApps } from '../StarredApps.js';
+
+/** A kind with NO `home.recentApps.itemType.*` key. */
+const UNKEYED_KIND = 'playbook';
+/** A kind WITH a key — the control that must stay untouched. */
+const KEYED_KIND = 'report';
+
+const ITEM_LABEL = 'Quarterly revenue';
+
+/**
+ * The rail takes `t` as a prop while the cards pull it from the hook. Reading
+ * it from the same hook here is what makes this a parity test rather than two
+ * unrelated renders: all three surfaces are driven by one translator.
+ */
+function Rail({ items }: { items: RecentItem[] }) {
+ const { t } = useObjectTranslation();
+ return {}} t={t} />;
+}
+
+/**
+ * Every surface renders the item label followed by the type label inside the
+ * item node, so the type label is what remains once the known label is
+ * removed. The two `expect`s are instrument checks — they fail loudly if a
+ * surface stops rendering in that shape, instead of silently returning `''`
+ * and making a comparison of two empty strings look like agreement.
+ */
+function typeLabelOf(node: HTMLElement, itemLabel: string): string {
+ const text = (node.textContent ?? '').trim();
+ expect(text.startsWith(itemLabel)).toBe(true);
+ const typeLabel = text.slice(itemLabel.length).trim();
+ expect(typeLabel).not.toBe('');
+ return typeLabel;
+}
+
+function renderAllThree(kind: string) {
+ const recent: RecentItem[] = [
+ {
+ id: 'r1',
+ label: ITEM_LABEL,
+ href: '/x',
+ type: kind as RecentItem['type'],
+ visitedAt: new Date().toISOString(),
+ },
+ ];
+ const favorites: FavoriteItem[] = [
+ {
+ id: 'f1',
+ label: ITEM_LABEL,
+ href: '/x',
+ type: kind as FavoriteItem['type'],
+ favoritedAt: new Date().toISOString(),
+ },
+ ];
+
+ render(
+ <>
+
+
+
+
+
+ >,
+ );
+
+ return {
+ rail: typeLabelOf(within(screen.getByTestId('rail-surface')).getByRole('button'), ITEM_LABEL),
+ recentCard: typeLabelOf(screen.getByTestId('recent-item-r1'), ITEM_LABEL),
+ starredCard: typeLabelOf(screen.getByTestId('starred-item-f1'), ITEM_LABEL),
+ };
+}
+
+describe('Home item-type label parity across the three consumers', () => {
+ it('fixture guard: the two candidate spellings differ for the unkeyed kind', () => {
+ // Without this, a single-character or already-capitalized kind would make
+ // both spellings identical and the pin below would agree trivially —
+ // passing while proving nothing.
+ expect(UNKEYED_KIND.length).toBeGreaterThan(1);
+ expect(capitalizeFirst(UNKEYED_KIND)).not.toBe(UNKEYED_KIND);
+ });
+
+ it('THE PIN: a kind with no translation key renders one identical label on all three surfaces', () => {
+ const { rail, recentCard, starredCard } = renderAllThree(UNKEYED_KIND);
+
+ // Agreement is the assertion. Before objectui#6165 the rail answered
+ // 'playbook' here while both cards answered 'Playbook'.
+ expect(rail).toBe(recentCard);
+ expect(rail).toBe(starredCard);
+
+ // And the agreed spelling is the readable one the two cards already used
+ // — pinning the DIRECTION of the convergence, so a future "make them all
+ // agree" that converges on the bare lowercase fails here too.
+ expect(rail).toBe(capitalizeFirst(UNKEYED_KIND));
+ });
+
+ it('control: a kind WITH a translation key still resolves through the key on all three surfaces', () => {
+ // Passes before and after the fix by design: the change is bounded to the
+ // fallback, and this is what proves it. `报表` is unreachable from the
+ // fallback, so a green here means the keyed path genuinely ran.
+ const { rail, recentCard, starredCard } = renderAllThree(KEYED_KIND);
+
+ expect(rail).toBe('报表');
+ expect(recentCard).toBe('报表');
+ expect(starredCard).toBe('报表');
+ });
+});
diff --git a/packages/app-shell/src/console/home/recentItemTypeLabel.ts b/packages/app-shell/src/console/home/recentItemTypeLabel.ts
new file mode 100644
index 0000000000..ae5273c0dc
--- /dev/null
+++ b/packages/app-shell/src/console/home/recentItemTypeLabel.ts
@@ -0,0 +1,42 @@
+// Copyright (c) 2025 ObjectStack. Licensed under the Apache-2.0 license.
+
+/**
+ * recentItemTypeLabel
+ *
+ * The ONE place the `home.recentApps.itemType.*` label is resolved.
+ *
+ * Three Home surfaces render a label for the same item kind — the rail
+ * (`HomeRail.HomeContinue`), the Recently-Accessed cards (`RecentApps`) and
+ * the Starred cards (`StarredApps`). Each used to spell the lookup itself,
+ * and they drifted: the two card surfaces fell back to
+ * `capitalizeFirst(type)` while the rail fell back to the bare `type`, so any
+ * kind without a translation key rendered as `Report` on the cards and
+ * `report` in the rail — on the same screen (objectui#6165).
+ *
+ * The duplication is what allowed the drift, so the fix removes the
+ * duplication rather than only re-spelling the odd one out. A fourth surface
+ * gets the agreed behaviour by construction.
+ *
+ * ⚠️ `type` is deliberately `string`, not a union. The three call sites do
+ * NOT share one union: the rail and `RecentApps` take `RecentItem['type']`
+ * (`… | 'metadata'`) while `StarredApps` takes `FavoriteItem['type']`
+ * (`… | 'nav'`). They share the KEY NAMESPACE, not the type — narrowing this
+ * parameter to either union would reject a legitimate caller.
+ *
+ * @module
+ */
+
+import { capitalizeFirst } from '../../utils/index.js';
+
+type TFn = (key: string, opts?: any) => string;
+
+/**
+ * Resolve the display label for a Home item kind.
+ *
+ * Falls back to the capitalized kind (`report` -> `Report`) when the locale
+ * carries no `home.recentApps.itemType.` key, matching what the card
+ * surfaces have always rendered.
+ */
+export function recentItemTypeLabel(t: TFn, type: string): string {
+ return t(`home.recentApps.itemType.${type}`, { defaultValue: capitalizeFirst(type) });
+}