Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .changeset/28483-phone-record-page-one-bar.md
Original file line number Diff line number Diff line change
@@ -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 (「‹ <object plural>」 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 `<header>` 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.
8 changes: 8 additions & 0 deletions content/docs/frontend/app-shell.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
```

Expand All @@ -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 `<header>` 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:
Expand Down
7 changes: 4 additions & 3 deletions content/docs/frontend/layout.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.

Expand All @@ -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 `<header>` 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

Expand All @@ -93,7 +94,7 @@ does not register it, and nothing else in this repo does either, so a
`unknown-component` and the message `<app-shell> 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 `<main>` element rendered **empty with nothing logged**; and a
Expand Down
19 changes: 19 additions & 0 deletions ui/packages/app-shell/src/layout/ConsoleLayout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -118,6 +119,15 @@ function SkipToContentLink({ targetRef }: { targetRef: React.RefObject<HTMLDivEl
);
}

/**
* Is `pathname` a record page, the console route `RecordDetailView` draws:
* `/apps/APP/OBJECT/record/ID`, or the same record on a named record page
* (`.../page/PAGE`)? The record's edit form (`.../edit`) is not one.
*/
export function isRecordPagePath(pathname: string): boolean {
return /^\/apps\/[^/]+\/[^/]+\/record\/[^/]+(?:\/page\/[^/]+)?\/?$/.test(pathname);
}

/** Inner component that can access SidebarProvider context */
function ConsoleLayoutInner({ children }: { children: React.ReactNode }) {
useResponsiveSidebar();
Expand Down Expand Up @@ -167,6 +177,14 @@ export function ConsoleLayout({
// rail's `hidden md:` classes alone) decides which one MOUNTS, so the phone
// never pays for an invisible rail's chat graph.
const isMobile = useIsMobile();
// objectstack#28483 (ruling letter A) — on a phone a record page has one
// bar, its own top bar (「‹ <object plural>」 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).
Expand Down Expand Up @@ -221,6 +239,7 @@ export function ConsoleLayout({
railOverlay
/>
}
showHeader={!recordPageOwnsTopBar}
navbar={
<AppHeader
variant="app"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectstack#28483 (ruling letter A) — below 768 px a record route draws no
* app bar: the record page's own top bar (「‹ <object plural>」, 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: () => <nav aria-label="App menu" /> }));
vi.mock('../AppHeader', () => ({
AppHeader: () => (
<div data-testid="app-bar">
<button type="button">Search</button>
<span>Mira Costa</span>
</div>
),
}));
vi.mock('../ConsoleNotificationBanners', () => ({ ConsoleNotificationBanners: () => null }));
vi.mock('../../preview/DraftPreviewBar', () => ({ DraftPreviewBar: () => null }));
vi.mock('../../preview/UnpublishedAppBar', () => ({ UnpublishedAppBar: () => null }));

import { ConsoleLayout, isRecordPagePath } from '../ConsoleLayout';
import { NavigationProvider } from '../../context/NavigationContext';

const APP = { name: 'crm', label: 'CRM', active: true, navigation: [] };
const RECORD = '/apps/crm/crm_lead/record/r-1';

function setWidth(width: number) {
Object.defineProperty(window, 'innerWidth', { value: width, configurable: true, writable: true });
}

/** The record page's phone face, as far as this layout sees it: its bar and its title. */
function RecordPageStub() {
return (
<div data-record-page-root="">
<div data-record-top-bar="">
<a href="/apps/crm/crm_lead" data-record-crumb-back="">Leads</a>
</div>
<h1>Mira Costa</h1>
</div>
);
}

function mountAt(path: string, width: number) {
setWidth(width);
return render(
<I18nProvider instance={createI18n({ defaultLanguage: 'en', detectBrowserLanguage: false })} persistLanguage={false}>
<MemoryRouter initialEntries={[path]}>
<NavigationProvider>
<ConsoleLayout activeAppName="crm" activeApp={APP} onAppChange={vi.fn()} objects={[]}>
<RecordPageStub />
</ConsoleLayout>
</NavigationProvider>
</MemoryRouter>
</I18nProvider>,
);
}

/** Every bar across the page's top: the shell's `<header>` and the record page's top bar. */
const bars = () => [...document.querySelectorAll('header, [data-record-top-bar]')];

afterEach(() => {
cleanup();
setWidth(1440);
});

describe('a phone record page draws one bar (objectstack#28483)', () => {
it('at 390 px a record route has one bar, the record\'s own, and the record name once', () => {
mountAt(RECORD, 390);
expect(document.querySelectorAll('header')).toHaveLength(0);
expect(screen.queryByTestId('app-bar')).toBeNull();
expect(bars()).toHaveLength(1);
expect(bars()[0]).toHaveAttribute('data-record-top-bar');
expect(screen.getAllByText('Mira Costa')).toHaveLength(1);
// The way to the four doors: the bar's 「‹」, to the object's list.
expect(screen.getByRole('link', { name: 'Leads' })).toHaveAttribute('href', '/apps/crm/crm_lead');
});

it('at 390 px the same record on a named record page draws no app bar either', () => {
mountAt(`${RECORD}/page/print_sheet`, 390);
expect(document.querySelectorAll('header')).toHaveLength(0);
expect(bars()).toHaveLength(1);
});

it('at 390 px the root routes keep the app bar, the four doors with it', () => {
for (const path of ['/apps/crm', '/apps/crm/crm_lead', '/apps/crm/crm_lead/view/all', '/apps/crm/dashboard/sales']) {
mountAt(path, 390);
expect(document.querySelectorAll('header'), path).toHaveLength(1);
expect(screen.getByTestId('app-bar'), path).toBeInTheDocument();
cleanup();
}
});

it('at 390 px the record\'s edit form is not the record page: it keeps the app bar', () => {
mountAt(`${RECORD}/edit`, 390);
expect(document.querySelectorAll('header')).toHaveLength(1);
expect(screen.getByTestId('app-bar')).toBeInTheDocument();
});

it('control: at 768 px and 1440 px a record route keeps the app bar, as before', () => {
for (const width of [768, 1440]) {
mountAt(RECORD, width);
const headers = document.querySelectorAll('header');
expect(headers, `${width} px`).toHaveLength(1);
expect(headers[0]).toContainElement(screen.getByTestId('app-bar'));
cleanup();
}
});

it('at 767 px, one pixel under the breakpoint, the record route is already on its phone face', () => {
mountAt(RECORD, 767);
expect(document.querySelectorAll('header')).toHaveLength(0);
});
});

describe('isRecordPagePath (objectstack#28483)', () => {
it('names the two routes the record page draws, and nothing else', () => {
expect(isRecordPagePath(RECORD)).toBe(true);
expect(isRecordPagePath(`${RECORD}/`)).toBe(true);
expect(isRecordPagePath(`${RECORD}/page/print_sheet`)).toBe(true);
for (const path of [
'/apps/crm',
'/apps/crm/crm_lead',
'/apps/crm/crm_lead/view/all',
`${RECORD}/edit`,
'/apps/crm/crm_lead/new',
'/apps/crm/crm_lead/record',
'/apps/crm/page/home',
'/organizations/acme',
]) {
expect(isRecordPagePath(path), path).toBe(false);
}
});
});
Loading
Loading