Skip to content
This repository was archived by the owner on Oct 26, 2026. It is now read-only.

Commit a5bb7ac

Browse files
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 (objectui#12605) (#12651)
Fixes #12605 Clause-②: no Supersedes PR #12650: the same tree for every file it changed, rebuilt on current `main` as one clean commit (`ba5461f18`), because that branch's commits carried model-named co-author trailers that the squash merge would carry onto `main`. Re-run on this head: the 22 AppHeader/AppSwitcher suites (191 tests) and the two i18n key-parity suites (48 tests), `--maxWorkers=1`, exit 0. Inside an app the product mark stays top-left as the home button, and the app pill's menu gains Back to home, All apps and Settings, per the maintainer-approved artboard (`AppBar.html`). ## What changed - **Left group** (`AppHeader.tsx`): sidebar trigger, the product mark, a drawn `/` rule, the app pill, the PREVIEW chip. The mark is one component (`ProductHomeMark`) used on every surface, the home page, the organizations and Studio front doors and inside every app: a 32 px link holding a 24 px tile on `--primary` with the stack glyph (or the deployment logo). Its accessible name is `layout.appSwitcher.productHome` (`{{product}} home` / 「{{product}} 首页」) from `getProductName()`; it links to `homePath` (`/home` unless the deployment declares a landing, the objectui#7256 rule the old logo already followed). - **Same x on every surface.** Off an app there is no sidebar trigger, so the bar holds the trigger's slot empty (`h-7 w-7 shrink-0 mr-1`, `aria-hidden`) before the mark; the mark does not jump when the viewer goes home. - **`⌘⇧H` / `Ctrl+Shift+H`** goes home from any app page with a push (Back returns). It skips text inputs, textareas, selects, content-editable regions and ARIA textboxes, and events already `defaultPrevented` or composing. The chord lives in the shortcut registry (`chrome/advertisedShortcuts.ts`, `GO_HOME_SHORTCUT`) and is advertised to the keyboard-shortcuts dialog under the same gate as the listener. - **App menu** (`AppSwitcher.tsx`): Switch Application lists the installed apps with the current one checked and **without Setup**; a divider; Back to home (keycaps drawn from the same chord), All apps, Settings (the Setup app, shown only when the viewer's app list carries it; checked inside Setup); then Edit APP in Studio after a second divider, its gate untouched. - **Below 640 px** the pill shows the app tile only; `title` names the app (tooltip and accessible name). The pill was previously hidden below `sm`. - **i18n**: `layout.appSwitcher.backToHome`, `layout.appSwitcher.settings`, `layout.appSwitcher.productHome`, `console.shortcuts.goHome` in all ten locales; zh uses the card's words (返回首页, 系统设置, 首页). - Every existing `data-testid` stays; new ones: `app-bar-product-home`, `app-bar-home-separator`, `app-bar-trigger-slot`, `app-switcher-home`, `app-switcher-settings`. ## Tests (head 0590733) - New pins: `AppHeader.homeMark-12605.test.tsx` (left-group order, name + `/home` link, click lands home, pill drawn at every width, chord advertised as ⌘⇧H, ⌘ and Ctrl push `/home` and Back returns, chord left to a text field, ⌘H alone does nothing, home page: same mark, no rule, no advertisement, trigger slot held), `AppSwitcher.homeMenu-12605.test.tsx` (menu order, Setup absent from the switch group, keycaps, Settings target and check, no Settings without Setup, sub-`sm` classes and title). - Vitest (`--maxWorkers=1`, repo root): all 80 `packages/app-shell/src/layout` suites + `src/chrome` (36 + 58 files, 290 + 382 tests, exit 0), `src/__tests__` + `console/home` (43 files, 567 tests, exit 0), `packages/i18n` (83 files, 1321 tests, exit 0). After merging `main`: AppHeader/AppSwitcher suites + every app-shell test file the merge brought in (31 files, 264 tests, exit 0). - Ablation (once): `switchApps = activeApps` (Setup filter removed), landed via `ablation-replace.mjs` (anchor 1 to 0, blob 3f315e7 to 656696e8) and turned 3 of 7 menu tests red; restored with `git checkout HEAD --`, `git diff HEAD` empty, blob hash matches HEAD. - type-check `@object-ui/i18n` and `@object-ui/app-shell`: exit 0 (after rebuilding the four deps `main` changed: auth, components, fields, i18n). - Pre-tsc battery (15 `check:*` listed in the dispatch), `check-hand-rolled-comment-mask`, `check-changeset-fixed`: all exit 0; i18n, control-bytes, unreferenced-sources, self-import, phantom-deps and changeset-fixed re-run on the merged head, exit 0. - eslint narrowed to the 16 changed TS/TSX files (`--no-inline-config --format json`): 16 files, 0 errors, 20 warnings (all `no-explicit-any` on pre-existing lines). The config enables no type-aware linting, so this diff cannot move any untouched file's verdict. - **NOT MEASURED locally, declared to CI**: the whole `packages/app-shell` package (1442 test files; at `--maxWorkers=1` it exceeds the foreground cap), and `apps/console` suites. ## Acceptance notes - `AppHeader.appIdentity-12201.test.tsx` changed: its item-4 cell pinned "no platform mark in app context", the ruling this card reverses. It now pins the mark before the pill; the "no crumb separator precedes the pill" cell is unchanged in substance. - The mark keeps its x across surfaces by an empty trigger slot on the home, organizations and Studio bars (the ruling's "measure the home page's mark position and match it" with the trigger first). If the maintainer prefers the home bar flush left, delete the one span. - `AppSwitcher` reads `SETUP_APP_PACKAGE_ID` / `SETUP_APP_NAME` from `console/ConsoleShell` (render-time use only), resolving Setup in the same order as `resolveSetupAppPath`. - objectui#12639 (48 px bar): the mark is 32 px (`size-8`) and fits. - PR #12638 (objectui#12601, Home's centred search) merged cleanly into this branch and its tests pass here. ## Changeset `.changeset/12605-app-bar-home-mark.md`: `@object-ui/app-shell` patch, `@object-ui/i18n` patch. No package export is added (`GO_HOME_SHORTCUT` is internal to `chrome/advertisedShortcuts`, not re-exported from the package index). --- _Generated by [Claude Code](https://claude.ai/code/session_01XVp19E1MYUTFM3dnkwz8gB)_ --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent f62b6ff commit a5bb7ac

19 files changed

Lines changed: 746 additions & 63 deletions
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/i18n': patch
4+
---
5+
6+
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).
7+
8+
- **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.
9+
- **`⌘⇧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.
10+
- **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.
11+
- **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.
12+
13+
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.

‎apps/console/src/components/StudioRoute.lazyBuilder-11798.test.tsx‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -182,8 +182,9 @@ describe('/studio/* — the builder module loads behind the gate (objectui#11798
182182

183183
await waitFor(() => expect(screen.getByTestId('studio-front-door')).toBeInTheDocument());
184184
// The console header is the frame the landing's fallback sits under; its
185-
// brand links home (objectui#11863).
186-
expect(screen.getByRole('link', { name: 'ObjectOS' })).toHaveAttribute('href', '/home');
185+
// brand links home (objectui#11863), named 「PRODUCT home」 since
186+
// objectui#12605.
187+
expect(screen.getByRole('link', { name: 'ObjectOS home' })).toHaveAttribute('href', '/home');
187188
// Both `lazy()` declarations name one specifier, so one module serves both.
188189
expect(builderModuleLoads.count).toBe(1);
189190
});

‎apps/console/src/components/StudioRoute.test.tsx‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -385,7 +385,9 @@ describe('/studio/* — the entry decision, both ways', () => {
385385
renderStudioDeepLink('/studio/', CONTROL_PLANE_APPS);
386386

387387
await waitFor(() => expect(screen.getByTestId('studio-front-door')).toBeInTheDocument());
388-
expect(screen.getByRole('link', { name: 'ObjectOS' })).toHaveAttribute(
388+
// The mark is named 「PRODUCT home」 since objectui#12605 (the ruled
389+
// accessible name: product name + home); its target is unchanged.
390+
expect(screen.getByRole('link', { name: 'ObjectOS home' })).toHaveAttribute(
389391
'href',
390392
'/apps/cloud_control',
391393
);

‎packages/app-shell/src/chrome/advertisedShortcuts.ts‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -67,6 +67,21 @@ export interface AdvertisedShortcut {
6767
label: (t: ShortcutLabelTranslate) => string;
6868
}
6969

70+
/**
71+
* `⌘⇧H` / `Ctrl+Shift+H` — back to home from any app page (objectui#12605).
72+
* `AppHeader` owns the listener and advertises this beside it; the app pill's
73+
* 「Back to home」 row draws its keycaps from the same chord, so the row, the
74+
* keyboard-shortcuts dialog and the handler cannot disagree. It lives here,
75+
* not beside either holder, because both read it: a test that stubs one
76+
* holder's module must not take the other's chord with it.
77+
*/
78+
export const GO_HOME_SHORTCUT: AdvertisedShortcut = {
79+
id: 'go-home',
80+
group: 'navigation',
81+
chord: { key: 'h', mod: true, shift: true },
82+
label: (t: ShortcutLabelTranslate) => t('console.shortcuts.goHome'),
83+
};
84+
7085
const KEYCAP_TEXT: Record<string, string> = { Escape: 'Esc' };
7186

7287
/** The keycaps the dialog draws for a chord: `{ key: 'k', mod: true }` → `['⌘', 'K']`. */

‎packages/app-shell/src/layout/AppHeader.tsx‎

Lines changed: 124 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -7,12 +7,13 @@
77
* (inside an app, from `lg` up: the search is centered)
88
*
99
* Variants:
10-
* - `app` (default when `appName` is present): sidebar trigger + AppSwitcher
11-
* + the platform-stage chip. Used by `ConsoleLayout` inside
12-
* `/apps/:appName/*`. The bar starts with the app pill — the
13-
* app's glyph and name — and carries no platform mark: one brand
14-
* mark per screen, and inside an app that mark is the app's
15-
* (objectui#12201). An object route (its list, a view, a record)
10+
* - `app` (default when `appName` is present): sidebar trigger + the
11+
* product mark + a `/` rule + AppSwitcher + the platform-stage
12+
* chip. Used by `ConsoleLayout` inside `/apps/:appName/*`. The
13+
* product mark stays in the bar as the way home, the same mark
14+
* the home page draws, and `⌘⇧H` / `Ctrl+Shift+H` goes home too
15+
* (objectui#12605, which reverses objectui#12201's "no platform
16+
* mark inside an app"). An object route (its list, a view, a record)
1617
* draws no crumb after the pill: the page header is the one place
1718
* that page is named (objectui#12461). A dashboard or page route
1819
* draws none either (objectui#12526). A report or `system/*`
@@ -103,6 +104,7 @@ import { resolveViewId } from '../utils/resolveViewId.js';
103104
import { getProductName, getLogoUrl } from '../runtime-config.js';
104105
import { LocalizedSidebarTrigger } from './LocalizedSidebarTrigger.js';
105106
import { PreviewBadge } from './PreviewBadge.js';
107+
import { GO_HOME_SHORTCUT, useAdvertiseShortcut, type ShortcutChord } from '../chrome/advertisedShortcuts.js';
106108

107109
function humanizeSlug(slug: string): string {
108110
return slug.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
@@ -138,6 +140,88 @@ function PathSep() {
138140
);
139141
}
140142

143+
/**
144+
* The product mark — the way home (objectui#12605). One element for every
145+
* surface that draws it: the home page, the organizations and Studio front
146+
* doors, and inside every app after the sidebar trigger. A 32 px target
147+
* holding a 24 px tile on `--primary` with the stack glyph, or the
148+
* deployment's logo. Named 「PRODUCT home」; `title` keeps the product's name.
149+
*
150+
* `homePath`, not a literal `/home` (objectui#7256): on a deployment that
151+
* DECLARES a landing this is the declared one, so the mark and the post-login
152+
* landing name the same screen.
153+
*/
154+
function ProductHomeMark({ homePath, label }: { homePath: string; label: string }) {
155+
const productName = getProductName();
156+
const logoUrl = getLogoUrl();
157+
return (
158+
<Link
159+
to={homePath}
160+
data-testid="app-bar-product-home"
161+
aria-label={label}
162+
title={productName}
163+
className="flex size-8 shrink-0 items-center justify-center rounded-md hover:bg-accent transition-colors outline-none focus-visible:ring-2 focus-visible:ring-ring"
164+
>
165+
<span className="flex size-6 items-center justify-center overflow-hidden rounded-md bg-primary text-primary-foreground">
166+
{logoUrl ? (
167+
<img src={logoUrl} alt="" className="h-full w-full object-contain" />
168+
) : (
169+
<Layers className="size-3.5" aria-hidden />
170+
)}
171+
</span>
172+
</Link>
173+
);
174+
}
175+
176+
/**
177+
* Whether a keydown is `chord`: ⌘ or Ctrl exactly when the chord has `mod`
178+
* (the advertised chords accept either), Shift exactly when it has `shift`,
179+
* never Alt, and the key compared case-blind (Shift makes it upper case).
180+
*/
181+
function matchesShortcutChord(
182+
e: Pick<KeyboardEvent, 'key' | 'metaKey' | 'ctrlKey' | 'shiftKey' | 'altKey'>,
183+
chord: ShortcutChord,
184+
): boolean {
185+
if (e.altKey) return false;
186+
if (Boolean(chord.mod) !== (e.metaKey || e.ctrlKey)) return false;
187+
if (Boolean(chord.shift) !== e.shiftKey) return false;
188+
return e.key.toLowerCase() === chord.key;
189+
}
190+
191+
/**
192+
* A keydown aimed at something the user types into: a text field, a select,
193+
* a content-editable region (rich-text and code editors), or an ARIA textbox.
194+
* The home chord leaves those alone, so an editor that binds the same keys
195+
* keeps them (objectui#12605).
196+
*/
197+
function isTypingTarget(target: EventTarget | null): boolean {
198+
if (!(target instanceof Element)) return false;
199+
if (target instanceof HTMLElement && target.isContentEditable) return true;
200+
return target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"]), [role="textbox"]') !== null;
201+
}
202+
203+
/**
204+
* `⌘⇧H` / `Ctrl+Shift+H` goes home from any app page, as a PUSH so the
205+
* browser's Back returns to the page (objectui#12605). Advertised beside the
206+
* listener, under the same gate, so the keyboard-shortcuts dialog lists it
207+
* exactly where it fires (objectui#11674).
208+
*/
209+
function useGoHomeShortcut(enabled: boolean, homePath: string): void {
210+
const navigate = useNavigate();
211+
useEffect(() => {
212+
if (!enabled) return undefined;
213+
const onKeyDown = (e: KeyboardEvent) => {
214+
if (e.defaultPrevented || e.isComposing) return;
215+
if (!matchesShortcutChord(e, GO_HOME_SHORTCUT.chord)) return;
216+
if (isTypingTarget(e.target)) return;
217+
e.preventDefault();
218+
navigate(homePath);
219+
};
220+
document.addEventListener('keydown', onKeyDown);
221+
return () => document.removeEventListener('keydown', onKeyDown);
222+
}, [enabled, homePath, navigate]);
223+
useAdvertiseShortcut(GO_HOME_SHORTCUT, enabled);
224+
}
141225

142226
// No fake fallback presence — render nothing when the API has no data so the
143227
// header doesn't ship phantom collaborators in production.
@@ -175,6 +259,7 @@ export function AppHeader({
175259
// objectui#7256 — the brand logo's target follows the product's DECLARED
176260
// landing, so the chrome cannot offer a second, contradicting "home".
177261
const homePath = useHomePath();
262+
useGoHomeShortcut(isApp, homePath);
178263
const { isOnline } = useOffline();
179264
// Idempotent, direct open of the ⌘K command palette (ADR-0054 C1). Replaces a
180265
// synthetic `⌘K` KeyboardEvent re-dispatch that did nothing under automation.
@@ -534,31 +619,35 @@ export function AppHeader({
534619
sidebar and its icon rail above that. It is the bar's leftmost
535620
control inside an app, so it never moves when the sidebar
536621
collapses; the sidebar itself carries no trigger. */}
537-
{isApp && (
622+
{isApp ? (
538623
<LocalizedSidebarTrigger className="shrink-0 mr-1" aria-label={t('common.toggleSidebar', { defaultValue: 'Toggle sidebar' })} />
624+
) : (
625+
// objectui#12605 — the product mark sits at the same x on every
626+
// surface. Off an app there is no sidebar to toggle, so the
627+
// trigger's slot (its 28 px box and its gap) is held empty: the
628+
// mark does not jump left when the viewer goes home.
629+
<span aria-hidden data-testid="app-bar-trigger-slot" className="h-7 w-7 shrink-0 mr-1" />
539630
)}
540631

541-
{/* Platform logo — links to home. Platform surfaces only (Home,
542-
organizations, the Studio front door): inside an app the bar
543-
starts with the app pill instead, and the way back to Home is the
544-
pill's 「All apps」 row (objectui#12201 items 4 and 6) — on a phone,
545-
the drawer header's 「All apps」 button.
546-
547-
`homePath`, not a literal `/home` (objectui#7256): on a deployment
548-
that DECLARES a landing this is the declared one, so the logo and
549-
the post-login landing name the same screen. */}
550-
{!isApp && (
551-
<Link
552-
to={homePath}
553-
className="flex items-center justify-center h-7 w-7 shrink-0 rounded-md bg-primary text-primary-foreground hover:bg-primary/90 transition-colors overflow-hidden"
554-
title={getProductName()}
555-
>
556-
{getLogoUrl() ? (
557-
<img src={getLogoUrl()} alt={getProductName()} className="h-full w-full object-contain" />
558-
) : (
559-
<Layers className="h-4 w-4" />
560-
)}
561-
</Link>
632+
{/* The product mark — links to home, on every surface: the home
633+
page, the organizations and Studio front doors, and inside every
634+
app, after the sidebar trigger (objectui#12605). Inside an app a
635+
`/` rule follows it, so the bar reads 「PRODUCT / APP」. The rule
636+
is drawn, not typed: it carries no text, and the `/` separators
637+
the crumbs after the pill use stay theirs alone. */}
638+
<ProductHomeMark
639+
homePath={homePath}
640+
label={t('layout.appSwitcher.productHome', {
641+
defaultValue: '{{product}} home',
642+
product: getProductName(),
643+
})}
644+
/>
645+
{isApp && (
646+
<span
647+
aria-hidden
648+
data-testid="app-bar-home-separator"
649+
className="mx-1.5 h-[18px] w-px shrink-0 rotate-[20deg] bg-border"
650+
/>
562651
)}
563652

564653
{resolvedVariant === 'home' && (
@@ -604,16 +693,15 @@ export function AppHeader({
604693

605694
{isApp && (
606695
<>
607-
{/* App pill — the app's glyph and name, and its switcher. It
608-
starts the path, so no separator precedes it. Desktop/tablet
609-
only: on mobile the drawer header names the active app, so a
610-
second app pill in the topbar is pure noise. The dropdown's
611-
Studio row takes the Studio button's own target, so the two
612-
cannot disagree (objectui#12201 item 6). `min-w-0` here and
613-
on the pill's button lets a long app name truncate in the
696+
{/* App pill — the app's glyph and name, and its switcher, after
697+
the product mark's `/` rule. At every width: below `sm` it is
698+
the glyph alone, named in its tooltip (objectui#12605). The
699+
dropdown's Studio row takes the Studio button's own target, so
700+
the two cannot disagree (objectui#12201 item 6). `min-w-0` here
701+
and on the pill's button lets a long app name truncate in the
614702
pill rather than spill out of the left zone (objectui#12472). */}
615703
{activeAppName && onAppChange ? (
616-
<div className="hidden sm:flex items-center min-w-0">
704+
<div className="flex items-center min-w-0">
617705
<AppSwitcher
618706
activeAppName={activeAppName}
619707
onAppChange={onAppChange}

0 commit comments

Comments
 (0)