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
13 changes: 13 additions & 0 deletions .changeset/11863-studio-header.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
---
'@object-ui/app-shell': minor
'@object-ui/i18n': minor
'@object-ui/console': patch
---

The Studio landing (`/studio`) gets the console's own header (objectui#11863). Its old header held one control, the product wordmark linking back to Home. It now mounts `AppHeader` with `variant="studio"`: the brand logo, then a fixed "Studio" crumb, then the header's right-hand cluster as on Home and the Workspaces page (the inbox, help, and the account menu with profile, theme, language and sign-out). The brand links to the declared landing (`useHomePath()`), as the wordmark did, so the landing and the Studio builder's Home button still name one home. No command palette is mounted on `/studio`, so the header shows no search trigger there.

**Clause-②: yes (widening; and one narrowing in `@object-ui/i18n`).** `AppHeader`'s `variant` prop accepts a fourth member, `'studio'`, beside `'app'`, `'home'` and `'orgs'`. It draws the brand and a fixed crumb, as `'orgs'` does. The union is not exported by name (the package entry exports `AppHeader` only), so a host meets it as the prop's type: a host that switches exhaustively over that type must handle `'studio'`. A call that passes no `variant` resolves it as before (`'app'` with an `appName`, `'home'` without). No export is added or removed.

`@object-ui/i18n`: each of the ten packs gains `console.studio.title` ("Studio", a product name every pack writes as is), the crumb's label, and drops `console.studio.backToHome`, the retired wordmark's tooltip, which nothing reads any more.

**Narrowed public surface (`@object-ui/i18n`).** The exported `en` pack and the `TranslationKeys` type derived from it lose one member, `console.studio.backToHome`. Code that reads `en.console.studio.backToHome`, or passes that key to `t()` expecting a translation, has to drop it. This is released as `minor` under objectui's version policy, which keeps the major aligned with `@objectstack`.
93 changes: 62 additions & 31 deletions apps/console/src/components/StudioRoute.landingI18n.test.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.

/**
* objectui#10043 — the Studio front door's wordmark tooltip is keyed, and no
* pack's language is hardcoded into it.
* The Studio front door's header speaks in keys, and no pack's language is
* hardcoded into it.
*
* The landing shipped `title=` as a raw Chinese literal: a user-visible string
* outside i18n entirely, which AGENTS.md commandment #-1 names by category
* ("component labels, buttons, titles, errors"). Its sibling one route away —
* `StudioDesignSurface`'s header Home button — has always resolved the same
* affordance through a catalogue, so the two named the same home in two
* different mechanisms and, for nine of the ten packs, two different languages.
* objectui#10043 keyed the old wordmark's tooltip, which had shipped as a raw
* Chinese literal: user-visible text outside i18n entirely, which AGENTS.md
* commandment #-1 names by category ("component labels, buttons, titles,
* errors"). objectui#11863 (Q1) replaced that wordmark bar with the console's
* own header, `AppHeader`'s `studio` variant, so the tooltip and its key
* (`console.studio.backToHome`) are retired, and what this file pins now is the
* header's fixed Studio crumb, read from `console.studio.title`, through the
* REAL `studioRoutes` tree and the real `AppHeader`.
*
* ## Why `t` answers in no natural language here
*
Expand All @@ -24,24 +26,19 @@
* ## Two-sided, and the negative side carries its own control
*
* Naming the key is necessary and not sufficient — a stray Han literal could
* sit beside a correctly keyed one. So the second assertion sweeps the rendered
* header for Han script. A script-class assertion is exactly the shape
* sit beside a correctly keyed one. So a second assertion sweeps the rendered
* landing for Han script. A script-class assertion is exactly the shape
* AGENTS.md's i18n forensics rule warns can go permanently vacuous, so the
* regex is pinned against a live positive built from a code point rather than
* trusted: without that control, a broken `HAN` would report "no Chinese on
* screen" forever, on a screen full of it.
*
* The Han sample is constructed with `String.fromCodePoint` rather than written
* out, so this file's own bytes stay ASCII under the commandment it enforces.
*
* ## Reverse verification
*
* Putting the literal back turns BOTH sides red at once: the key's sentinel is
* missing, and the sweep finds Han in a `title` attribute.
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Routes, Route } from 'react-router-dom';
Expand Down Expand Up @@ -110,8 +107,11 @@ vi.mock('@object-ui/i18n', async (importOriginal) => ({

import { studioRoutes } from './StudioRoute';

/** The key the wordmark tooltip must ask the catalogue for. */
const TOOLTIP_KEY = 'console.studio.backToHome';
/** The key the header's fixed Studio crumb must ask the catalogue for. */
const CRUMB_KEY = 'console.studio.title';

/** The retired wordmark tooltip's key: nothing on the landing may ask for it. */
const RETIRED_KEY = 'console.studio.backToHome';

/**
* Han script, the class the retired literal belonged to. `u` flag + a script
Expand All @@ -134,42 +134,73 @@ function renderStudioLanding() {
);
}

/** Every attribute value on the page: `textContent` never sees one. */
function attributeValues(): string[] {
return Array.from(document.body.querySelectorAll('*')).flatMap((el) =>
Array.from(el.attributes).map((a) => a.value),
);
}

beforeEach(() => {
vi.clearAllMocks();
// The real `AppHeader` reads its user-scoped feeds (the inbox, approvals) and
// the AI agent catalogue on mount; each answers empty here, so no request
// leaves the process.
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [] })));
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('the Han sweep is a live instrument', () => {
it('matches Han script', () => {
expect(HAN.test(HAN_SAMPLE)).toBe(true);
});

it('does not match the English this card installs', () => {
expect(HAN.test('Back to home')).toBe(false);
it('does not match the English the packs carry for the crumb', () => {
expect(HAN.test('Studio')).toBe(false);
});
});

describe('Studio landing wordmark tooltip (objectui#10043)', () => {
it('asks the catalogue for the tooltip instead of carrying a literal', async () => {
describe('Studio landing header (objectui#11863, objectui#10043)', () => {
it("draws the console header's fixed Studio crumb, asked of the catalogue", async () => {
renderStudioLanding();

expect(await screen.findByText(`«${CRUMB_KEY}»`)).toBeInTheDocument();
// The front door itself rendered under it.
expect(await screen.findByTestId('studio-front-door')).toBeInTheDocument();
});

it('asks nothing of the retired wordmark tooltip key, in text or in any attribute', async () => {
renderStudioLanding();
await screen.findByText(`«${CRUMB_KEY}»`);

expect(document.body.textContent ?? '').not.toContain(RETIRED_KEY);
const values = attributeValues();
// CONTROL: the sweep reads attributes at all.
expect(values.length).toBeGreaterThan(0);
expect(values.filter((v) => v.includes(RETIRED_KEY))).toEqual([]);
});

it('links the brand to the declared landing, which is the launcher here', async () => {
renderStudioLanding();
await screen.findByText(`«${CRUMB_KEY}»`);

const wordmark = await screen.findByTitle(`«${TOOLTIP_KEY}»`);
expect(wordmark).toBeInTheDocument();
// It is the wordmark link, not some other titled node.
expect(wordmark).toHaveTextContent('ObjectOS');
expect(screen.getByTitle('ObjectOS').closest('a')).toHaveAttribute('href', '/home');
});

it('renders no Han script anywhere on the landing, attributes included', async () => {
renderStudioLanding();
await screen.findByTestId('studio-front-door');
await screen.findByText(`«${CRUMB_KEY}»`);

expect(HAN.test(document.body.textContent ?? '')).toBe(false);

// `textContent` never sees an attribute, and the measured defect WAS an
// attribute — so sweep every one of them separately.
const attributeValues = Array.from(document.body.querySelectorAll('*')).flatMap((el) =>
Array.from(el.attributes).map((a) => a.value),
);
expect(attributeValues.length).toBeGreaterThan(0);
expect(attributeValues.filter((v) => HAN.test(v))).toEqual([]);
const values = attributeValues();
expect(values.length).toBeGreaterThan(0);
expect(values.filter((v) => HAN.test(v))).toEqual([]);
});
});
14 changes: 12 additions & 2 deletions apps/console/src/components/StudioRoute.lazyBuilder-11798.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
Expand Down Expand Up @@ -127,6 +127,15 @@ const answer = (systemPermissions: string[]) => async () =>

beforeEach(() => {
auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } };
// The landing mounts the real `AppHeader` (objectui#11863), which reads its
// user-scoped feeds and the AI agent catalogue on mount; each answers empty
// here, so no request leaves the process. The entry gate's own read goes
// through the mocked `createAuthenticatedFetch`, not this.
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [] })));
});

afterEach(() => {
vi.unstubAllGlobals();
});

describe('/studio/* — the builder module loads behind the gate (objectui#11798)', () => {
Expand Down Expand Up @@ -171,7 +180,8 @@ describe('/studio/* — the builder module loads behind the gate (objectui#11798
renderStudio('/studio');

await waitFor(() => expect(screen.getByTestId('studio-front-door')).toBeInTheDocument());
// The wordmark header is the frame the landing's fallback sits under.
// The console header is the frame the landing's fallback sits under; its
// brand links home (objectui#11863).
expect(screen.getByRole('link', { name: 'ObjectOS' })).toHaveAttribute('href', '/home');
// Both `lazy()` declarations name one specifier, so one module serves both.
expect(builderModuleLoads.count).toBe(1);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -133,10 +133,16 @@ beforeEach(() => {
auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } };
permissionsFetch = holder;
localStorage.clear();
// The landing mounts the real `AppHeader` (objectui#11863), which reads its
// user-scoped feeds and the AI agent catalogue on mount; each answers empty
// here, so no request leaves the process. The entry gate's own read goes
// through the mocked `createAuthenticatedFetch`, not this.
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [] })));
});

afterEach(() => {
cleanup();
vi.unstubAllGlobals();
});

describe('/studio/PKG/TAB records the package as a recent entry (objectui#11863)', () => {
Expand Down
21 changes: 16 additions & 5 deletions apps/console/src/components/StudioRoute.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,7 +36,7 @@
*/

import '@testing-library/jest-dom/vitest';
import { describe, it, expect, beforeEach, vi } from 'vitest';
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { render, screen, waitFor } from '@testing-library/react';
import React from 'react';
import { MemoryRouter, Routes, Route, useLocation } from 'react-router-dom';
Expand Down Expand Up @@ -207,6 +207,15 @@ beforeEach(() => {
builderLanding.mockClear();
designSurface.mockClear();
answerWith(TENANT_OWNER_CAPS);
// The landing mounts the real `AppHeader` (objectui#11863), which reads its
// user-scoped feeds and the AI agent catalogue on mount; each answers empty
// here, so no request leaves the process. The entry gate's own read goes
// through the mocked `createAuthenticatedFetch`, not this.
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [] })));
});

afterEach(() => {
vi.unstubAllGlobals();
});

/**
Expand Down Expand Up @@ -322,10 +331,12 @@ describe('/studio/* — the entry decision, both ways', () => {
expect(pathname()).toBe('/studio/hotcrm/data');
});

it("the front door's wordmark walks back to the same home the gate bounces to", async () => {
// Two affordances one route apart — this wordmark and the pillar builder's
// header Home button — must not name two different homes; that asymmetry is
// the defect objectui#7256 measured and objectui#7373 finished removing.
it("the front door's header brand walks back to the same home the gate bounces to", async () => {
// Two affordances one route apart — this brand (the `studio` variant of
// `AppHeader` since objectui#11863, the wordmark before it) and the pillar
// builder's header Home button — must not name two different homes; that
// asymmetry is the defect objectui#7256 measured and objectui#7373 finished
// removing.
answerWith(OPERATOR_CAPS);
renderStudioDeepLink('/studio/', CONTROL_PLANE_APPS);

Expand Down
39 changes: 12 additions & 27 deletions apps/console/src/components/StudioRoute.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,13 +35,13 @@
*/

import { lazy, Suspense, useEffect, type ReactNode } from 'react';
import { Link, Navigate, Outlet, Route, useParams } from 'react-router-dom';
import { Navigate, Outlet, Route, useParams } from 'react-router-dom';
import {
AppHeader,
LoadingFallback,
LoadingScreen,
STUDIO_ORG_SCOPE_PILLAR,
STUDIO_ORG_SCOPE_SEGMENT,
getProductName,
useHomePath,
useRecentItems,
} from '@object-ui/app-shell';
Expand Down Expand Up @@ -125,7 +125,7 @@ export function StudioRoute() {

/**
* What a Studio builder screen shows INSIDE an already-painted frame while its
* chunk loads (objectui#11798): the `/studio` landing under its wordmark header,
* chunk loads (objectui#11798): the `/studio` landing under its console header,
* and the `studio:builder` registry entry inside the app shell. One line of the
* shared `common.loading` text, as `registerAccountComponents` shows for its own
* lazy page. The full-screen pillar builder has no frame of its own, so it
Expand All @@ -146,34 +146,19 @@ export function StudioBuilderLoading() {
* The `/studio` front door: pick or create a writable package, or open the
* organization's package-less flows.
*
* Standalone frame — the landing must never be a navigation dead end, so the
* wordmark walks back to the platform Home.
*
* Its sibling screen inside the same frame — `StudioDesignSurface`'s header
* Home button — follows the declared landing since objectui#7373, and two
* affordances one route apart must not name two different homes (the very
* defect objectui#7256 measured), so this one reads the same hook.
*
* The wordmark's tooltip resolves through `useObjectTranslation` and the
* `console.*` bundle, which is how every other user-visible string in this
* app is written (objectui#10043; objectui#4024 ruled the same way for the
* settings screen, whose chrome was hardcoded beside a keyed sibling). It
* shipped as a raw literal in one language before that — a title attribute
* is user-visible text, which AGENTS.md commandment #-1 names by category.
* Its header is the console's own (objectui#11863): `AppHeader`'s `studio`
* variant, as `/home` mounts the `home` one, with the same account menu, inbox
* and help, and the brand, then a fixed Studio crumb. The brand links to
* `useHomePath()`, so the landing is never a navigation dead end, and it names
* the same home as `StudioDesignSurface`'s Home button one route apart
* (objectui#7256, objectui#7373). No command palette is mounted here, so the
* header draws no search trigger (objectui#11912).
*/
function StudioLanding() {
const homePath = useHomePath();
const { t } = useObjectTranslation();
return (
<div className="flex min-h-screen flex-col bg-background text-foreground">
<header className="flex shrink-0 items-center border-b px-3 py-2">
<Link
to={homePath}
title={t('console.studio.backToHome')}
className="inline-flex items-center gap-1.5 rounded-md px-1.5 py-0.5 text-[13px] font-semibold hover:bg-muted"
>
{getProductName()}
</Link>
<header className="sticky top-0 z-30 flex h-14 w-full shrink-0 items-center gap-2 border-b bg-background px-2 sm:px-4">
<AppHeader variant="studio" />
</header>
<div className="min-h-0 flex-1 overflow-auto">
{/* The frame above stays painted while the landing's chunk loads. */}
Expand Down
4 changes: 2 additions & 2 deletions packages/app-shell/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -1176,8 +1176,8 @@ go down"):

While the whole platform is pre-GA, the top bar (`AppHeader`) shows a small
**Preview** chip next to the product wordmark on every console surface (home /
app / orgs). It's rendered by `PreviewBadge`, driven by the platform stage in
runtime-config:
app / orgs / studio). It's rendered by `PreviewBadge`, driven by the platform
stage in runtime-config:

```ts
// packages/app-shell/src/runtime-config.ts — `RuntimeBranding.stage`
Expand Down
22 changes: 17 additions & 5 deletions packages/app-shell/src/layout/AppHeader.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
* logo. Used by `/home`.
* - `orgs` : no breadcrumb; logo + "Organizations" title. Used by the
* `/organizations` landing page.
* - `studio`: no breadcrumb; logo + a fixed "Studio" crumb, as `orgs` draws
* its own. Used by the `/studio` front door (objectui#11863).
*
* The user avatar dropdown includes the organization (workspace) switcher at
* the top so the same chrome lets users change orgs from any page.
Expand Down Expand Up @@ -126,7 +128,7 @@ function PathSep() {
// header doesn't ship phantom collaborators in production.
const EMPTY_PRESENCE_USERS: PresenceUser[] = [];

export type AppHeaderVariant = 'app' | 'home' | 'orgs';
export type AppHeaderVariant = 'app' | 'home' | 'orgs' | 'studio';

export interface AppHeaderProps {
variant?: AppHeaderVariant;
Expand Down Expand Up @@ -163,9 +165,10 @@ export function AppHeader({
// synthetic `⌘K` KeyboardEvent re-dispatch that did nothing under automation.
const { openCommandPalette } = useCommandPalette();
// objectui#11912 — the search trigger is drawn only where a palette is
// mounted. Outside a `CommandPaletteProvider` (the `home` / `orgs` frames:
// `/home`, `/ai`, the organizations pages) `openCommandPalette` is the inert
// fallback and no ⌘K handler is installed, so a "Search ⌘K" there opens nothing.
// mounted. Outside a `CommandPaletteProvider` (the `home` / `orgs` / `studio`
// frames: `/home`, `/ai`, the organizations pages, `/studio`)
// `openCommandPalette` is the inert fallback and no ⌘K handler is installed,
// so a "Search ⌘K" there opens nothing.
const hasCommandPalette = useCommandPaletteProviderMounted();
// Click-reachable entry for the keyboard-shortcuts dialog (was `?`-key only).
// Shares the `?shortcuts=1` URL param with KeyboardShortcutsDialog (C2/C3).
Expand Down Expand Up @@ -514,7 +517,7 @@ export function AppHeader({
)}

{/* Platform-stage chip — sits in the brand zone so it rides along on
every console surface (home / app / orgs) while the whole platform
every console surface (home / app / orgs / studio) while the whole platform
is in preview. Desktop-only to spare the crowded mobile top bar;
renders nothing once runtime-config reports GA. */}
<PreviewBadge className="ml-2 hidden sm:inline-flex" />
Expand All @@ -537,6 +540,15 @@ export function AppHeader({
</>
)}

{resolvedVariant === 'studio' && (
<>
<PathSep />
<span className="text-sm font-medium text-foreground/80 px-1.5">
{t('console.studio.title')}
</span>
</>
)}

{isApp && (
<>
{/* Keep the sidebar trigger visible through narrow desktop widths,
Expand Down
Loading
Loading