@xemahq/ui-kernel 0.11.0 → 0.11.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/ui/chrome/page-shell.d.ts +1 -1
- package/dist/ui/chrome/page-shell.d.ts.map +1 -1
- package/dist/ui/chrome/page-shell.js +9 -4
- package/dist/ui/chrome/page-shell.js.map +1 -1
- package/package.json +6 -3
- package/src/ui/chrome/page-shell.spec.tsx +117 -0
- package/src/ui/chrome/page-shell.tsx +56 -24
|
@@ -22,6 +22,6 @@ interface PageShellProps {
|
|
|
22
22
|
backTo?: PageBackTarget;
|
|
23
23
|
topbarActions?: ReactNode;
|
|
24
24
|
}
|
|
25
|
-
export default function PageShell(
|
|
25
|
+
export default function PageShell(props: Readonly<PageShellProps>): import("react").JSX.Element;
|
|
26
26
|
export {};
|
|
27
27
|
//# sourceMappingURL=page-shell.d.ts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-shell.d.ts","sourceRoot":"","sources":["../../../src/ui/chrome/page-shell.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG1E,UAAU,MAAM;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,UAAU,cAAc;IACtB,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,OAAO,CAAC,EAAE,MAAM,CAAC;IAKjB,MAAM,CAAC,EAAE,cAAc,CAAC;IAOxB,aAAa,CAAC,EAAE,SAAS,CAAC;CAC3B;
|
|
1
|
+
{"version":3,"file":"page-shell.d.ts","sourceRoot":"","sources":["../../../src/ui/chrome/page-shell.tsx"],"names":[],"mappings":"AAAA,OAAO,EAAgB,KAAK,UAAU,EAAE,MAAM,cAAc,CAAC;AAC7D,OAAO,EAAE,KAAK,SAAS,EAAE,MAAM,OAAO,CAAC;AAGvC,OAAO,EAAiB,KAAK,cAAc,EAAE,MAAM,sBAAsB,CAAC;AAG1E,UAAU,MAAM;IACd,KAAK,EAAE,MAAM,CAAC;IACd,KAAK,EAAE,MAAM,GAAG,SAAS,CAAC;IAC1B,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,SAAS,CAAC,EAAE,OAAO,CAAC;CACrB;AAED,UAAU,cAAc;IACtB,IAAI,EAAE,UAAU,CAAC;IACjB,KAAK,EAAE,MAAM,CAAC;IACd,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,EAAE,SAAS,CAAC;IACrB,OAAO,CAAC,EAAE,SAAS,CAAC;IACpB,IAAI,CAAC,EAAE,MAAM,EAAE,CAAC;IAChB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,WAAW,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACtC,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,QAAQ,CAAC,EAAE,OAAO,CAAC;IAEnB,OAAO,CAAC,EAAE,MAAM,CAAC;IAKjB,MAAM,CAAC,EAAE,cAAc,CAAC;IAOxB,aAAa,CAAC,EAAE,SAAS,CAAC;CAC3B;AAsBD,MAAM,CAAC,OAAO,UAAU,SAAS,CAAC,KAAK,EAAE,QAAQ,CAAC,cAAc,CAAC,+BAMhE"}
|
|
@@ -3,7 +3,15 @@ import { FileQuestion } from 'lucide-react';
|
|
|
3
3
|
import EmptyState from './EmptyState';
|
|
4
4
|
import { useHostBridge } from '../../lib/biome-host';
|
|
5
5
|
import { Tabs, TabsList, TabsTrigger } from '../primitives/tabs';
|
|
6
|
-
export default function PageShell(
|
|
6
|
+
export default function PageShell(props) {
|
|
7
|
+
return props.embedded ? (_jsx(EmbeddedPageShell, { ...props })) : (_jsx(FullPageShell, { ...props }));
|
|
8
|
+
}
|
|
9
|
+
function EmbeddedPageShell({ children, actions, tabs, activeTab, onTabChange, }) {
|
|
10
|
+
{
|
|
11
|
+
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col bg-paper", children: [(tabs && tabs.length > 0) || actions ? (_jsx("div", { className: "shrink-0 border-b border-rule bg-paper-elev/40", children: _jsxs("div", { className: "flex items-center gap-3 px-4 py-2 lg:px-6", children: [tabs && tabs.length > 0 && (_jsx(Tabs, { value: activeTab, onValueChange: onTabChange, className: "flex-1 min-w-0", children: _jsx("div", { className: "overflow-x-auto", children: _jsx(TabsList, { className: "h-7 bg-transparent p-0 gap-0", children: tabs.map((tab) => (_jsxs(TabsTrigger, { value: tab.value, className: "rounded-none border-b-2 border-transparent px-2 py-1 text-body-2 font-medium text-ink-3 transition-colors data-[state=active]:border-primary data-[state=active]:text-ink data-[state=active]:bg-transparent data-[state=active]:shadow-none", children: [tab.label, tab.count !== undefined && (_jsx("span", { className: "ml-1.5 text-caption tabular-nums opacity-70", children: tab.count })), tab.indicator && (_jsx("span", { className: "ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-warning" }))] }, tab.value))) }) }) })), !tabs && _jsx("div", { className: "flex-1" }), actions && (_jsx("div", { className: "flex items-center gap-2 shrink-0", children: actions }))] }) })) : null, _jsx("div", { className: "flex-1 overflow-auto min-h-0", children: _jsx("div", { className: "flex h-full min-h-0 flex-col px-4 pb-4 pt-3 lg:px-6", children: children }) })] }));
|
|
12
|
+
}
|
|
13
|
+
}
|
|
14
|
+
function FullPageShell({ title, description, children, actions, tabs, activeTab, onTabChange, maxWidth, eyebrow, backTo, topbarActions, }) {
|
|
7
15
|
useHostBridge().pageMeta.usePageMeta({
|
|
8
16
|
title,
|
|
9
17
|
description,
|
|
@@ -11,9 +19,6 @@ export default function PageShell({ icon: _Icon, title, description, children, a
|
|
|
11
19
|
backTo,
|
|
12
20
|
topbarActions,
|
|
13
21
|
});
|
|
14
|
-
if (embedded) {
|
|
15
|
-
return (_jsxs("div", { className: "flex h-full min-h-0 flex-col bg-paper", children: [(tabs && tabs.length > 0) || actions ? (_jsx("div", { className: "shrink-0 border-b border-rule bg-paper-elev/40", children: _jsxs("div", { className: "flex items-center gap-3 px-4 py-2 lg:px-6", children: [tabs && tabs.length > 0 && (_jsx(Tabs, { value: activeTab, onValueChange: onTabChange, className: "flex-1 min-w-0", children: _jsx("div", { className: "overflow-x-auto", children: _jsx(TabsList, { className: "h-7 bg-transparent p-0 gap-0", children: tabs.map((tab) => (_jsxs(TabsTrigger, { value: tab.value, className: "rounded-none border-b-2 border-transparent px-2 py-1 text-body-2 font-medium text-ink-3 transition-colors data-[state=active]:border-primary data-[state=active]:text-ink data-[state=active]:bg-transparent data-[state=active]:shadow-none", children: [tab.label, tab.count !== undefined && (_jsx("span", { className: "ml-1.5 text-caption tabular-nums opacity-70", children: tab.count })), tab.indicator && (_jsx("span", { className: "ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-warning" }))] }, tab.value))) }) }) })), !tabs && _jsx("div", { className: "flex-1" }), actions && (_jsx("div", { className: "flex items-center gap-2 shrink-0", children: actions }))] }) })) : null, _jsx("div", { className: "flex-1 overflow-auto min-h-0", children: _jsx("div", { className: "flex h-full min-h-0 flex-col px-4 pb-4 pt-3 lg:px-6", children: children }) })] }));
|
|
16
|
-
}
|
|
17
22
|
return (_jsxs("div", { className: `flex h-full min-h-0 flex-col ${maxWidth ?? ''}`, children: [(tabs || actions) && (_jsx("div", { className: "shrink-0 border-b border-border/50 px-4 sm:px-5 lg:px-8", children: _jsxs("div", { className: "flex min-h-[34px] flex-col gap-2 py-1.5 sm:flex-row sm:items-center sm:gap-3 sm:py-0", children: [tabs && tabs.length > 0 && (_jsx(Tabs, { value: activeTab, onValueChange: onTabChange, className: "flex-1 min-w-0", children: _jsx("div", { className: "overflow-x-auto", children: _jsx(TabsList, { className: "h-7 bg-transparent p-0 gap-0", children: tabs.map((tab) => (_jsxs(TabsTrigger, { value: tab.value, className: "whitespace-nowrap rounded-none border-b-2 border-transparent px-2.5 py-1.5 text-caption font-medium text-ink-3/90 transition-colors data-[state=active]:border-primary data-[state=active]:bg-transparent data-[state=active]:text-ink data-[state=active]:shadow-none hover:text-ink", children: [tab.label, tab.count !== undefined && (_jsx("span", { className: "ml-1.5 text-caption tabular-nums opacity-60", children: tab.count })), tab.indicator && (_jsx("span", { className: "ml-1.5 inline-block h-1.5 w-1.5 rounded-full bg-warning" }))] }, tab.value))) }) }) })), !tabs && _jsx("div", { className: "flex-1" }), actions && (_jsx("div", { className: "flex w-full flex-wrap items-center gap-2 sm:w-auto sm:shrink-0 sm:justify-end", children: actions }))] }) })), _jsx("div", { className: "flex min-h-0 flex-1 flex-col overflow-auto px-4 pb-5 pt-4 sm:px-6 sm:pb-6 sm:pt-5", children: children || (_jsx(EmptyState, { icon: FileQuestion, title: "Nothing to show yet", description: "This page has no content to display. Check back once the workspace has data." })) })] }));
|
|
18
23
|
}
|
|
19
24
|
//# sourceMappingURL=page-shell.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"page-shell.js","sourceRoot":"","sources":["../../../src/ui/chrome/page-shell.tsx"],"names":[],"mappings":";AAAA,OAAO,EAAE,YAAY,EAAmB,MAAM,cAAc,CAAC;AAG7D,OAAO,UAAU,MAAM,cAAc,CAAC;AACtC,OAAO,EAAE,aAAa,EAAuB,MAAM,sBAAsB,CAAC;AAC1E,OAAO,EAAE,IAAI,EAAE,QAAQ,EAAE,WAAW,EAAE,MAAM,oBAAoB,CAAC;
|
|
1
|
+
{"version":3,"file":"page-shell.js","sourceRoot":"","sources":["../../../src/ui/chrome/page-shell.tsx"],"names":[],"mappings":"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"}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@xemahq/ui-kernel",
|
|
3
|
-
"version": "0.11.
|
|
3
|
+
"version": "0.11.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"sideEffects": false,
|
|
6
6
|
"description": "Host-framework-agnostic UI kernel for the Xema OS. Defines the SystemBus orchestration contract (capability.invoke, cross-biome intents, command palette, xema:// deeplinks, window manager) AND the biome-host contract surface (FrontendBiome/FrontendBiomeFactory, HostBridge, the singleton biomeRegistry, session contributions) that every frontend biome composes against. No Vite, Next.js, or React-Router — React itself IS allowed as the shared component model (the contracts traffic in ReactNode/ComponentType and a React context). Concrete host adapters (router/auth/toast wiring) live in separate packages that consume this kernel. The SystemBus is pure orchestration: it never authorizes, the backend capability-router enforces all policy.",
|
|
@@ -74,10 +74,13 @@
|
|
|
74
74
|
"@tanstack/react-query": "^5.100.13",
|
|
75
75
|
"@types/node": "25.2.3",
|
|
76
76
|
"@types/react": "^18.3.30",
|
|
77
|
+
"@types/react-dom": "^18.3.7",
|
|
78
|
+
"@xemahq/kernel-contracts": "^8.0.0",
|
|
77
79
|
"prettier": "3.6.2",
|
|
78
80
|
"react": "^18.3.1",
|
|
81
|
+
"react-dom": "^18.3.1",
|
|
79
82
|
"typescript": "5.9.3",
|
|
80
|
-
"
|
|
83
|
+
"vitest": "^3.2.4"
|
|
81
84
|
},
|
|
82
85
|
"exports": {
|
|
83
86
|
".": {
|
|
@@ -109,7 +112,7 @@
|
|
|
109
112
|
"scripts": {
|
|
110
113
|
"clean": "rm -rf dist",
|
|
111
114
|
"build": "tsc -p tsconfig.json",
|
|
112
|
-
"test": "echo '[ui-kernel] *.{test,spec} files
|
|
115
|
+
"test": "echo '[ui-kernel] *.{test,spec} files RUN under host-web vitest (this package has no vitest runner config). vitest is a devDependency here for TYPES only, so tsc typechecks the specs in the repo that owns them.'",
|
|
113
116
|
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
|
114
117
|
"lint": "eslint .",
|
|
115
118
|
"typecheck": "tsc -p tsconfig.lint.json --noEmit"
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* An EMBEDDED PageShell must not touch the topbar.
|
|
3
|
+
*
|
|
4
|
+
* ── THE DEFECT ────────────────────────────────────────────────────────────
|
|
5
|
+
*
|
|
6
|
+
* `embedded` means "the host page already owns the topbar — render only my
|
|
7
|
+
* body". But `usePageMeta` was called UNCONDITIONALLY, above the `embedded`
|
|
8
|
+
* check, and the page-meta store is a STACK: "nested pages can push their own
|
|
9
|
+
* meta; on unmount the outer meta is restored".
|
|
10
|
+
*
|
|
11
|
+
* So an embedded shell pushed its own `title`, `description`, `eyebrow`,
|
|
12
|
+
* `backTo` and `topbarActions` OVER its host's — the precise opposite of what
|
|
13
|
+
* embedding means. The host's title and back button silently became the
|
|
14
|
+
* child's for as long as the child was mounted, and the page-meta contract
|
|
15
|
+
* ("never render a back button inside the page body… move it into
|
|
16
|
+
* usePageMeta") made that the ONLY back affordance on screen, so it pointed at
|
|
17
|
+
* the wrong parent.
|
|
18
|
+
*
|
|
19
|
+
* A hook cannot be called conditionally, so the fix is structural: `PageShell`
|
|
20
|
+
* calls no hooks and only chooses between two components. These tests pin the
|
|
21
|
+
* OBSERVABLE property — whether meta is registered — not the file layout, so
|
|
22
|
+
* any future refactor that reintroduces the push fails them.
|
|
23
|
+
*
|
|
24
|
+
* Runs under the host-web vitest mirror (this package carries no vitest dep).
|
|
25
|
+
*/
|
|
26
|
+
import { FileQuestion } from 'lucide-react';
|
|
27
|
+
import { renderToStaticMarkup } from 'react-dom/server';
|
|
28
|
+
import { describe, expect, it, vi, beforeEach } from 'vitest';
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
const registered: unknown[] = [];
|
|
32
|
+
|
|
33
|
+
// The kernel reaches the page-meta store through the HostBridge singleton,
|
|
34
|
+
// never a host import — so the bridge is the seam to observe.
|
|
35
|
+
vi.mock('../../lib/biome-host/host-bridge', async () => {
|
|
36
|
+
const actual = await vi.importActual<Record<string, unknown>>(
|
|
37
|
+
'../../lib/biome-host/host-bridge',
|
|
38
|
+
);
|
|
39
|
+
return {
|
|
40
|
+
...actual,
|
|
41
|
+
useHostBridge: () => ({
|
|
42
|
+
pageMeta: {
|
|
43
|
+
usePageMeta: (meta: unknown) => {
|
|
44
|
+
registered.push(meta);
|
|
45
|
+
},
|
|
46
|
+
},
|
|
47
|
+
}),
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
const { default: PageShell } = await import('./page-shell');
|
|
52
|
+
|
|
53
|
+
beforeEach(() => {
|
|
54
|
+
registered.length = 0;
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
const META = {
|
|
58
|
+
icon: FileQuestion,
|
|
59
|
+
title: 'Child Title',
|
|
60
|
+
description: 'Child description',
|
|
61
|
+
eyebrow: 'Child eyebrow',
|
|
62
|
+
backTo: { to: '/child-parent', label: 'Child parent' },
|
|
63
|
+
} as const;
|
|
64
|
+
|
|
65
|
+
describe('PageShell page-meta ownership', () => {
|
|
66
|
+
it('registers NOTHING when embedded — the host owns the topbar', () => {
|
|
67
|
+
// THE BUG, directly. Before the fix this registered the child's meta,
|
|
68
|
+
// replacing the host's title and back button on a stack-based store.
|
|
69
|
+
renderToStaticMarkup(
|
|
70
|
+
<PageShell {...META} embedded>
|
|
71
|
+
<p>body</p>
|
|
72
|
+
</PageShell>,
|
|
73
|
+
);
|
|
74
|
+
expect(registered).toEqual([]);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('registers its meta when NOT embedded — a full page owns its topbar', () => {
|
|
78
|
+
// The other half: the fix must not silence the normal case.
|
|
79
|
+
renderToStaticMarkup(
|
|
80
|
+
<PageShell {...META}>
|
|
81
|
+
<p>body</p>
|
|
82
|
+
</PageShell>,
|
|
83
|
+
);
|
|
84
|
+
expect(registered).toHaveLength(1);
|
|
85
|
+
expect(registered[0]).toMatchObject({
|
|
86
|
+
title: 'Child Title',
|
|
87
|
+
description: 'Child description',
|
|
88
|
+
eyebrow: 'Child eyebrow',
|
|
89
|
+
backTo: { to: '/child-parent' },
|
|
90
|
+
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('still renders the embedded body — not registering is not "render nothing"', () => {
|
|
94
|
+
const html = renderToStaticMarkup(
|
|
95
|
+
<PageShell {...META} embedded>
|
|
96
|
+
<p>embedded-body-marker</p>
|
|
97
|
+
</PageShell>,
|
|
98
|
+
);
|
|
99
|
+
expect(html).toContain('embedded-body-marker');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('renders the embedded sub-tab strip, which is the child’s own chrome', () => {
|
|
103
|
+
// Embedding suppresses the TOPBAR meta, not the child's in-body tabs.
|
|
104
|
+
const html = renderToStaticMarkup(
|
|
105
|
+
<PageShell
|
|
106
|
+
{...META}
|
|
107
|
+
embedded
|
|
108
|
+
tabs={[{ value: 'a', label: 'Alpha' }]}
|
|
109
|
+
activeTab="a"
|
|
110
|
+
>
|
|
111
|
+
<p>body</p>
|
|
112
|
+
</PageShell>,
|
|
113
|
+
);
|
|
114
|
+
expect(html).toContain('Alpha');
|
|
115
|
+
expect(registered).toEqual([]);
|
|
116
|
+
});
|
|
117
|
+
});
|
|
@@ -44,37 +44,41 @@ interface PageShellProps {
|
|
|
44
44
|
* page-meta store) and renders an optional tab strip + action row above the
|
|
45
45
|
* scrollable content canvas. Host-framework-agnostic: reaches the page-meta
|
|
46
46
|
* store through the kernel `HostBridge` singleton, never a host import.
|
|
47
|
+
*
|
|
48
|
+
* ── WHY THIS DISPATCHES INSTEAD OF BRANCHING ──────────────────────────────
|
|
49
|
+
*
|
|
50
|
+
* `embedded` means "the host page already owns the topbar — render only my
|
|
51
|
+
* body". But `usePageMeta` used to be called UNCONDITIONALLY, above the
|
|
52
|
+
* `embedded` check, and the page-meta store is a STACK ("nested pages can push
|
|
53
|
+
* their own meta; on unmount the outer meta is restored"). So an embedded shell
|
|
54
|
+
* pushed its own title, description, eyebrow, backTo and topbarActions OVER its
|
|
55
|
+
* host's — the precise opposite of what embedding means. The host's back button
|
|
56
|
+
* and title silently became the child's for as long as the child was mounted.
|
|
57
|
+
*
|
|
58
|
+
* A hook cannot be called conditionally, so the fix is structural: this
|
|
59
|
+
* component calls NO hooks and only chooses. The embedded variant never reaches
|
|
60
|
+
* the page-meta hook because it is a different component, not a later branch.
|
|
47
61
|
*/
|
|
48
|
-
export default function PageShell({
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
62
|
+
export default function PageShell(props: Readonly<PageShellProps>) {
|
|
63
|
+
return props.embedded ? (
|
|
64
|
+
<EmbeddedPageShell {...props} />
|
|
65
|
+
) : (
|
|
66
|
+
<FullPageShell {...props} />
|
|
67
|
+
);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Embedded body. Registers NO page meta — the host page owns the topbar.
|
|
72
|
+
* Renders only the child content plus an optional sub-tab strip + actions.
|
|
73
|
+
*/
|
|
74
|
+
function EmbeddedPageShell({
|
|
52
75
|
children,
|
|
53
76
|
actions,
|
|
54
77
|
tabs,
|
|
55
78
|
activeTab,
|
|
56
79
|
onTabChange,
|
|
57
|
-
maxWidth,
|
|
58
|
-
embedded,
|
|
59
|
-
eyebrow,
|
|
60
|
-
backTo,
|
|
61
|
-
topbarActions,
|
|
62
80
|
}: Readonly<PageShellProps>) {
|
|
63
|
-
|
|
64
|
-
// PageShell call the page-meta hook directly; PageShell wraps the same
|
|
65
|
-
// hook so consumers don't need to register meta twice.
|
|
66
|
-
useHostBridge().pageMeta.usePageMeta({
|
|
67
|
-
title,
|
|
68
|
-
description,
|
|
69
|
-
eyebrow,
|
|
70
|
-
backTo,
|
|
71
|
-
topbarActions,
|
|
72
|
-
});
|
|
73
|
-
if (embedded) {
|
|
74
|
-
// Embedded mode: the host page already provides the title + tabs +
|
|
75
|
-
// context, so we render ONLY the child content + any sub-tab strip +
|
|
76
|
-
// actions inline. This eliminates the legacy duplicate icon-and-title
|
|
77
|
-
// row that previously stole vertical space.
|
|
81
|
+
{
|
|
78
82
|
return (
|
|
79
83
|
<div className="flex h-full min-h-0 flex-col bg-paper">
|
|
80
84
|
{(tabs && tabs.length > 0) || actions ? (
|
|
@@ -126,6 +130,34 @@ export default function PageShell({
|
|
|
126
130
|
</div>
|
|
127
131
|
);
|
|
128
132
|
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Full page. Owns the topbar meta for the route it renders.
|
|
137
|
+
*/
|
|
138
|
+
function FullPageShell({
|
|
139
|
+
title,
|
|
140
|
+
description,
|
|
141
|
+
children,
|
|
142
|
+
actions,
|
|
143
|
+
tabs,
|
|
144
|
+
activeTab,
|
|
145
|
+
onTabChange,
|
|
146
|
+
maxWidth,
|
|
147
|
+
eyebrow,
|
|
148
|
+
backTo,
|
|
149
|
+
topbarActions,
|
|
150
|
+
}: Readonly<PageShellProps>) {
|
|
151
|
+
// Single source of truth for the topbar title. Pages that bypass
|
|
152
|
+
// PageShell call the page-meta hook directly; PageShell wraps the same
|
|
153
|
+
// hook so consumers don't need to register meta twice.
|
|
154
|
+
useHostBridge().pageMeta.usePageMeta({
|
|
155
|
+
title,
|
|
156
|
+
description,
|
|
157
|
+
eyebrow,
|
|
158
|
+
backTo,
|
|
159
|
+
topbarActions,
|
|
160
|
+
});
|
|
129
161
|
|
|
130
162
|
return (
|
|
131
163
|
<div className={`flex h-full min-h-0 flex-col ${maxWidth ?? ''}`}>
|