Skip to content

Commit 3694d3b

Browse files
feat(studio): an All metadata area in the builder, per package and across packages (#28623)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbWkJsH8dxzJdrDDhxzoUM9G)_ Fixes #28561 Clause-②: no Metadata-protocol: none ## 维护者速读 - **改了什么**:Studio 构建器在四个支柱旁加了「全部元数据」区域,把 Setup 现有的元数据页(类型目录、列表、通用条目页、历史、诊断)挂进来,不复制。按包是 `/studio/<包>/metadata/…`,跨包是 `/studio/~all/metadata/…`,后者服务环境目录(岗位、能力、权限集、共享规则,`?scope=environment`)。支柱类型(object、flow、page、dashboard、report、view、permission)从区域打开时跳到对应支柱并带 `?surface=`,其余类型打开通用条目页。 - **为什么改**:ADR-0146 D1 规定 Studio 负责全部元数据,N-26a(#28236 的子卡)。以前只有 Setup 能列出所有类型。 - **风险与回滚**:没有新的写入路径。可写包的保存与 Setup 带 `?package=` 时完全相同。只读包(含切换器隐藏的内核包)上,条目页、历史、权限编辑器和新建都只读。Setup 的页面没有改动:所有新行为都挂在一个只有区域才提供的 context 上,没有 provider 时页面行为不变。回滚就是 revert 本 PR。 - **先看**:`studio-design/metadataArea.ts`(两条路由腿和跳转规则)、`studio-design/StudioMetadataArea.tsx`(区域本身与只读判断)、`metadata-admin/metadataAreaHost.ts`(页面读到的 context)。 - **两分钟验证**:打开 `/studio/<可写包>/metadata`,确认目录只列该包、顶栏「全部元数据」高亮;点一个 flow 应落到 Automations 并带 `?surface=flow:<名>`;点一个 translation 应打开通用条目页。再打开一个只读包的 translation,应看到只读提示、没有编辑或重置按钮。最后从 Studio 首页的「环境目录」进入 `/studio/~all/metadata`。 - **需要你做的**:无需决策。N-28 做 Setup 重定向时须放过 datasource:区域会把 datasource 的地址送回 `/apps/setup/metadata/datasource`,两边都重定向就会循环。 ## What changes - **The area, per package.** `/studio/<package>/metadata/…` mounts the metadata-admin pages Setup mounts, with the route shapes Setup has under `/apps/setup/metadata`. The pages are lazy and load through the same specifier (`../metadata-admin/index.js`) that `AppContent` uses. Studio and Setup share one chunk, so the builder's own chunk does not grow by them. The header links the area beside the four pillars and names it from 1280 px, because the 1024 px row was sized for the four pillar names. The console route is `:packageId/metadata/*`. Without it, `:packageId/:tab` matches one segment only, and an area URL such as `/studio/crm/metadata/flow` would match no route. - **The cross-package leg.** `/studio/~all/metadata/…` serves the environment catalog with no package: position, capability, permission and sharing_rule, each opened with `?scope=environment`. It is the area alone, in the frame the Studio landing draws. The landing's "Not in a package" row gains an "Environment catalog" card. `~all` is a reserved segment that no package id can spell (`metadata` is a legal package id, so there is no bare `/studio/metadata`). It is not `~org`, the package-less flows scope. Every other `~all` URL lands on the area. - **The query keeps its Setup meaning** (`areaLocationRedirect`): - `?package=` naming another package re-roots onto that package. - `?scope=environment` and `?package=all` move to the cross-package leg. - A package leg without `?package=` gets its own. The item page binds its saves to `?package=` (`readActivePackageBinding`). - `?review=1` opens the generic item page and its review. - **Pillar types open their pillar** with `?surface=`: object → Data, flow → Automations, page, dashboard, report and view → Interfaces, permission → Access. Every other type opens the generic item page, and so does any item on the cross-package leg, which names no package. The choice is made once per item, so the item page dropping `?review=1` after it opens the review does not send an open review on to the pillar. - **Interfaces opens an off-nav item by its link.** A page, dashboard, report or view that no nav entry opens used to land on the first nav leaf. It now opens from the package's own lists once they load, and falls back to the first leaf only when the package has no such item. This is what makes the Interfaces redirect land on the item. - **Read-only package.** The area reads `GET /api/v1/packages` through `fetchListedPackageManifests`, which keeps the kernel packages that the switcher hides. A package listed as not writable gets a read-only area: - The item page shows a package read-only banner and offers no edit, overlay save, source save, reset or delete. - The history page offers no rollback. - The permission editor offers no Save. - The list offers no create, and a create URL lands on the list. - Layers, references, history, audit and source stay. - A package the list cannot answer for stays ungated, as the pillars do. The server's write gate is the authority. - **Setup's parts stay Setup's** (ADR-0146 D7). - A position's holders and a permission set's assignments are a link to their Setup page (`SetupAssignmentsLink`), not drawn. - The datasource tile and every datasource URL in the area open Setup's Datasources page. - **Setup's pages do not change.** Every page reads the area through `MetadataAreaHostContext`, which only the area provides. With no provider each page renders as it does today. Outside the card's file surface, with reasons: - `item-tools/ResourceHistoryPage.tsx`: the routed history page gets the rollback gate and its "back to item" link under the area. - `i18n` studio `en.ts` / `zh.ts`: the six new strings and the not-found sentence naming "All metadata". - `app-shell` `src/index.ts`: exports the area and its route constants for the console. - console `studioBuilder.ts`: the one lazy boundary the console loads the builder through. ## Tests - `studio-design/metadataArea.test.ts`: the legs, every redirect rule with the case that would break it, and the pillar map. - `studio-design/StudioMetadataArea.routes-28561.test.tsx` (stub pages that print the host) covers: - mounts on a writable package, a read-only package, a kernel package, an unanswerable list, and the cross-package leg; - the environment scope re-rooting, diagnostics and history, create on a read-only package, and the datasource URL; - object, flow, view and permission landing in the right pillar with `?surface=`; - a translation opening the generic page; - `?review=1` opening and staying on the review; - a cross-package permission opening the generic page. - `metadata-admin/metadataAreaHost.gates-28561.test.tsx` (real item pages) covers: - a code-shipped translation under no area, a writable area and a read-only area; - history rollback; - the permission editor's assignments link and Save. - `metadata-admin/metadataAreaHost.scope-28561.test.tsx` (real directory and list) covers: - Setup's repair as a control, then a package pin with no repair; - the datasource tile; - the cross-package environment types and the `?scope=environment` permission list; - no create on a read-only package. - `studio-design/InterfacesPillar.offNavDeepLink-28561.test.tsx`: a view, report, page and app-less dashboard open by link, with controls. - `studio-design/StudioDesignSurface.metadataArea-28561.test.tsx` (real surface and area) covers: - the header link, and the area as current on its route; - a kernel package read-only, and a missing package with no area. - `console/StudioRoute.metadataArea-28561.test.tsx` (real `studioRoutes`) covers: - both legs, and the `~all` landings; - the entry gate refusing both legs. - Existing header tests count five tabs, and the package-less scope asserts no area link. Each new gate was checked to fail with its fix taken out. Without the area's `canWrite` gate, 2 cases fail. Without the off-nav deep link, 3 fail. Without the surface's `area` prop, 3 fail. Without the two new console routes, 4 fail. ## Acceptance notes - #28559 (M-1) is still open. The cross-package permission list shows the activation switch exactly as Setup's environment scope does. - An environment-scope item of a pillar type (a permission set on `~all`) opens the generic page, because it names no package to open a pillar on. - Under the area, `PageShell`'s metadata base is the area's root, so its `indexOf('/metadata')` derivation is not used there. - Left as Setup has them: the type-level `MetadataTypeActions`, the permission set's "Clone to customize" and the landing's "Open in Setup" overlay link (that last one is for N-28). - Not pinned by a test: QuickFind on the cross-package leg. It reads the area's root and filters item hits to the area's package. - Main's #28583 (the layered read naming no package refuses a name two packages hold) applies to the cross-package leg's item page exactly as it applies to Setup's environment scope. - Out of scope, for the seat: Setup's history page links "back to item" as `../<name>`, which may be a pre-existing quirk there. The area builds that link from its root instead. ## Verification - The new tests above (10 + 16 + 9 + 9 + 5 + 5 + 8 cases), the edited header tests (43) and the 8 console Studio route files (69): green. - The 283 existing test files that import a changed module, run in six batches: green. - `@object-ui/i18n` suite plus the studio-namespace tests (98 files): green. - Type-check of `@object-ui/app-shell` (tests included), `@object-ui/console` and `@object-ui/i18n`: green. - ESLint on the 29 changed ts/tsx files: 0 errors and 0 findings on changed lines (128 warnings already there). - `check:i18n-keys`, `check:i18n-drift` and `check:i18n-dead-keys`: green. `node scripts/check-sdui-manifest.mjs`: green. - Console `vite build`, then `check:eager-closure`: 2052.2 KB gzipped eager, against a budget of 2431.6 KB. Every per-chunk ceiling holds, and the 41 ineffective dynamic imports are all already pinned, none new. - `dispatch-gates --commands`: 34 derived, 34 run, all exit 0. `--ran` reconciles 34 of 34. - Main merged at a9c6eac. Main's 9 new commits share no file with this diff. - The dogfood battery is n/a: no runtime, protocol or seed change. - The diff is 2192 changed lines (873 source, 1319 tests), under the 3000-line Tier H line. _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jSErqBgsh8YbjzzqZ9)_ 🤖 Generated with [Claude Code](https://claude.com/claude-code) https://claude.ai/code/session_01QsR2jSErqBgsh8YbjzzqZ9 --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 5e879ea commit 3694d3b

30 files changed

Lines changed: 2134 additions & 58 deletions
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
---
2+
'@object-ui/app-shell': patch
3+
'@object-ui/console': patch
4+
'@object-ui/i18n': patch
5+
---
6+
7+
feat(studio): an "All metadata" area in the builder, per package and across packages; a pillar type opened there opens its pillar
8+
9+
Clause-②: no
10+
Metadata-protocol: none
11+
12+
Studio owns all metadata (ADR-0146 D1), and until now only Setup listed every type. The builder gains an "All metadata" area beside its four pillars. It is the metadata-admin pages Setup mounts (the type directory, the lists, the generic item page, history and diagnostics), mounted, not copied.
13+
14+
- **Per package.** `/studio/<package>/metadata/…` is the open package's area, with the same route shapes Setup has below `/apps/setup/metadata`. The header links it beside the pillars, and names it from 1280 px. The URL keeps `?package=` for the area's package, which the item page binds its saves to, so a writable package saves exactly as Setup does with `?package=`. There is no new write path.
15+
- **Across packages.** `/studio/~all/metadata/…` serves the environment catalog (position, capability, permission, sharing_rule) with `?scope=environment` and no package. The landing's "Not in a package" row opens it. `~all` is a reserved segment, so no package id spells it, and it is not the package-less flows scope (`~org`).
16+
- **Query kept.** `?package=`, `?scope=environment` and `?review=1` mean what they mean in Setup. A `?package=` naming another package re-roots onto that package. `?scope=environment` and `?package=all` move to the cross-package leg. `?review=1` opens the generic item page and its review.
17+
- **Pillar types open their pillar.** An item opened from the area opens its pillar with `?surface=`: object → Data, flow → Automations, page, dashboard, report and view → Interfaces, permission → Access. Every other type opens the generic item page. Interfaces now opens a page, dashboard, report or view that no nav entry opens by its `?surface=` link, instead of the first nav leaf.
18+
- **Read-only package.** On a package `GET /api/v1/packages` lists as not writable (a kernel package the switcher hides included), the item page is read-only and says the package is why. Layers, references, history, audit and source stay. No rollback, source save, reset, delete, create or "Customize with an overlay" save is offered. A package the list cannot answer for stays ungated, as on the pillars.
19+
- **Setup's parts stay Setup's.** In the area, a position's holders and a permission set's assignments are a link to their Setup page and are not drawn there. The datasource tile and any datasource URL open Setup's Datasources page. Setup's own pages are unchanged.
Lines changed: 174 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,174 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* objectstack#28561 — the "All metadata" area's routes in the `/studio`
5+
* subtree (ADR-0146 D1), on the REAL `studioRoutes` the console mounts.
6+
*
7+
* - A package's area (`/studio/<package>/metadata/*`) is the pillar builder
8+
* with its area open: the surface draws the header and mounts the area, so
9+
* the route hands it `area="metadata"`. `:packageId/:tab` matches one
10+
* segment, so without this route an area URL such as
11+
* `/studio/crm/metadata/flow` matched nothing.
12+
* - The cross-package leg (`/studio/~all/metadata/*`) is the area alone, with
13+
* no package, in the frame the landing draws. Every other `~all` URL lands
14+
* on it, so none reaches the pillar builder as a package id.
15+
* - The entry gate decides first, as for every Studio URL.
16+
*
17+
* The builder module is mocked with markers (as in
18+
* `StudioRoute.lazyBuilder-11798.test.tsx`); the gate, the frame and the
19+
* router are real.
20+
*/
21+
22+
import '@testing-library/jest-dom/vitest';
23+
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
24+
import { cleanup, render, screen } from '@testing-library/react';
25+
import React from 'react';
26+
import { MemoryRouter, Routes, Route, useLocation, useParams } from 'react-router-dom';
27+
28+
let auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } as unknown };
29+
30+
let permissionsFetch: (input: unknown, init?: unknown) => Promise<Response> = async () =>
31+
jsonResponse({ authenticated: true, systemPermissions: [] });
32+
33+
function jsonResponse(body: unknown, status = 200): Response {
34+
return {
35+
ok: status >= 200 && status < 300,
36+
status,
37+
headers: { get: () => null },
38+
json: async () => body,
39+
} as unknown as Response;
40+
}
41+
42+
vi.mock('@object-ui/auth', async (importOriginal) => ({
43+
...(await importOriginal<Record<string, unknown>>()),
44+
useAuth: () => auth,
45+
createAuthenticatedFetch: () => (input: unknown, init?: unknown) => permissionsFetch(input, init),
46+
}));
47+
48+
// `AuthGuard` reads `useAuth` through the auth package's own module graph; the
49+
// seam `StudioRoute.test.tsx` documents.
50+
vi.mock('../../../../packages/auth/src/useAuth', async (importOriginal) => ({
51+
...(await importOriginal<Record<string, unknown>>()),
52+
useAuth: () => auth,
53+
}));
54+
55+
// The real barrel's surface is inherited (`check:vi-mock-inherit`); the route
56+
// constants (`STUDIO_ALL_PACKAGES_SEGMENT`, `STUDIO_METADATA_AREA`,
57+
// `studioMetadataAreaPath`) are the real ones. The barrel's own builder
58+
// components are overridden with a marker no case looks for: the route reaches
59+
// them only through `./studioBuilder`.
60+
vi.mock('@object-ui/app-shell', async (importOriginal) => ({
61+
...(await importOriginal<Record<string, unknown>>()),
62+
BuilderLanding: () => <div data-testid="barrel-builder" />,
63+
StudioDesignSurface: () => <div data-testid="barrel-builder" />,
64+
StudioMetadataArea: () => <div data-testid="barrel-builder" />,
65+
ConnectedShell: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
66+
RequireOrganization: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
67+
RedirectWithSplash: () => <div data-testid="redirect" />,
68+
LoadingFallback: () => <div data-testid="loading" />,
69+
LoadingScreen: () => <div data-testid="error-screen" />,
70+
getProductName: () => 'ObjectOS',
71+
useHomePath: () => '/home',
72+
}));
73+
74+
function SurfaceMarker({ area }: { area?: string }) {
75+
const { packageId, tab } = useParams();
76+
return (
77+
<div data-testid="studio-pillar-builder" data-package={packageId} data-tab={tab ?? ''} data-area={area ?? ''} />
78+
);
79+
}
80+
81+
vi.mock('./studioBuilder', () => ({
82+
BuilderLanding: () => <div data-testid="studio-front-door" />,
83+
StudioDesignSurface: SurfaceMarker,
84+
StudioMetadataArea: ({ packageId }: { packageId: string | null }) => (
85+
<div data-testid="studio-metadata-area" data-package={String(packageId)} />
86+
),
87+
}));
88+
89+
import { STUDIO_ENTRY_CAPABILITY } from './studioEntry';
90+
import { studioRoutes } from './StudioRoute';
91+
92+
function LocationProbe() {
93+
const { pathname, search } = useLocation();
94+
return <div data-testid="pathname">{`${pathname}${search}`}</div>;
95+
}
96+
97+
function renderStudio(url: string) {
98+
return render(
99+
<MemoryRouter initialEntries={[url]}>
100+
<LocationProbe />
101+
<Routes>
102+
{studioRoutes}
103+
<Route path="/home" element={<div data-testid="home-launcher">home</div>} />
104+
</Routes>
105+
</MemoryRouter>,
106+
);
107+
}
108+
109+
const answer = (systemPermissions: string[]) => async () =>
110+
jsonResponse({ authenticated: true, userId: 'u1', systemPermissions, objects: {}, fields: {} });
111+
const location = () => screen.getByTestId('pathname').textContent;
112+
113+
beforeEach(() => {
114+
auth = { isAuthenticated: true, isLoading: false, user: { id: 'u1' } };
115+
permissionsFetch = answer([STUDIO_ENTRY_CAPABILITY]);
116+
// The frame mounts the real `AppHeader`, whose feeds answer empty here.
117+
vi.stubGlobal('fetch', vi.fn(async () => jsonResponse({ data: [] })));
118+
});
119+
120+
afterEach(() => {
121+
cleanup();
122+
vi.unstubAllGlobals();
123+
});
124+
125+
describe('/studio — the "All metadata" area\'s routes (objectstack#28561)', () => {
126+
it('a package\'s area is the pillar builder with its area open, the rest of the path its own', async () => {
127+
renderStudio('/studio/com.example.crm/metadata/flow/f1/history?package=com.example.crm');
128+
const surface = await screen.findByTestId('studio-pillar-builder');
129+
expect(surface).toHaveAttribute('data-package', 'com.example.crm');
130+
expect(surface).toHaveAttribute('data-area', 'metadata');
131+
expect(location()).toBe('/studio/com.example.crm/metadata/flow/f1/history?package=com.example.crm');
132+
expect(screen.queryByTestId('barrel-builder')).toBeNull();
133+
});
134+
135+
it('CONTROL — a pillar is the pillar builder with no area', async () => {
136+
renderStudio('/studio/com.example.crm/data');
137+
const surface = await screen.findByTestId('studio-pillar-builder');
138+
expect(surface).toHaveAttribute('data-tab', 'data');
139+
expect(surface).toHaveAttribute('data-area', '');
140+
});
141+
142+
it('the cross-package leg is the area alone, with no package, in the landing\'s frame', async () => {
143+
renderStudio('/studio/~all/metadata/permission?scope=environment');
144+
const area = await screen.findByTestId('studio-metadata-area');
145+
expect(area).toHaveAttribute('data-package', 'null');
146+
expect(screen.queryByTestId('studio-pillar-builder')).toBeNull();
147+
// The console's own top bar frames it; its brand links home.
148+
expect(screen.getByRole('link', { name: 'ObjectOS home' })).toHaveAttribute('href', '/home');
149+
expect(location()).toBe('/studio/~all/metadata/permission?scope=environment');
150+
});
151+
152+
it.each(['/studio/~all', '/studio/~all/data', '/studio/~all/flow/f1'])(
153+
'%s lands on the cross-package area, never on the builder as a package id',
154+
async (url) => {
155+
renderStudio(url);
156+
expect(await screen.findByTestId('studio-metadata-area')).toBeInTheDocument();
157+
expect(location()).toBe('/studio/~all/metadata');
158+
expect(screen.queryByTestId('studio-pillar-builder')).toBeNull();
159+
},
160+
);
161+
162+
it.each(['/studio/~all/metadata', '/studio/com.example.crm/metadata'])(
163+
'a principal the gate refuses gets neither leg: %s',
164+
async (url) => {
165+
permissionsFetch = answer(['setup.access']);
166+
renderStudio(url);
167+
expect(await screen.findByTestId('studio-access-required')).toBeInTheDocument();
168+
expect(screen.queryByTestId('studio-metadata-area')).toBeNull();
169+
expect(screen.queryByTestId('studio-pillar-builder')).toBeNull();
170+
// The refusal is said where the caller asked.
171+
expect(location()).toBe(url);
172+
},
173+
);
174+
});

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

Lines changed: 66 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -44,8 +44,11 @@ import {
4444
CommandPaletteProvider,
4545
LoadingFallback,
4646
LoadingScreen,
47+
STUDIO_ALL_PACKAGES_SEGMENT,
48+
STUDIO_METADATA_AREA,
4749
STUDIO_ORG_SCOPE_PILLAR,
4850
STUDIO_ORG_SCOPE_SEGMENT,
51+
studioMetadataAreaPath,
4952
useHomePath,
5053
useRecentItems,
5154
} from '@object-ui/app-shell';
@@ -74,6 +77,9 @@ const StudioDesignSurface = lazy(() =>
7477
const BuilderLanding = lazy(() =>
7578
import('./studioBuilder').then((m) => ({ default: m.BuilderLanding })),
7679
);
80+
const StudioMetadataArea = lazy(() =>
81+
import('./studioBuilder').then((m) => ({ default: m.StudioMetadataArea })),
82+
);
7783

7884
/**
7985
* What a principal without `studio.access` sees, at the Studio URL they opened
@@ -205,6 +211,19 @@ export function StudioBuilderLoading() {
205211
* (objectui#11912), and `⌘K` opens it.
206212
*/
207213
function StudioLanding() {
214+
return (
215+
<StudioConsoleFrame>
216+
<BuilderLanding />
217+
</StudioConsoleFrame>
218+
);
219+
}
220+
221+
/**
222+
* The console frame `/studio` screens without a package draw (objectui#11863):
223+
* the console's own top bar in its `studio` variant and the command palette in
224+
* its `studio` scope, around a body that loads with its route.
225+
*/
226+
function StudioConsoleFrame({ children }: { children: ReactNode }) {
208227
return (
209228
<CommandPaletteProvider>
210229
<div className="flex min-h-screen flex-col bg-background text-foreground">
@@ -217,16 +236,29 @@ function StudioLanding() {
217236
</header>
218237
<CommandPalette scope="studio" />
219238
<div className="min-h-0 flex-1 overflow-auto">
220-
{/* The frame above stays painted while the landing's chunk loads. */}
221-
<Suspense fallback={<StudioBuilderLoading />}>
222-
<BuilderLanding />
223-
</Suspense>
239+
{/* The frame above stays painted while the body's chunk loads. */}
240+
<Suspense fallback={<StudioBuilderLoading />}>{children}</Suspense>
224241
</div>
225242
</div>
226243
</CommandPaletteProvider>
227244
);
228245
}
229246

247+
/**
248+
* objectstack#28561 — the "All metadata" area's cross-package leg
249+
* (`/studio/~all/metadata/*`, ADR-0146 D1): the environment catalog, served
250+
* with no package. No package means no pillars, no package switcher and no
251+
* package publish, so it is not the pillar builder: it is the area alone, in
252+
* the frame the landing draws, which it is entered from.
253+
*/
254+
function StudioAllPackagesMetadata() {
255+
return (
256+
<StudioConsoleFrame>
257+
<StudioMetadataArea packageId={null} />
258+
</StudioConsoleFrame>
259+
);
260+
}
261+
230262
/**
231263
* A package's pillar builder, which records the visit as a recent `package`
232264
* entry (objectui#11863), so the landing can list the packages an author was
@@ -241,7 +273,7 @@ function StudioLanding() {
241273
* and the provider writes nothing. The package-less scope (`~org`) is not a
242274
* package and records nothing.
243275
*/
244-
function StudioPackageBuilder() {
276+
function StudioPackageBuilder({ area }: { area?: typeof STUDIO_METADATA_AREA }) {
245277
const { packageId } = useParams();
246278
const { addRecentItem } = useRecentItems();
247279
useEffect(() => {
@@ -259,7 +291,7 @@ function StudioPackageBuilder() {
259291
}, [packageId]);
260292
return (
261293
<Suspense fallback={<LoadingFallback />}>
262-
<StudioDesignSurface />
294+
<StudioDesignSurface area={area} />
263295
</Suspense>
264296
);
265297
}
@@ -273,10 +305,13 @@ function StudioPackageBuilder() {
273305
* REAL tree instead of a transcription of it — a transcription would be free to
274306
* agree with itself while `App.tsx` quietly mounted the builder ungated.
275307
*
276-
* `/studio` front door (pick / create a writable package)
277-
* `/studio/~org` the package-less scope lands on its one pillar
278-
* `/studio/:packageId` a package lands on its Data pillar
279-
* `/studio/:packageId/:tab` the pillar builder (a recent-package visit)
308+
* `/studio` front door (pick / create a writable package)
309+
* `/studio/~org` the package-less scope lands on its one pillar
310+
* `/studio/~all/metadata/*` the "All metadata" area across packages
311+
* `/studio/~all/*` lands on that area
312+
* `/studio/:packageId` a package lands on its Data pillar
313+
* `/studio/:packageId/metadata/*` a package's "All metadata" area, in the builder
314+
* `/studio/:packageId/:tab` the pillar builder (a recent-package visit)
280315
*
281316
* `~org` is the reserved segment for the organization's own, package-less
282317
* flows (objectui#11553; `studioScope.ts` in app-shell says why `~` can never
@@ -285,6 +320,15 @@ function StudioPackageBuilder() {
285320
* package" — so only its bare leg needs a route of its own: a static segment
286321
* outranks `:packageId`, and the generic leg would send it to a Data pillar the
287322
* scope does not have.
323+
*
324+
* objectstack#28561 — the "All metadata" area (ADR-0146 D1) has routes of its
325+
* own because its pages nest below it (`StudioMetadataArea` declares them), and
326+
* `:packageId/:tab` matches one segment: an area URL such as
327+
* `/studio/crm/metadata/flow` would match no route at all. The static
328+
* `metadata` segment outranks `:tab`. `~all` is the area's reserved
329+
* cross-package segment (`metadataArea.ts` in app-shell says why it is not
330+
* `~org`); every `~all` URL outside the area lands on it, so none reaches the
331+
* pillar builder as a package id.
288332
*/
289333
export const studioRoutes = (
290334
<Route path="/studio" element={<StudioRoute />}>
@@ -293,7 +337,19 @@ export const studioRoutes = (
293337
path={STUDIO_ORG_SCOPE_SEGMENT}
294338
element={<Navigate to={STUDIO_ORG_SCOPE_PILLAR} replace />}
295339
/>
340+
<Route
341+
path={`${STUDIO_ALL_PACKAGES_SEGMENT}/${STUDIO_METADATA_AREA}/*`}
342+
element={<StudioAllPackagesMetadata />}
343+
/>
344+
<Route
345+
path={`${STUDIO_ALL_PACKAGES_SEGMENT}/*`}
346+
element={<Navigate to={studioMetadataAreaPath(STUDIO_ALL_PACKAGES_SEGMENT)} replace />}
347+
/>
296348
<Route path=":packageId" element={<Navigate to="data" replace />} />
349+
<Route
350+
path={`:packageId/${STUDIO_METADATA_AREA}/*`}
351+
element={<StudioPackageBuilder area={STUDIO_METADATA_AREA} />}
352+
/>
297353
<Route path=":packageId/:tab" element={<StudioPackageBuilder />} />
298354
</Route>
299355
);

‎ui/apps/console/src/components/studioBuilder.ts‎

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,9 @@
11
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
22

33
/**
4-
* The Studio builder's two screens, as the ONE module the console loads them
5-
* through (objectui#11798).
4+
* The Studio builder's screens, as the ONE module the console loads them
5+
* through (objectui#11798): the landing, the pillar builder, and the "All
6+
* metadata" area's cross-package leg (objectstack#28561).
67
*
78
* `StudioRoute.tsx` (the `/studio` subtree) and `registerStudioComponents.tsx`
89
* (the `studio:builder` registry entry) both reach these components through
@@ -29,4 +30,4 @@
2930
* one, and `pnpm check:eager-closure` weighs the bytes that would come back.
3031
*/
3132

32-
export { BuilderLanding, StudioDesignSurface } from '@object-ui/app-shell';
33+
export { BuilderLanding, StudioDesignSurface, StudioMetadataArea } from '@object-ui/app-shell';

‎ui/packages/app-shell/src/index.ts‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -518,6 +518,16 @@ export {
518518
STUDIO_ORG_SCOPE_PILLAR,
519519
studioOrgScopePath,
520520
} from './views/studio-design/studioScope.js';
521+
// Studio's "All metadata" area (objectstack ADR-0146 D1): per package beside
522+
// the pillars, at `/studio/<package>/metadata/*`, and across packages at the
523+
// reserved `/studio/~all/metadata/*`. A host that declares the `/studio` routes
524+
// reads the segments from here, and mounts the cross-package leg's area.
525+
export {
526+
STUDIO_METADATA_AREA,
527+
STUDIO_ALL_PACKAGES_SEGMENT,
528+
studioMetadataAreaPath,
529+
} from './views/studio-design/metadataArea.js';
530+
export { StudioMetadataArea } from './views/studio-design/StudioMetadataArea.js';
521531

522532
// Setup › Packaged automation (ADR-0126 §7.4) — on/off + clone for the flows
523533
// installed packages ship. Reached through the `automation:packaged` component

0 commit comments

Comments
 (0)