From e305bdbb1f7265ac1855e932dd354343652b9ba3 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:12:09 +0000 Subject: [PATCH 1/5] feat(app-shell): the product mark stays in the app bar as the home button; the app menu gains Back to home, All apps and Settings Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- packages/app-shell/src/layout/AppHeader.tsx | 152 ++++++++++++++---- packages/app-shell/src/layout/AppSwitcher.tsx | 105 ++++++++++-- packages/i18n/src/locales/ar.ts | 4 + packages/i18n/src/locales/de.ts | 4 + packages/i18n/src/locales/en.ts | 4 + packages/i18n/src/locales/es.ts | 4 + packages/i18n/src/locales/fr.ts | 4 + packages/i18n/src/locales/ja.ts | 4 + packages/i18n/src/locales/ko.ts | 4 + packages/i18n/src/locales/pt.ts | 4 + packages/i18n/src/locales/ru.ts | 4 + packages/i18n/src/locales/zh.ts | 4 + 12 files changed, 246 insertions(+), 51 deletions(-) diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index a9f24b57b2..1ac3d2e21e 100644 --- a/packages/app-shell/src/layout/AppHeader.tsx +++ b/packages/app-shell/src/layout/AppHeader.tsx @@ -7,12 +7,13 @@ * (inside an app, from `lg` up: the search is centered) * * Variants: - * - `app` (default when `appName` is present): sidebar trigger + AppSwitcher - * + the platform-stage chip. Used by `ConsoleLayout` inside - * `/apps/:appName/*`. The bar starts with the app pill — the - * app's glyph and name — and carries no platform mark: one brand - * mark per screen, and inside an app that mark is the app's - * (objectui#12201). An object route (its list, a view, a record) + * - `app` (default when `appName` is present): sidebar trigger + the + * product mark + a `/` rule + AppSwitcher + the platform-stage + * chip. Used by `ConsoleLayout` inside `/apps/:appName/*`. The + * product mark stays in the bar as the way home, the same mark + * the home page draws, and `⌘⇧H` / `Ctrl+Shift+H` goes home too + * (objectui#12605, which reverses objectui#12201's "no platform + * mark inside an app"). An object route (its list, a view, a record) * draws no crumb after the pill: the page header is the one place * that page is named (objectui#12461). A dashboard or page route * draws none either (objectui#12526). A report or `system/*` @@ -101,6 +102,8 @@ import { resolveViewId } from '../utils/resolveViewId.js'; import { getProductName, getLogoUrl } from '../runtime-config.js'; import { LocalizedSidebarTrigger } from './LocalizedSidebarTrigger.js'; import { PreviewBadge } from './PreviewBadge.js'; +import { GO_HOME_SHORTCUT } from './AppSwitcher.js'; +import { useAdvertiseShortcut, type ShortcutChord } from '../chrome/advertisedShortcuts.js'; function humanizeSlug(slug: string): string { return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); @@ -136,6 +139,87 @@ function PathSep() { ); } +/** + * The product mark — the way home (objectui#12605). One element for every + * surface that draws it: the home page, the organizations and Studio front + * doors, and inside every app after the sidebar trigger. A 32 px target + * holding a 24 px tile on `--primary` with the stack glyph, or the + * deployment's logo. Named 「PRODUCT home」; `title` keeps the product's name. + * + * `homePath`, not a literal `/home` (objectui#7256): on a deployment that + * DECLARES a landing this is the declared one, so the mark and the post-login + * landing name the same screen. + */ +function ProductHomeMark({ homePath, label }: { homePath: string; label: string }) { + const productName = getProductName(); + const logoUrl = getLogoUrl(); + return ( + + + {logoUrl ? ( + + ) : ( + + )} + + + ); +} + +/** + * Whether a keydown is `GO_HOME_SHORTCUT`'s chord: ⌘ or Ctrl, with Shift, no + * Alt. Exported for the pin that fires the advertised chord. + */ +export function matchesShortcutChord( + e: Pick, + chord: ShortcutChord, +): boolean { + if (e.altKey) return false; + if (Boolean(chord.mod) !== (e.metaKey || e.ctrlKey)) return false; + if (Boolean(chord.shift) !== e.shiftKey) return false; + return e.key.toLowerCase() === chord.key; +} + +/** + * A keydown aimed at something the user types into: a text field, a select, + * a content-editable region (rich-text and code editors), or an ARIA textbox. + * The home chord leaves those alone, so an editor that binds the same keys + * keeps them (objectui#12605). + */ +function isTypingTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + if (target instanceof HTMLElement && target.isContentEditable) return true; + return target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null; +} + +/** + * `⌘⇧H` / `Ctrl+Shift+H` goes home from any app page, as a PUSH so the + * browser's Back returns to the page (objectui#12605). Advertised beside the + * listener, under the same gate, so the keyboard-shortcuts dialog lists it + * exactly where it fires (objectui#11674). + */ +function useGoHomeShortcut(enabled: boolean, homePath: string): void { + const navigate = useNavigate(); + useEffect(() => { + if (!enabled) return undefined; + const onKeyDown = (e: KeyboardEvent) => { + if (e.defaultPrevented || e.isComposing) return; + if (!matchesShortcutChord(e, GO_HOME_SHORTCUT.chord)) return; + if (isTypingTarget(e.target)) return; + e.preventDefault(); + navigate(homePath); + }; + document.addEventListener('keydown', onKeyDown); + return () => document.removeEventListener('keydown', onKeyDown); + }, [enabled, homePath, navigate]); + useAdvertiseShortcut(GO_HOME_SHORTCUT, enabled); +} // No fake fallback presence — render nothing when the API has no data so the // header doesn't ship phantom collaborators in production. @@ -173,6 +257,7 @@ export function AppHeader({ // objectui#7256 — the brand logo's target follows the product's DECLARED // landing, so the chrome cannot offer a second, contradicting "home". const homePath = useHomePath(); + useGoHomeShortcut(isApp, homePath); const { isOnline } = useOffline(); // Idempotent, direct open of the ⌘K command palette (ADR-0054 C1). Replaces a // synthetic `⌘K` KeyboardEvent re-dispatch that did nothing under automation. @@ -525,27 +610,25 @@ export function AppHeader({ )} - {/* Platform logo — links to home. Platform surfaces only (Home, - organizations, the Studio front door): inside an app the bar - starts with the app pill instead, and the way back to Home is the - pill's 「All apps」 row (objectui#12201 items 4 and 6) — on a phone, - the drawer header's 「All apps」 button. - - `homePath`, not a literal `/home` (objectui#7256): on a deployment - that DECLARES a landing this is the declared one, so the logo and - the post-login landing name the same screen. */} - {!isApp && ( - - {getLogoUrl() ? ( - {getProductName()} - ) : ( - - )} - + {/* The product mark — links to home, on every surface: the home + page, the organizations and Studio front doors, and inside every + app, after the sidebar trigger (objectui#12605). Inside an app a + `/` rule follows it, so the bar reads 「PRODUCT / APP」. The rule + is drawn, not typed: it carries no text, and the `/` separators + the crumbs after the pill use stay theirs alone. */} + + {isApp && ( + )} {resolvedVariant === 'home' && ( @@ -591,16 +674,15 @@ export function AppHeader({ {isApp && ( <> - {/* App pill — the app's glyph and name, and its switcher. It - starts the path, so no separator precedes it. Desktop/tablet - only: on mobile the drawer header names the active app, so a - second app pill in the topbar is pure noise. The dropdown's - Studio row takes the Studio button's own target, so the two - cannot disagree (objectui#12201 item 6). `min-w-0` here and - on the pill's button lets a long app name truncate in the + {/* App pill — the app's glyph and name, and its switcher, after + the product mark's `/` rule. At every width: below `sm` it is + the glyph alone, named in its tooltip (objectui#12605). The + dropdown's Studio row takes the Studio button's own target, so + the two cannot disagree (objectui#12201 item 6). `min-w-0` here + and on the pill's button lets a long app name truncate in the pill rather than spill out of the left zone (objectui#12472). */} {activeAppName && onAppChange ? ( -
+
t('console.shortcuts.goHome'), +}; + +/** + * The Setup app in an app list: by its package id first, then by its name — + * the order `resolveSetupAppPath` resolves it in. + */ +function findSetupApp(apps: readonly T[]): T | undefined { + return apps.find((a) => a?._packageId === SETUP_APP_PACKAGE_ID) ?? apps.find((a) => a?.name === SETUP_APP_NAME); +} + export interface AppSwitcherProps { activeAppName: string; onAppChange: (name: string) => void; @@ -66,6 +100,13 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: if (!activeApp) return null; const appLabelText = appLabel({ name: activeApp.name, label: resolveKeyedI18nLabel(activeApp.label, t) }); + // objectui#12605 — Setup is the menu's 「Settings」 row, not one of the apps + // to switch between. Read off the viewer's own list: a viewer whose list + // carries no Setup app (the server withholds it without `setup.access`) is + // offered no row. + const setupApp = findSetupApp(activeApps); + const switchApps = setupApp ? activeApps.filter((app: any) => app !== setupApp) : activeApps; + const setupPath = setupApp ? `/apps/${appRouteSegment(setupApp) ?? setupApp.name}` : null; // `min-w-0` lets the pill shrink inside the bar's left zone, so a long app // name truncates instead of spilling over the search (objectui#12472). @@ -75,11 +116,14 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: @@ -87,7 +131,7 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: {t('layout.appSwitcher.switchApplication', { defaultValue: 'Switch Application' })} - {activeApps.map((app: any) => { + {switchApps.map((app: any) => { const label = appLabel({ name: app.name, label: resolveKeyedI18nLabel(app.label, t) }); const isActive = activeApp.name === app.name; return ( @@ -104,6 +148,19 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: ); })} + + + + + + + {t('layout.appSwitcher.backToHome', { defaultValue: 'Back to home' })} + + + {shortcutKeycaps(GO_HOME_SHORTCUT.chord).join('')} + + + @@ -114,21 +171,37 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: - {designInStudioPath ? ( + {setupPath ? ( - + - + - {t('layout.appSwitcher.editAppInStudio', { - defaultValue: 'Edit {{app}} in Studio', - app: appLabelText, - })} + {t('layout.appSwitcher.settings', { defaultValue: 'Settings' })} + {activeApp === setupApp && } ) : null} + {designInStudioPath ? ( + <> + + + + + + + + {t('layout.appSwitcher.editAppInStudio', { + defaultValue: 'Edit {{app}} in Studio', + app: appLabelText, + })} + + + + + ) : null} ); diff --git a/packages/i18n/src/locales/ar.ts b/packages/i18n/src/locales/ar.ts index 55f5ce8fbf..985b95defe 100644 --- a/packages/i18n/src/locales/ar.ts +++ b/packages/i18n/src/locales/ar.ts @@ -2237,6 +2237,7 @@ const ar = { toggleSidebar: "تبديل الشريط الجانبي", newChat: "محادثة جديدة", toggleChatsList: "تبديل قائمة المحادثات", + goHome: "العودة إلى الرئيسية", }, commandPalette: { title: "لوحة الأوامر", @@ -2942,6 +2943,9 @@ const ar = { noAppsConfigured: "لا تطبيقات مُهيأة", allApps: "جميع التطبيقات", editAppInStudio: "تعديل {{app}} في Studio", + backToHome: "العودة إلى الرئيسية", + settings: "الإعدادات", + productHome: "الصفحة الرئيسية لـ {{product}}", }, activityFeed: { title: "النشاط الأخير", diff --git a/packages/i18n/src/locales/de.ts b/packages/i18n/src/locales/de.ts index f2bb7ba8dc..a212baf5fa 100644 --- a/packages/i18n/src/locales/de.ts +++ b/packages/i18n/src/locales/de.ts @@ -2040,6 +2040,7 @@ const de = { toggleSidebar: "Seitenleiste umschalten", newChat: "Neuer Chat", toggleChatsList: "Chatliste umschalten", + goHome: "Zurück zur Startseite", }, commandPalette: { title: "Befehlspalette", @@ -2726,6 +2727,9 @@ const de = { noAppsConfigured: "Keine Apps konfiguriert", allApps: "Alle Apps", editAppInStudio: "{{app}} im Studio bearbeiten", + backToHome: "Zurück zur Startseite", + settings: "Einstellungen", + productHome: "{{product}} Startseite", }, activityFeed: { title: "Letzte Aktivitäten", diff --git a/packages/i18n/src/locales/en.ts b/packages/i18n/src/locales/en.ts index 1482cfaf36..d513248fbc 100644 --- a/packages/i18n/src/locales/en.ts +++ b/packages/i18n/src/locales/en.ts @@ -2173,6 +2173,7 @@ const en = { toggleSidebar: 'Toggle sidebar', newChat: 'New chat', toggleChatsList: 'Toggle conversations list', + goHome: 'Back to home', }, commandPalette: { title: 'Command palette', @@ -3234,6 +3235,9 @@ const en = { noAppsConfigured: 'No apps configured', allApps: 'All apps', editAppInStudio: 'Edit {{app}} in Studio', + backToHome: 'Back to home', + settings: 'Settings', + productHome: '{{product}} home', }, systemNav: { systemSettings: 'System Settings', diff --git a/packages/i18n/src/locales/es.ts b/packages/i18n/src/locales/es.ts index 1e841b6523..a0d3c526ff 100644 --- a/packages/i18n/src/locales/es.ts +++ b/packages/i18n/src/locales/es.ts @@ -2089,6 +2089,7 @@ const es = { toggleSidebar: "Alternar barra lateral", newChat: "Nuevo chat", toggleChatsList: "Alternar lista de chats", + goHome: "Volver al inicio", }, commandPalette: { title: "Paleta de comandos", @@ -2779,6 +2780,9 @@ const es = { noAppsConfigured: "Sin aplicaciones configuradas", allApps: "Todas las aplicaciones", editAppInStudio: "Editar {{app}} en Studio", + backToHome: "Volver al inicio", + settings: "Configuración", + productHome: "Inicio de {{product}}", }, activityFeed: { title: "Actividad reciente", diff --git a/packages/i18n/src/locales/fr.ts b/packages/i18n/src/locales/fr.ts index ef44af9963..350a24b988 100644 --- a/packages/i18n/src/locales/fr.ts +++ b/packages/i18n/src/locales/fr.ts @@ -2086,6 +2086,7 @@ const fr = { toggleSidebar: "Basculer la barre latérale", newChat: "Nouvelle discussion", toggleChatsList: "Basculer la liste des discussions", + goHome: "Retour à l'accueil", }, commandPalette: { title: "Palette de commandes", @@ -2776,6 +2777,9 @@ const fr = { noAppsConfigured: "Aucune application configurée", allApps: "Toutes les applications", editAppInStudio: "Modifier {{app}} dans Studio", + backToHome: "Retour à l'accueil", + settings: "Paramètres", + productHome: "Accueil {{product}}", }, activityFeed: { title: "Activité récente", diff --git a/packages/i18n/src/locales/ja.ts b/packages/i18n/src/locales/ja.ts index 69abf7954d..842c338b90 100644 --- a/packages/i18n/src/locales/ja.ts +++ b/packages/i18n/src/locales/ja.ts @@ -2042,6 +2042,7 @@ const ja = { toggleSidebar: "サイドバーを切り替え", newChat: "新しいチャット", toggleChatsList: "チャット一覧を切り替え", + goHome: "ホームに戻る", }, commandPalette: { title: "コマンドパレット", @@ -2728,6 +2729,9 @@ const ja = { noAppsConfigured: "アプリが設定されていません", allApps: "すべてのアプリ", editAppInStudio: "Studio で {{app}} を編集", + backToHome: "ホームに戻る", + settings: "システム設定", + productHome: "{{product}} ホーム", }, activityFeed: { title: "最近の活動", diff --git a/packages/i18n/src/locales/ko.ts b/packages/i18n/src/locales/ko.ts index ffb3e07e6d..01bdf9968a 100644 --- a/packages/i18n/src/locales/ko.ts +++ b/packages/i18n/src/locales/ko.ts @@ -2040,6 +2040,7 @@ const ko = { toggleSidebar: "사이드바 전환", newChat: "새 채팅", toggleChatsList: "채팅 목록 전환", + goHome: "홈으로 돌아가기", }, commandPalette: { title: "명령 팔레트", @@ -2725,6 +2726,9 @@ const ko = { noAppsConfigured: "구성된 앱 없음", allApps: "모든 앱", editAppInStudio: "Studio에서 {{app}} 편집", + backToHome: "홈으로 돌아가기", + settings: "시스템 설정", + productHome: "{{product}} 홈", }, activityFeed: { title: "최근 활동", diff --git a/packages/i18n/src/locales/pt.ts b/packages/i18n/src/locales/pt.ts index 6bb16a01ce..c2f1b128e1 100644 --- a/packages/i18n/src/locales/pt.ts +++ b/packages/i18n/src/locales/pt.ts @@ -2084,6 +2084,7 @@ const pt = { toggleSidebar: "Alternar barra lateral", newChat: "Nova conversa", toggleChatsList: "Alternar lista de conversas", + goHome: "Voltar ao início", }, commandPalette: { title: "Paleta de comandos", @@ -2774,6 +2775,9 @@ const pt = { noAppsConfigured: "Nenhum aplicativo configurado", allApps: "Todos os aplicativos", editAppInStudio: "Editar {{app}} no Studio", + backToHome: "Voltar ao início", + settings: "Configurações", + productHome: "Início do {{product}}", }, activityFeed: { title: "Atividade recente", diff --git a/packages/i18n/src/locales/ru.ts b/packages/i18n/src/locales/ru.ts index e41129f24d..00f79cc1a7 100644 --- a/packages/i18n/src/locales/ru.ts +++ b/packages/i18n/src/locales/ru.ts @@ -2147,6 +2147,7 @@ const ru = { toggleSidebar: "Переключить боковую панель", newChat: "Новый чат", toggleChatsList: "Переключить список чатов", + goHome: "Вернуться на главную", }, commandPalette: { title: "Палитра команд", @@ -2845,6 +2846,9 @@ const ru = { noAppsConfigured: "Нет приложений", allApps: "Все приложения", editAppInStudio: "Редактировать {{app}} в Studio", + backToHome: "Вернуться на главную", + settings: "Настройки", + productHome: "Главная {{product}}", }, activityFeed: { title: "Последние действия", diff --git a/packages/i18n/src/locales/zh.ts b/packages/i18n/src/locales/zh.ts index d8e71db5e5..abfafc1eda 100644 --- a/packages/i18n/src/locales/zh.ts +++ b/packages/i18n/src/locales/zh.ts @@ -1832,6 +1832,7 @@ const zh = { toggleSidebar: '切换侧边栏', newChat: '新建对话', toggleChatsList: '切换对话列表', + goHome: '返回首页', }, commandPalette: { title: '命令面板', @@ -2792,6 +2793,9 @@ const zh = { noAppsConfigured: '尚未配置任何应用', allApps: '所有应用', editAppInStudio: '在 Studio 中编辑 {{app}}', + backToHome: '返回首页', + settings: '系统设置', + productHome: '{{product}} 首页', }, systemNav: { systemSettings: '系统设置', From 5c79f6e7b4ff3d3075ba1c10256a21670ebe474e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 16 Oct 2026 08:22:49 +0000 Subject: [PATCH 2/5] test(app-shell): pin the app-bar home mark, the home chord and the app menu order (objectui#12605) Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB --- .changeset/12605-app-bar-home-mark.md | 13 + .../AppHeader.appIdentity-12201.test.tsx | 19 +- .../AppHeader.homeMark-12605.test.tsx | 285 ++++++++++++++++++ .../AppSwitcher.homeMenu-12605.test.tsx | 163 ++++++++++ 4 files changed, 472 insertions(+), 8 deletions(-) create mode 100644 .changeset/12605-app-bar-home-mark.md create mode 100644 packages/app-shell/src/layout/__tests__/AppHeader.homeMark-12605.test.tsx create mode 100644 packages/app-shell/src/layout/__tests__/AppSwitcher.homeMenu-12605.test.tsx diff --git a/.changeset/12605-app-bar-home-mark.md b/.changeset/12605-app-bar-home-mark.md new file mode 100644 index 0000000000..2916786e75 --- /dev/null +++ b/.changeset/12605-app-bar-home-mark.md @@ -0,0 +1,13 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/i18n': patch +--- + +Inside an app, the product mark stays in the top bar as the way home, and the app menu gains Back to home, All apps and Settings (objectui#12605). + +- **The product mark is back in the app bar.** The bar's left group reads: the sidebar trigger, the product mark, a `/` rule, the app pill and the preview chip. The mark is the one the home page draws: a 32 px button holding a 24 px tile on `--primary` with the stack glyph, or the deployment's logo. It is named "PRODUCT home" from the configured product name and links to home. +- **`⌘⇧H` / `Ctrl+Shift+H` goes home** from any app page. It pushes a history entry, so the browser's Back returns to the page. A key press in a text field, a select or a content-editable region is left to that field. The keyboard-shortcuts dialog lists the shortcut inside apps. +- **The app menu.** The "Switch Application" group lists the installed apps with the current one checked. The Setup app is no longer in this group. After a divider come "Back to home" with its `⌘⇧H` keycaps, "All apps" and "Settings", which opens the Setup app. "Settings" is shown only when the viewer's app list includes the Setup app. "Edit APP in Studio" keeps its existing gate and now follows a second divider. +- **Below 640 px** the app pill shows the app's glyph only. The app's name is in the pill's tooltip and accessible name. The pill was previously hidden at this width. + +Language packs: `layout.appSwitcher.backToHome`, `layout.appSwitcher.settings`, `layout.appSwitcher.productHome` and `console.shortcuts.goHome` are added in all ten locales. No package export is added. diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx index 70a20727b4..0a6c5851f1 100644 --- a/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx +++ b/packages/app-shell/src/layout/__tests__/AppHeader.appIdentity-12201.test.tsx @@ -4,9 +4,10 @@ * The top bar inside an app starts with the app (objectui#12201, maintainer * ruling of 2026-10-12, items 4, 5 and 6). * - * - Item 4: the platform mark leaves the bar in app context — the bar starts - * with the app pill, and no path separator precedes it. Platform surfaces - * (`home`, `orgs`, `studio` variants) keep the mark. + * - Item 4: the platform mark left the bar in app context, until + * objectui#12605 put it back as the way home, before the app pill + * (`AppHeader.homeMark-12605` pins it); no crumb separator precedes the + * pill. Platform surfaces (`home`, `orgs`, `studio` variants) keep the mark. * - Item 5: the PREVIEW stage chip; on platform surfaces it stays beside the * mark. Inside an app it sat on the right, beside the search box, until * objectui#12461 moved it beside the app pill — the cells below read that. @@ -211,14 +212,16 @@ beforeEach(() => { vi.stubGlobal('fetch', vi.fn(async () => ({ ok: true, status: 200, json: async () => ({ data: [] }) }))); }); -describe('inside an app the bar starts with the app pill (objectui#12201 item 4)', () => { - it('no platform mark in app context', () => { +describe('inside an app the app pill follows the product mark (objectui#12201 item 4, objectui#12605)', () => { + it('the platform mark is back in app context, before the pill (objectui#12605)', () => { mountApp(); - expect(screen.getByTestId('app-switcher-stub')).toBeInTheDocument(); - expect(screen.queryByTitle(getProductName())).not.toBeInTheDocument(); + const pill = screen.getByTestId('app-switcher-stub'); + const mark = screen.getByTitle(getProductName()).closest('a')!; + expect(mark).toHaveAttribute('href', '/home'); + expect(follows(mark, pill)).toBe(true); }); - it('no path separator precedes the pill — the separators belong to the crumbs after it', () => { + it('no crumb separator precedes the pill — the `/` separators belong to the crumbs after it', () => { mountApp(); const pill = screen.getByTestId('app-switcher-stub'); const separators = [...document.querySelectorAll('span[aria-hidden]')].filter((s) => s.textContent?.trim() === '/'); diff --git a/packages/app-shell/src/layout/__tests__/AppHeader.homeMark-12605.test.tsx b/packages/app-shell/src/layout/__tests__/AppHeader.homeMark-12605.test.tsx new file mode 100644 index 0000000000..34bf1b61e7 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/AppHeader.homeMark-12605.test.tsx @@ -0,0 +1,285 @@ +// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license. + +/** + * objectui#12605 — inside an app the product mark stays in the bar as the way + * home, and `⌘⇧H` / `Ctrl+Shift+H` goes home from any app page. + * + * What this file pins, through the real `AppHeader` under a real router: + * - the left group reads: sidebar trigger, the product mark, the `/` rule, + * the app pill, the PREVIEW chip; + * - the mark is named 「PRODUCT home」 (the product name from + * `getProductName`) and a click on it lands on `/home`; + * - the home chord, with ⌘ and with Ctrl, lands on `/home` as a PUSH, and + * Back returns to the app page; typed into a text field it does nothing; + * - the chord is advertised to the keyboard-shortcuts dialog while the app + * bar is mounted, with the chord the listener answers; + * - the pill is drawn at every width: no breakpoint class hides it. + * + * CONTROLS: the home page draws the same mark (one element, same test id), + * named and linked the same way, and neither listens for nor advertises the + * chord — Home is where it goes. + * + * Layout is not measured — jsdom applies no stylesheet — so, as in + * `AppHeader.appBarLeftGroup-12461`, the order of the elements and the + * responsive classes the header controls are what is asserted. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { render, screen, fireEvent, cleanup, act } from '@testing-library/react'; +import React from 'react'; +import { MemoryRouter, Routes, Route, useLocation, useNavigate, useNavigationType } from 'react-router-dom'; +import { MePermissionsProvider, type MePermissionsResponse } from '@object-ui/permissions'; + +vi.mock('@object-ui/i18n', async (importOriginal) => ({ + ...(await importOriginal>()), + useObjectTranslation: () => ({ + language: 'en', + t: (key: string, options?: Record) => + String(options?.defaultValue ?? key).replace(/\{\{(\w+)\}\}/g, (_m, name: string) => String(options?.[name] ?? '')), + }), + useObjectLabel: () => { + const label = (x: { name?: string; label?: string } | string) => + typeof x === 'string' ? x : String(x?.label ?? x?.name ?? ''); + return { + objectLabel: label, + objectPluralLabel: label, + dashboardLabel: label, + pageLabel: label, + reportLabel: label, + viewLabel: (_o: string, _v: string, fallback?: string) => fallback ?? '', + appLabel: label, + }; + }, +})); + +vi.mock('@object-ui/react', async (importOriginal) => ({ + ...(await importOriginal>()), + useOffline: () => ({ isOnline: true }), +})); + +vi.mock('@object-ui/collaboration', async (importOriginal) => ({ + ...(await importOriginal>()), + PresenceAvatars: () => null, + useTenantPresence: () => [], +})); + +vi.mock('../ModeToggle', () => ({ ModeToggle: () => null })); +vi.mock('../WorkspaceSwitcher', () => ({ WorkspaceSwitcher: () => null })); +vi.mock('../CurrentOrganizationIndicator', () => ({ CurrentOrganizationIndicator: () => null })); +vi.mock('../LocaleSwitcher', () => ({ LocaleSwitcher: () => null })); +vi.mock('../ConnectionStatus', () => ({ ConnectionStatus: () => null })); +vi.mock('../LocalizedSidebarTrigger', () => ({ + LocalizedSidebarTrigger: () =>