From 4af52f80a69ae4a6df18c39aece7574f7bf5b738 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:14:31 +0000 Subject: [PATCH 1/2] fix(app-shell): resolve the Home item-type label through one shared helper MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The rail (`HomeRail.HomeContinue`), `RecentApps` and `StarredApps` all render a label for the same item kind through the `home.recentApps.itemType.*` key namespace, and all three spelled the lookup themselves. They drifted: the two card surfaces fell back to `capitalizeFirst(type)` while the rail fell back to the bare `type`, so a kind with no key rendered `Report` on the cards and `report` in the rail — on the same screen. Converge on the capitalising fallback the two card surfaces already used, and remove the duplication that allowed the drift: one `recentItemTypeLabel` helper now owns the key spelling and the fallback for all three call sites. The pin asserts AGREEMENT between the three surfaces for a kind with no translation key, because the defect is disagreement between consumers rather than any one component's output. It uses a synthetic kind deliberately: every member of the union today has a key, so a fixture built on a real member takes the keyed path and never reaches the fallback at all. --- .../app-shell/src/console/home/HomeRail.tsx | 3 +- .../app-shell/src/console/home/RecentApps.tsx | 6 +- .../src/console/home/StarredApps.tsx | 10 +- .../HomeItemTypeLabel.parity.test.tsx | 170 ++++++++++++++++++ .../src/console/home/recentItemTypeLabel.ts | 42 +++++ 5 files changed, 220 insertions(+), 11 deletions(-) create mode 100644 packages/app-shell/src/console/home/__tests__/HomeItemTypeLabel.parity.test.tsx create mode 100644 packages/app-shell/src/console/home/recentItemTypeLabel.ts 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) }); +} From 2510207aa4c8245cc504497796a937b22b74f614 Mon Sep 17 00:00:00 2001 From: Claude Date: Tue, 25 Aug 2026 13:30:06 +0000 Subject: [PATCH 2/2] chore(changeset): declare the Home item-type label parity fix --- .changeset/6165-home-item-type-label-parity.md | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) create mode 100644 .changeset/6165-home-item-type-label-parity.md 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.