diff --git a/.changeset/12605-app-bar-home-mark.md b/.changeset/12605-app-bar-home-mark.md new file mode 100644 index 0000000000..f9b50230d2 --- /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. It sits at the same x on every surface: off an app, where there is no sidebar trigger, the trigger's slot is held empty before it. +- **`⌘⇧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/chrome/advertisedShortcuts.ts b/packages/app-shell/src/chrome/advertisedShortcuts.ts index 3076244101..2c494e79f0 100644 --- a/packages/app-shell/src/chrome/advertisedShortcuts.ts +++ b/packages/app-shell/src/chrome/advertisedShortcuts.ts @@ -67,6 +67,21 @@ export interface AdvertisedShortcut { label: (t: ShortcutLabelTranslate) => string; } +/** + * `⌘⇧H` / `Ctrl+Shift+H` — back to home from any app page (objectui#12605). + * `AppHeader` owns the listener and advertises this beside it; the app pill's + * 「Back to home」 row draws its keycaps from the same chord, so the row, the + * keyboard-shortcuts dialog and the handler cannot disagree. It lives here, + * not beside either holder, because both read it: a test that stubs one + * holder's module must not take the other's chord with it. + */ +export const GO_HOME_SHORTCUT: AdvertisedShortcut = { + id: 'go-home', + group: 'navigation', + chord: { key: 'h', mod: true, shift: true }, + label: (t: ShortcutLabelTranslate) => t('console.shortcuts.goHome'), +}; + const KEYCAP_TEXT: Record = { Escape: 'Esc' }; /** The keycaps the dialog draws for a chord: `{ key: 'k', mod: true }` → `['⌘', 'K']`. */ diff --git a/packages/app-shell/src/layout/AppHeader.tsx b/packages/app-shell/src/layout/AppHeader.tsx index 0cdcaa99cc..b58f8db152 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/*` @@ -103,6 +104,7 @@ 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, useAdvertiseShortcut, type ShortcutChord } from '../chrome/advertisedShortcuts.js'; function humanizeSlug(slug: string): string { return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()); @@ -138,6 +140,88 @@ 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 `chord`: ⌘ or Ctrl exactly when the chord has `mod` + * (the advertised chords accept either), Shift exactly when it has `shift`, + * never Alt, and the key compared case-blind (Shift makes it upper case). + */ +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. @@ -175,6 +259,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. @@ -534,31 +619,35 @@ export function AppHeader({ sidebar and its icon rail above that. It is the bar's leftmost control inside an app, so it never moves when the sidebar collapses; the sidebar itself carries no trigger. */} - {isApp && ( + {isApp ? ( + ) : ( + // objectui#12605 — the product mark sits at the same x on every + // surface. Off an app there is no sidebar to toggle, so the + // trigger's slot (its 28 px box and its gap) is held empty: the + // mark does not jump left when the viewer goes home. + )} - {/* 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' && ( @@ -604,16 +693,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 ? ( -
+
(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 +83,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) => 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 +99,14 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: @@ -87,7 +114,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 +131,19 @@ export function AppSwitcher({ activeAppName, onAppChange, designInStudioPath }: ); })} + + + + + + + {t('layout.appSwitcher.backToHome', { defaultValue: 'Back to home' })} + + + {shortcutKeycaps(GO_HOME_SHORTCUT.chord).join('')} + + + @@ -114,21 +154,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/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..2e4e8c8651 --- /dev/null +++ b/packages/app-shell/src/layout/__tests__/AppHeader.homeMark-12605.test.tsx @@ -0,0 +1,302 @@ +// 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: ({ className }: { className?: string }) => ( +