diff --git a/.changeset/28483-phone-record-page-one-bar.md b/.changeset/28483-phone-record-page-one-bar.md new file mode 100644 index 00000000000..e1418b03bd8 --- /dev/null +++ b/.changeset/28483-phone-record-page-one-bar.md @@ -0,0 +1,17 @@ +--- +'@object-ui/app-shell': patch +'@object-ui/layout': patch +--- + +feat(app-shell): a phone record page draws one bar, its own top bar, and the record's name once + +Clause-②: yes (widening) +Metadata-protocol: none + +Below 768 px a console record page drew two bars at its top: the app bar (sidebar trigger, app, the record's name, search, notifications, account) and, under it, the record page's own top bar (「‹ 」 and the counter with ↓ ↑). The record's name showed twice, once in the app bar and once as the header title right below it. + +- **One bar on a phone record page.** Below 768 px the console mounts no app bar on a record route (`/apps/APP/OBJECT/record/ID`, and the same record on a named record page, `.../page/PAGE`). The record page's top bar is the page's one bar, and the record's name shows once, as the header title. +- **The four doors stay on the root routes.** The app menu, search, notifications and the account menu stay in the app bar on every other route: the object's list, home, dashboards, the record's edit form. A phone record page reaches them through its 「‹」 link, one tap. No menu is added to the record bar, and there is no bottom navigation bar. +- **A record page that cannot show its record keeps its 「‹」 bar on a phone.** While it loads, and when the record is not found, the read is refused or fails, the object is unknown, the named page is not one of the object's record pages, or the viewer's approval access has ended, the page draws the same back bar above that state, so a deep link always has a way off the page. +- **768 px and up is unchanged.** Every route keeps the app bar, and a record page that cannot show its record draws no extra bar. +- **`AppShell` gains `showHeader`** (`@object-ui/layout`, optional, default `true`). `showHeader={false}` draws no `
` element and does not mount `navbar`. Leaving it out renders exactly as before, including a shell with no `navbar`, which still draws its empty bar. diff --git a/content/docs/frontend/app-shell.mdx b/content/docs/frontend/app-shell.mdx index b00fc254d74..4b14b98f1c4 100644 --- a/content/docs/frontend/app-shell.mdx +++ b/content/docs/frontend/app-shell.mdx @@ -44,6 +44,8 @@ interface AppShellProps { branding?: AppShellBranding; // App branding — four theme fields, see Branding rightRail?: React.ReactNode; // Optional right rail beside the content, not // over it (ADR-0057 P3a) + showHeader?: boolean; // Draw the top bar (default: true); false draws + // no header and does not mount navbar } ``` @@ -70,6 +72,12 @@ of its own to it, in particular **no sidebar toggle**. If you want one, render i - Border bottom separator - Background matching app theme +Pass `showHeader={false}` to draw no header at all, for a page that draws its own top bar: +the shell then renders no `
` element and does not mount `navbar`. It defaults to +`true`, and leaving `navbar` out alone still draws the (empty) bar. The Console uses it on +a phone (below 768 px): a record page's own top bar, with its back link to the object's +list, is the page's only bar there, and every other route keeps the app bar. + ### Content Area The main content area features: diff --git a/content/docs/frontend/layout.mdx b/content/docs/frontend/layout.mdx index b7befdd6e10..9bef309a173 100644 --- a/content/docs/frontend/layout.mdx +++ b/content/docs/frontend/layout.mdx @@ -29,7 +29,7 @@ Layout components are automatically registered when you import ObjectUI. The `AppShell` provides a complete application structure with a top navbar, a sidebar, and a main content area. -**`AppShell` is a React component, not an authorable JSON node.** Four of its seven props +**`AppShell` is a React component, not an authorable JSON node.** Four of its eight props are `React.ReactNode` slots — `sidebar`, `navbar`, `children` and `rightRail` — and a JSON document has no way to put a node into any of them. Compose the shell in React, and render your JSON pages *inside* it. `app-shell` is not a component key either — what a @@ -64,7 +64,7 @@ the component's `children`; there is no `body` prop either. ### Props -`AppShellProps` (`packages/layout/src/AppShell.tsx`) declares exactly these seven. The +`AppShellProps` (`packages/layout/src/AppShell.tsx`) declares exactly these eight. The component destructures that fixed key list with **no rest element**, so anything else you pass is built and then dropped on the floor. @@ -80,6 +80,7 @@ These are React props, passed in JSX. None of them is authorable in JSON — the | `defaultOpen` | `boolean` | no | Initial open state of the underlying Shadcn `SidebarProvider`. Defaults to `true`. | | `branding` | `AppShellBranding` | no | App branding, applied by `useAppShellBranding`: `primaryColor` / `accentColor` become CSS custom properties on the document root, `favicon` sets the icon link's `href`, and `title` sets `document.title`. | | `rightRail` | `React.ReactNode` | no | Optional right-side rail. It reflows the content beside it rather than overlaying it; absent → unchanged single-pane layout. | +| `showHeader` | `boolean` | no | Whether the shell draws its top bar. Defaults to `true`; `false` draws no `
` element and does not mount `navbar`, for a page that draws its own top bar. Leaving `navbar` out alone still draws the bar. | ### There is no `app-shell` node @@ -93,7 +94,7 @@ does not register it, and nothing else in this repo does either, so a `unknown-component` and the message ` is not a known component`. It **was** registered until objectui#4841, and the registration could never produce a -shell. Four of the seven props are `React.ReactNode` slots that a JSON document cannot +shell. Four of the eight props are `React.ReactNode` slots that a JSON document cannot fill, so a node had exactly two outcomes: `children` was stripped by `SchemaRenderer` before a node's keys were spread as props — `AppShell` reads its `children` prop, never `schema.children` — so the `
` element rendered **empty with nothing logged**; and a diff --git a/ui/packages/app-shell/src/layout/ConsoleLayout.tsx b/ui/packages/app-shell/src/layout/ConsoleLayout.tsx index 6f3a2ba8964..af15253f4d7 100644 --- a/ui/packages/app-shell/src/layout/ConsoleLayout.tsx +++ b/ui/packages/app-shell/src/layout/ConsoleLayout.tsx @@ -30,6 +30,7 @@ import { ConsoleNotificationBanners } from './ConsoleNotificationBanners.js'; import { UnifiedSidebar } from './UnifiedSidebar.js'; import { AppHeader } from './AppHeader.js'; import { useResponsiveSidebar } from '../hooks/useResponsiveSidebar.js'; +import { usePhoneViewport } from '../hooks/usePhoneViewport.js'; import { primeConsoleSidebarOpen } from './consoleSidebarRail.js'; import { useAiSurfaceEnabled } from '../hooks/useAiSurface.js'; import { useNavigationContext } from '../context/NavigationContext.js'; @@ -118,6 +119,15 @@ function SkipToContentLink({ targetRef }: { targetRef: React.RefObject」 and the stepper), so the app + // bar is not mounted there. The app menu, search, notifications and account + // stay on every other route, one 「‹」 away. From 768 px up every route keeps + // the app bar. The width is read synchronously, at the breakpoint the record + // page draws its phone face at, so the two never disagree for a frame. + const phone = usePhoneViewport(); + const recordPageOwnsTopBar = phone && isRecordPagePath(location.pathname); // "/ai = the dock maximized": record where we maximized FROM at click time, // so the page's collapse-to-dock returns exactly here (history-back could // land on a prior /ai URL after in-page conversation switches). @@ -221,6 +239,7 @@ export function ConsoleLayout({ railOverlay /> } + showHeader={!recordPageOwnsTopBar} navbar={ 」, the stepper) + * is the page's one bar, and the record's name shows once, as the header + * title under it. The four doors (app menu, search, notifications, account) + * stay on every other route, the object's list among them, one 「‹」 away. + * From 768 px up every route keeps the app bar. + * + * Harness: the real `ConsoleLayout` with its real `@object-ui/layout` + * `AppShell` (`ConsoleLayout.oneMainLandmark-13334`'s). `AppHeader` is a stub + * that draws what its phone label draws on a record route, the record's name + * (`AppHeader`'s own label is `AppHeader.phoneNoViewDropdown-27058`'s pin); the + * route's page is a stub of the record page's phone face: its top bar and its + * header title. The record page's own phone bar is `RecordDetailView.phoneFace-27058`'s. + */ + +import '@testing-library/jest-dom/vitest'; +import { describe, it, expect, vi, afterEach } from 'vitest'; +import React from 'react'; +import { render, screen, cleanup } from '@testing-library/react'; +import { MemoryRouter } from 'react-router-dom'; +import { createI18n, I18nProvider } from '@object-ui/i18n'; + +vi.mock('../../hooks/useAiSurface', () => ({ + useAiSurfaceEnabled: () => ({ enabled: false, isLoading: false }), +})); +vi.mock('../UnifiedSidebar', () => ({ UnifiedSidebar: () =>