cortena-ui 1.4.2 → 1.6.0
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/CHANGELOG.md +107 -0
- package/LICENSE +7 -0
- package/README.md +235 -3
- package/dist/a2ui/views.js +2 -2
- package/dist/agent-chat/a2ui-block.d.ts +60 -0
- package/dist/agent-chat/a2ui-block.js +69 -0
- package/dist/agent-chat/a2ui-block.js.map +1 -0
- package/dist/agent-chat/agui-client.d.ts +40 -0
- package/dist/agent-chat/agui-client.js +251 -0
- package/dist/agent-chat/agui-client.js.map +1 -0
- package/dist/agent-chat/bridge.d.ts +109 -0
- package/dist/agent-chat/bridge.js +353 -0
- package/dist/agent-chat/bridge.js.map +1 -0
- package/dist/agent-chat/session.d.ts +79 -0
- package/dist/agent-chat/session.js +391 -0
- package/dist/agent-chat/session.js.map +1 -0
- package/dist/agent-chat/step-label.d.ts +99 -0
- package/dist/agent-chat/step-label.js +116 -0
- package/dist/agent-chat/step-label.js.map +1 -0
- package/dist/agent-chat/store.d.ts +102 -0
- package/dist/agent-chat/store.js +876 -0
- package/dist/agent-chat/store.js.map +1 -0
- package/dist/agent-chat/types.d.ts +277 -0
- package/dist/agent-chat/types.js +17 -0
- package/dist/agent-chat/types.js.map +1 -0
- package/dist/agent-chat.d.ts +11 -0
- package/dist/agent-chat.js +11 -0
- package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
- package/dist/components/admin-permissions/admin-permissions.js +101 -0
- package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
- package/dist/components/admin-permissions/context.d.ts +70 -0
- package/dist/components/admin-permissions/context.js +258 -0
- package/dist/components/admin-permissions/context.js.map +1 -0
- package/dist/components/admin-permissions/index.d.ts +10 -0
- package/dist/components/admin-permissions/licence.d.ts +15 -0
- package/dist/components/admin-permissions/licence.js +78 -0
- package/dist/components/admin-permissions/licence.js.map +1 -0
- package/dist/components/admin-permissions/matrix.d.ts +20 -0
- package/dist/components/admin-permissions/matrix.js +191 -0
- package/dist/components/admin-permissions/matrix.js.map +1 -0
- package/dist/components/admin-permissions/members.d.ts +18 -0
- package/dist/components/admin-permissions/members.js +185 -0
- package/dist/components/admin-permissions/members.js.map +1 -0
- package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
- package/dist/components/admin-permissions/role-assignment.js +174 -0
- package/dist/components/admin-permissions/role-assignment.js.map +1 -0
- package/dist/components/admin-permissions/roles.d.ts +25 -0
- package/dist/components/admin-permissions/roles.js +168 -0
- package/dist/components/admin-permissions/roles.js.map +1 -0
- package/dist/components/admin-permissions/types.d.ts +152 -0
- package/dist/components/admin-permissions/types.js +63 -0
- package/dist/components/admin-permissions/types.js.map +1 -0
- package/dist/components/agent-chat-popup.d.ts +29 -0
- package/dist/components/agent-chat-popup.js +188 -0
- package/dist/components/agent-chat-popup.js.map +1 -0
- package/dist/components/agent-chat.d.ts +163 -0
- package/dist/components/agent-chat.js +673 -0
- package/dist/components/agent-chat.js.map +1 -0
- package/dist/components/app-shell.d.ts +126 -0
- package/dist/components/app-shell.js +297 -0
- package/dist/components/app-shell.js.map +1 -0
- package/dist/components/badge.d.ts +1 -1
- package/dist/components/button-link.js +1 -1
- package/dist/components/button.d.ts +1 -1
- package/dist/components/checkbox.d.ts +1 -1
- package/dist/components/combobox.d.ts +1 -1
- package/dist/components/combobox.js +1 -1
- package/dist/components/consent-screen.d.ts +65 -0
- package/dist/components/consent-screen.js +123 -0
- package/dist/components/consent-screen.js.map +1 -0
- package/dist/components/data-table/data-table.d.ts +15 -1
- package/dist/components/data-table/data-table.js +18 -4
- package/dist/components/data-table/data-table.js.map +1 -1
- package/dist/components/data-table/index.d.ts +4 -4
- package/dist/components/data-table/parts.d.ts +27 -3
- package/dist/components/data-table/parts.js +175 -55
- package/dist/components/data-table/parts.js.map +1 -1
- package/dist/components/data-table/types.d.ts +61 -0
- package/dist/components/data-table/use-data-table.js +91 -6
- package/dist/components/data-table/use-data-table.js.map +1 -1
- package/dist/components/data-table/use-server-source.js +119 -28
- package/dist/components/data-table/use-server-source.js.map +1 -1
- package/dist/components/help-panel.d.ts +131 -0
- package/dist/components/help-panel.js +545 -0
- package/dist/components/help-panel.js.map +1 -0
- package/dist/components/login-screen.d.ts +127 -0
- package/dist/components/login-screen.js +339 -0
- package/dist/components/login-screen.js.map +1 -0
- package/dist/components/session-guard.d.ts +268 -0
- package/dist/components/session-guard.js +632 -0
- package/dist/components/session-guard.js.map +1 -0
- package/dist/components/toast.d.ts +1 -1
- package/dist/core.d.ts +5 -1
- package/dist/core.js +11 -7
- package/dist/data-table.d.ts +13 -4
- package/dist/data-table.js +10 -2
- package/dist/hooks/use-cortena-theme.js +49 -3
- package/dist/hooks/use-cortena-theme.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +21 -8
- package/dist/markdown.d.ts +2 -1
- package/dist/markdown.js +2 -1
- package/package.json +18 -5
- package/src/agent-chat/a2ui-block.ts +118 -0
- package/src/agent-chat/agui-client.ts +405 -0
- package/src/agent-chat/bridge.ts +445 -0
- package/src/agent-chat/session.ts +549 -0
- package/src/agent-chat/step-label.ts +177 -0
- package/src/agent-chat/store.ts +1234 -0
- package/src/agent-chat/types.ts +308 -0
- package/src/components/admin-permissions/admin-permissions.tsx +130 -0
- package/src/components/admin-permissions/context.tsx +376 -0
- package/src/components/admin-permissions/index.tsx +32 -0
- package/src/components/admin-permissions/licence.tsx +84 -0
- package/src/components/admin-permissions/matrix.tsx +257 -0
- package/src/components/admin-permissions/members.tsx +204 -0
- package/src/components/admin-permissions/role-assignment.tsx +239 -0
- package/src/components/admin-permissions/roles.tsx +169 -0
- package/src/components/admin-permissions/types.ts +231 -0
- package/src/components/agent-chat-popup.tsx +289 -0
- package/src/components/agent-chat.tsx +1006 -0
- package/src/components/app-shell.tsx +502 -0
- package/src/components/consent-screen.tsx +239 -0
- package/src/components/data-table/data-table.tsx +36 -0
- package/src/components/data-table/index.tsx +6 -1
- package/src/components/data-table/parts.tsx +223 -47
- package/src/components/data-table/types.ts +68 -0
- package/src/components/data-table/use-data-table.ts +152 -4
- package/src/components/data-table/use-server-source.ts +150 -12
- package/src/components/help-panel.tsx +765 -0
- package/src/components/login-screen.tsx +479 -0
- package/src/components/session-guard.tsx +1071 -0
- package/src/entries/agent-chat.ts +137 -0
- package/src/entries/core.ts +8 -0
- package/src/entries/data-table.ts +41 -0
- package/src/entries/markdown.ts +25 -0
- package/src/hooks/use-cortena-theme.ts +63 -4
- package/src/index.ts +6 -0
|
@@ -0,0 +1,502 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { getMark } from "cortena-design/marks";
|
|
4
|
+
import { CircleQuestionMark, LogOut, Monitor, Moon, Settings, Sun, User } from "lucide-react";
|
|
5
|
+
import * as React from "react";
|
|
6
|
+
import { Avatar, AvatarFallback, AvatarImage } from "@/components/avatar";
|
|
7
|
+
import {
|
|
8
|
+
DropdownMenu,
|
|
9
|
+
DropdownMenuContent,
|
|
10
|
+
DropdownMenuItem,
|
|
11
|
+
DropdownMenuSeparator,
|
|
12
|
+
DropdownMenuTrigger,
|
|
13
|
+
} from "@/components/dropdown-menu";
|
|
14
|
+
import { type CortenaTheme, useCortenaTheme } from "@/hooks/use-cortena-theme";
|
|
15
|
+
import { cn } from "@/lib/cn";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* AppShell — the chrome every Cortena extension wears, so that moving between
|
|
19
|
+
* two of them is moving between two screens of one product rather than between
|
|
20
|
+
* two applications (how-to-create-a-cortena-extension §9, audit rule P-10).
|
|
21
|
+
*
|
|
22
|
+
* Four things are fixed, and they are fixed because an extension that places
|
|
23
|
+
* them itself places them somewhere slightly different:
|
|
24
|
+
*
|
|
25
|
+
* top left the extension's name and its registered mark, 20px inside a
|
|
26
|
+
* 32px square. The mark comes from `cortena-design/marks` by
|
|
27
|
+
* `extension.id` — the same file the Apps tile, the MCP app
|
|
28
|
+
* card, the agent pop-up pill and the favicon draw (§9.1), not
|
|
29
|
+
* an SVG in this app's web/public.
|
|
30
|
+
* top right the avatar menu. It is the ONLY settings entry point:
|
|
31
|
+
* Profile, Settings, Log out. Not a gear in a sidebar as well
|
|
32
|
+
* (§9.2).
|
|
33
|
+
* bottom right one fixed cluster, theme toggle then help button, left to
|
|
34
|
+
* right, 24px from both viewport edges, 40px each, 12px apart
|
|
35
|
+
* (§9.3). The agent pop-up pill stacks above it.
|
|
36
|
+
* everywhere the theme is `useCortenaTheme`, so the choice persists and a
|
|
37
|
+
* host `postMessage` still wins.
|
|
38
|
+
*
|
|
39
|
+
* The help panel itself is EXTBP-24 and does not exist yet, so `helpPanel` is a
|
|
40
|
+
* slot: pass a node or a render function and the shell owns the button, the
|
|
41
|
+
* open state and the corner. When HelpPanel ships, `help.source` is what it
|
|
42
|
+
* reads — the extension's functional document (§12) — and the default panel
|
|
43
|
+
* replaces the slot without a call-site change.
|
|
44
|
+
*/
|
|
45
|
+
|
|
46
|
+
/* ── the mark ────────────────────────────────────────────────────────────── */
|
|
47
|
+
|
|
48
|
+
export interface BrandMarkProps extends Omit<React.ComponentProps<"span">, "children"> {
|
|
49
|
+
/** Registered mark id — what the extension puts in `x-cortena.icon`. */
|
|
50
|
+
id: string;
|
|
51
|
+
/** The extension name. Labels the mark, and supplies the fallback initial. */
|
|
52
|
+
name: string;
|
|
53
|
+
/** Rendered size of the glyph in pixels. 20 in the shell, 24 in the Apps tile, 16 on the pill. */
|
|
54
|
+
size?: number;
|
|
55
|
+
/** Set when the mark is decorative because the name is already beside it. */
|
|
56
|
+
decorative?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* The presentation attributes a mark's root may carry into the DOM.
|
|
61
|
+
*
|
|
62
|
+
* The same list cortena-design's `validateMarkup` enforces at build time,
|
|
63
|
+
* repeated here because this component spreads values that came from a
|
|
64
|
+
* package the consumer resolves — an older `cortena-design`, a patched one, a
|
|
65
|
+
* hand-edited `dist/marks.js` — straight onto an element.
|
|
66
|
+
*/
|
|
67
|
+
const MARK_ROOT_ATTRIBUTES: readonly string[] = [
|
|
68
|
+
"fill",
|
|
69
|
+
"stroke",
|
|
70
|
+
"stroke-width",
|
|
71
|
+
"stroke-linecap",
|
|
72
|
+
"stroke-linejoin",
|
|
73
|
+
"transform",
|
|
74
|
+
"opacity",
|
|
75
|
+
];
|
|
76
|
+
|
|
77
|
+
/** `root`, with anything outside the allow-list dropped. */
|
|
78
|
+
function safeRootAttributes(root: Record<string, string> | undefined): Record<string, string> {
|
|
79
|
+
const safe: Record<string, string> = {};
|
|
80
|
+
for (const name of MARK_ROOT_ATTRIBUTES) {
|
|
81
|
+
const value = root?.[name];
|
|
82
|
+
if (typeof value === "string") {
|
|
83
|
+
safe[name] = value;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return safe;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The registered mark for `id`, or the extension's initial when nothing is
|
|
91
|
+
* registered. The fallback matters: an unregistered id used to render an empty
|
|
92
|
+
* box, which reads as a broken image rather than as "this extension has not
|
|
93
|
+
* registered a mark yet".
|
|
94
|
+
*/
|
|
95
|
+
function BrandMark({ className, id, name, size = 20, decorative, ...props }: BrandMarkProps) {
|
|
96
|
+
const mark = getMark(id);
|
|
97
|
+
const label = decorative ? undefined : name;
|
|
98
|
+
return (
|
|
99
|
+
<span
|
|
100
|
+
data-slot="brand-mark"
|
|
101
|
+
data-mark={mark ? mark.id : "fallback"}
|
|
102
|
+
className={cn("inline-flex shrink-0 items-center justify-center", className)}
|
|
103
|
+
style={{ width: size, height: size }}
|
|
104
|
+
{...props}
|
|
105
|
+
>
|
|
106
|
+
{mark ? (
|
|
107
|
+
<svg
|
|
108
|
+
viewBox={mark.viewBox}
|
|
109
|
+
width={size}
|
|
110
|
+
height={size}
|
|
111
|
+
role={decorative ? undefined : "img"}
|
|
112
|
+
aria-label={label}
|
|
113
|
+
aria-hidden={decorative ? true : undefined}
|
|
114
|
+
focusable="false"
|
|
115
|
+
// The body is generated by cortena-design's build from a file in this
|
|
116
|
+
// monorepo, where `validateMarkup` accepts an allow-list of elements
|
|
117
|
+
// and attributes — no <script>, <style>, <use>, <image> or
|
|
118
|
+
// <foreignObject>, no href and no on* handler — and paints only in
|
|
119
|
+
// currentColor. It is not user input, and it is not sanitised here:
|
|
120
|
+
// the guarantee is the build's, so a mark that would need sanitising
|
|
121
|
+
// fails `pnpm build` instead of being quietly cleaned up.
|
|
122
|
+
dangerouslySetInnerHTML={{ __html: mark.body }}
|
|
123
|
+
// `root` is filtered again on the way in. A consumer can be running
|
|
124
|
+
// against an older or hand-edited `cortena-design/marks`, and this
|
|
125
|
+
// spread is the one place its values reach the DOM as attributes.
|
|
126
|
+
{...safeRootAttributes(mark.root)}
|
|
127
|
+
/>
|
|
128
|
+
) : (
|
|
129
|
+
<span
|
|
130
|
+
aria-label={label}
|
|
131
|
+
aria-hidden={decorative ? true : undefined}
|
|
132
|
+
role={decorative ? undefined : "img"}
|
|
133
|
+
className={cn(
|
|
134
|
+
"font-display font-bold uppercase leading-none",
|
|
135
|
+
"text-[length:var(--ds-text-caption-lg)]",
|
|
136
|
+
)}
|
|
137
|
+
>
|
|
138
|
+
{name.trim().charAt(0) || "?"}
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
</span>
|
|
142
|
+
);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/* ── bottom-right cluster ────────────────────────────────────────────────── */
|
|
146
|
+
|
|
147
|
+
export interface BottomRightClusterProps extends React.ComponentProps<"div"> {
|
|
148
|
+
/** The agent pop-up pill (§17). Stacks above the row, never beside it. */
|
|
149
|
+
agentSlot?: React.ReactNode;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* The one fixed container in the bottom-right corner. It exists so that no
|
|
154
|
+
* extension positions a floating control itself — that is how a third one
|
|
155
|
+
* appears and covers one of the first two.
|
|
156
|
+
*/
|
|
157
|
+
function BottomRightCluster({ className, agentSlot, children, ...props }: BottomRightClusterProps) {
|
|
158
|
+
return (
|
|
159
|
+
<div
|
|
160
|
+
data-slot="bottom-right-cluster"
|
|
161
|
+
className={cn("fixed right-6 bottom-6 z-40 flex flex-col items-end gap-3", className)}
|
|
162
|
+
{...props}
|
|
163
|
+
>
|
|
164
|
+
{agentSlot ? (
|
|
165
|
+
<div data-slot="bottom-right-cluster-agent" className="flex items-center justify-end">
|
|
166
|
+
{agentSlot}
|
|
167
|
+
</div>
|
|
168
|
+
) : null}
|
|
169
|
+
<div data-slot="bottom-right-cluster-row" className="flex items-center gap-3">
|
|
170
|
+
{children}
|
|
171
|
+
</div>
|
|
172
|
+
</div>
|
|
173
|
+
);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The cluster's distance from both viewport edges (§9.3). `right-6` / `bottom-6`. */
|
|
177
|
+
const CLUSTER_INSET = 24;
|
|
178
|
+
/** What the help panel leaves between itself and the cluster. */
|
|
179
|
+
const HELP_PANEL_GAP = 12;
|
|
180
|
+
|
|
181
|
+
const clusterButtonClassName = [
|
|
182
|
+
"inline-flex size-10 shrink-0 items-center justify-center rounded-[var(--ds-radius-full)]",
|
|
183
|
+
"border border-[var(--ds-border)] bg-[var(--ds-card)] text-[color:var(--ds-foreground)]",
|
|
184
|
+
"shadow-[var(--ds-shadow-md)] outline-none",
|
|
185
|
+
"transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
|
|
186
|
+
"hover:bg-[var(--ds-hover)]",
|
|
187
|
+
"focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)] focus-visible:ring-offset-2",
|
|
188
|
+
"focus-visible:ring-offset-[var(--ds-background)]",
|
|
189
|
+
].join(" ");
|
|
190
|
+
|
|
191
|
+
/** light → dark → system → light. Three states, one control, no menu to open. */
|
|
192
|
+
const THEME_ORDER: readonly CortenaTheme[] = ["light", "dark", "system"];
|
|
193
|
+
|
|
194
|
+
const THEME_LABEL: Record<CortenaTheme, string> = {
|
|
195
|
+
light: "Light",
|
|
196
|
+
dark: "Dark",
|
|
197
|
+
system: "System",
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
export interface ThemeToggleProps extends Omit<React.ComponentProps<"button">, "onClick"> {
|
|
201
|
+
/** localStorage key, forwarded to `useCortenaTheme`. */
|
|
202
|
+
storageKey?: string;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The theme selector. It lives here rather than in the avatar menu because a
|
|
207
|
+
* user changing theme is usually reacting to the room they are in, and that is
|
|
208
|
+
* a one-tap decision, not a settings visit.
|
|
209
|
+
*/
|
|
210
|
+
function ThemeToggle({ className, storageKey, ...props }: ThemeToggleProps) {
|
|
211
|
+
const { theme, resolvedTheme, setTheme } = useCortenaTheme(
|
|
212
|
+
storageKey === undefined ? {} : { storageKey },
|
|
213
|
+
);
|
|
214
|
+
const next = THEME_ORDER[(THEME_ORDER.indexOf(theme) + 1) % THEME_ORDER.length] ?? "light";
|
|
215
|
+
const Icon = theme === "system" ? Monitor : theme === "dark" ? Moon : Sun;
|
|
216
|
+
return (
|
|
217
|
+
<button
|
|
218
|
+
type="button"
|
|
219
|
+
data-slot="theme-toggle"
|
|
220
|
+
data-theme-choice={theme}
|
|
221
|
+
data-theme-resolved={resolvedTheme}
|
|
222
|
+
aria-label={`Theme: ${THEME_LABEL[theme]}. Switch to ${THEME_LABEL[next].toLowerCase()}.`}
|
|
223
|
+
className={cn(clusterButtonClassName, className)}
|
|
224
|
+
onClick={() => setTheme(next)}
|
|
225
|
+
{...props}
|
|
226
|
+
>
|
|
227
|
+
<Icon className="size-5" aria-hidden />
|
|
228
|
+
</button>
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The id the shell gives its help region, and the button's `aria-controls`. */
|
|
233
|
+
export const HELP_PANEL_ID = "cortena-help-panel";
|
|
234
|
+
|
|
235
|
+
export interface HelpButtonProps extends React.ComponentProps<"button"> {
|
|
236
|
+
/** Whether the panel it controls is open. */
|
|
237
|
+
open?: boolean;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** The help affordance. Right of the theme toggle, always — order is fixed (§9.3). */
|
|
241
|
+
function HelpButton({ className, open, ...props }: HelpButtonProps) {
|
|
242
|
+
return (
|
|
243
|
+
<button
|
|
244
|
+
type="button"
|
|
245
|
+
data-slot="help-button"
|
|
246
|
+
aria-label="Help"
|
|
247
|
+
aria-expanded={open}
|
|
248
|
+
// `aria-expanded` alone says "something is open" without saying what.
|
|
249
|
+
// A screen reader user then has to go and find it.
|
|
250
|
+
aria-controls={HELP_PANEL_ID}
|
|
251
|
+
data-open={open ? "" : undefined}
|
|
252
|
+
className={cn(clusterButtonClassName, open && "bg-[var(--ds-hover)]", className)}
|
|
253
|
+
{...props}
|
|
254
|
+
>
|
|
255
|
+
<CircleQuestionMark className="size-5" aria-hidden />
|
|
256
|
+
</button>
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/* ── the shell ───────────────────────────────────────────────────────────── */
|
|
261
|
+
|
|
262
|
+
export interface AppShellExtension {
|
|
263
|
+
/** Registered mark id, from `x-cortena.icon` in the OpenAPI document. */
|
|
264
|
+
id: string;
|
|
265
|
+
/** Display name. Also the first half of the document title, "<name> · Cortena". */
|
|
266
|
+
name: string;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
export interface AppShellUser {
|
|
270
|
+
name: string;
|
|
271
|
+
avatarUrl?: string;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export interface AppShellHelp {
|
|
275
|
+
/**
|
|
276
|
+
* The extension's functional document — the thing the help panel answers
|
|
277
|
+
* from (§12). Giving that document a runtime consumer is the point: a stale
|
|
278
|
+
* doc becomes a visibly wrong help panel rather than a file nobody opens.
|
|
279
|
+
*/
|
|
280
|
+
source: string;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
export interface HelpPanelSlotProps {
|
|
284
|
+
open: boolean;
|
|
285
|
+
source?: string;
|
|
286
|
+
onClose: () => void;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
export interface AppShellProps extends Omit<React.ComponentProps<"div">, "title"> {
|
|
290
|
+
extension: AppShellExtension;
|
|
291
|
+
user: AppShellUser;
|
|
292
|
+
/** Opens the extension's own settings route. The avatar menu is the only way in. */
|
|
293
|
+
onSettings?: () => void;
|
|
294
|
+
onProfile?: () => void;
|
|
295
|
+
onLogout?: () => void;
|
|
296
|
+
help?: AppShellHelp;
|
|
297
|
+
/**
|
|
298
|
+
* The help panel, until HelpPanel ships (EXTBP-24). A render function gets
|
|
299
|
+
* the open state and owns its own markup; a node is mounted in a right-hand
|
|
300
|
+
* region while open.
|
|
301
|
+
*/
|
|
302
|
+
helpPanel?: React.ReactNode | ((props: HelpPanelSlotProps) => React.ReactNode);
|
|
303
|
+
/** The agent pop-up pill. Stacks above the bottom-right cluster. */
|
|
304
|
+
agentSlot?: React.ReactNode;
|
|
305
|
+
/** localStorage key for the theme choice, forwarded to `useCortenaTheme`. */
|
|
306
|
+
themeStorageKey?: string;
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
function AppShell({
|
|
310
|
+
className,
|
|
311
|
+
extension,
|
|
312
|
+
user,
|
|
313
|
+
onSettings,
|
|
314
|
+
onProfile,
|
|
315
|
+
onLogout,
|
|
316
|
+
help,
|
|
317
|
+
helpPanel,
|
|
318
|
+
agentSlot,
|
|
319
|
+
themeStorageKey,
|
|
320
|
+
children,
|
|
321
|
+
...props
|
|
322
|
+
}: AppShellProps) {
|
|
323
|
+
const [helpOpen, setHelpOpen] = React.useState(false);
|
|
324
|
+
const helpButtonRef = React.useRef<HTMLButtonElement>(null);
|
|
325
|
+
const closeHelp = React.useCallback(() => {
|
|
326
|
+
setHelpOpen(false);
|
|
327
|
+
// Focus goes back where it came from. A panel closed with Escape or its
|
|
328
|
+
// own X otherwise left focus on a removed node, which the browser resets
|
|
329
|
+
// to <body> — the next Tab starts from the top of the page, and a
|
|
330
|
+
// keyboard user has to walk the whole shell to get back.
|
|
331
|
+
helpButtonRef.current?.focus();
|
|
332
|
+
}, []);
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* The help panel stops 12px above the bottom-right cluster, so the button
|
|
336
|
+
* that opened it stays visible and clickable. The gap was hard-coded at
|
|
337
|
+
* 76px — one 40px control plus the two margins — and the cluster is not
|
|
338
|
+
* always that tall: the agent pop-up pill stacks ABOVE the row, which makes
|
|
339
|
+
* it 92px, and the panel then covered the pill it was sitting on.
|
|
340
|
+
*/
|
|
341
|
+
const clusterRef = React.useRef<HTMLDivElement>(null);
|
|
342
|
+
const [clusterHeight, setClusterHeight] = React.useState(40);
|
|
343
|
+
React.useEffect(() => {
|
|
344
|
+
const el = clusterRef.current;
|
|
345
|
+
if (!el || typeof ResizeObserver === "undefined") {
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
const measure = () => setClusterHeight(el.getBoundingClientRect().height);
|
|
349
|
+
measure();
|
|
350
|
+
const observer = new ResizeObserver(measure);
|
|
351
|
+
observer.observe(el);
|
|
352
|
+
return () => observer.disconnect();
|
|
353
|
+
}, [agentSlot, helpOpen]);
|
|
354
|
+
const hasHelp = help !== undefined || helpPanel !== undefined;
|
|
355
|
+
const panel =
|
|
356
|
+
typeof helpPanel === "function"
|
|
357
|
+
? helpPanel({ open: helpOpen, source: help?.source, onClose: closeHelp })
|
|
358
|
+
: helpOpen
|
|
359
|
+
? helpPanel
|
|
360
|
+
: null;
|
|
361
|
+
|
|
362
|
+
return (
|
|
363
|
+
<div
|
|
364
|
+
data-slot="app-shell"
|
|
365
|
+
className={cn(
|
|
366
|
+
"flex min-h-screen w-full flex-col bg-[var(--ds-background)] text-[color:var(--ds-foreground)]",
|
|
367
|
+
className,
|
|
368
|
+
)}
|
|
369
|
+
{...props}
|
|
370
|
+
>
|
|
371
|
+
<header
|
|
372
|
+
data-slot="app-shell-header"
|
|
373
|
+
className={cn(
|
|
374
|
+
"sticky top-0 z-30 flex h-16 w-full shrink-0 items-center justify-between gap-3",
|
|
375
|
+
"border-b border-[var(--ds-border-subtle)] bg-[var(--ds-background)] px-4 sm:px-6",
|
|
376
|
+
)}
|
|
377
|
+
>
|
|
378
|
+
<div data-slot="app-shell-brand" className="flex min-w-0 items-center gap-2.5">
|
|
379
|
+
<span
|
|
380
|
+
data-slot="app-shell-brand-square"
|
|
381
|
+
className={cn(
|
|
382
|
+
"flex size-8 shrink-0 items-center justify-center rounded-[var(--ds-radius-md)]",
|
|
383
|
+
"bg-[var(--ds-primary-soft)] text-[color:var(--ds-primary)]",
|
|
384
|
+
)}
|
|
385
|
+
>
|
|
386
|
+
<BrandMark id={extension.id} name={extension.name} size={20} decorative />
|
|
387
|
+
</span>
|
|
388
|
+
<span
|
|
389
|
+
data-slot="app-shell-brand-name"
|
|
390
|
+
className={cn(
|
|
391
|
+
"truncate font-display text-[length:var(--ds-text-body-md)] font-bold",
|
|
392
|
+
"leading-[var(--ds-text-body-md--line-height)] text-[color:var(--ds-foreground)]",
|
|
393
|
+
)}
|
|
394
|
+
>
|
|
395
|
+
{extension.name}
|
|
396
|
+
</span>
|
|
397
|
+
</div>
|
|
398
|
+
|
|
399
|
+
<DropdownMenu>
|
|
400
|
+
<DropdownMenuTrigger
|
|
401
|
+
data-slot="app-shell-avatar-trigger"
|
|
402
|
+
aria-label={`Account: ${user.name}`}
|
|
403
|
+
className={cn(
|
|
404
|
+
"flex shrink-0 items-center rounded-[var(--ds-radius-full)] outline-none",
|
|
405
|
+
"focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)] focus-visible:ring-offset-2",
|
|
406
|
+
"focus-visible:ring-offset-[var(--ds-background)]",
|
|
407
|
+
)}
|
|
408
|
+
>
|
|
409
|
+
<Avatar>
|
|
410
|
+
{user.avatarUrl ? <AvatarImage src={user.avatarUrl} alt="" /> : null}
|
|
411
|
+
<AvatarFallback>{initials(user.name)}</AvatarFallback>
|
|
412
|
+
</Avatar>
|
|
413
|
+
</DropdownMenuTrigger>
|
|
414
|
+
{/* Profile, Settings, Log out. Nothing else, and no second settings
|
|
415
|
+
entry point anywhere in the extension (§9.2). */}
|
|
416
|
+
<DropdownMenuContent align="end" sideOffset={8} className="min-w-48">
|
|
417
|
+
<DropdownMenuItem
|
|
418
|
+
data-item="profile"
|
|
419
|
+
disabled={!onProfile}
|
|
420
|
+
onClick={onProfile}
|
|
421
|
+
>
|
|
422
|
+
<User /> Profile
|
|
423
|
+
</DropdownMenuItem>
|
|
424
|
+
<DropdownMenuItem
|
|
425
|
+
data-item="settings"
|
|
426
|
+
disabled={!onSettings}
|
|
427
|
+
onClick={onSettings}
|
|
428
|
+
>
|
|
429
|
+
<Settings /> Settings
|
|
430
|
+
</DropdownMenuItem>
|
|
431
|
+
<DropdownMenuSeparator />
|
|
432
|
+
<DropdownMenuItem
|
|
433
|
+
data-item="logout"
|
|
434
|
+
variant="destructive"
|
|
435
|
+
disabled={!onLogout}
|
|
436
|
+
onClick={onLogout}
|
|
437
|
+
>
|
|
438
|
+
<LogOut /> Log out
|
|
439
|
+
</DropdownMenuItem>
|
|
440
|
+
</DropdownMenuContent>
|
|
441
|
+
</DropdownMenu>
|
|
442
|
+
</header>
|
|
443
|
+
|
|
444
|
+
<main data-slot="app-shell-main" className="min-w-0 flex-1">
|
|
445
|
+
{children}
|
|
446
|
+
</main>
|
|
447
|
+
|
|
448
|
+
{panel ? (
|
|
449
|
+
<aside
|
|
450
|
+
id={HELP_PANEL_ID}
|
|
451
|
+
data-slot="app-shell-help-panel"
|
|
452
|
+
aria-label={`${extension.name} help`}
|
|
453
|
+
// Measured, not assumed: 24px of viewport margin, the cluster the
|
|
454
|
+
// panel must clear, and 12px between the two.
|
|
455
|
+
style={{ bottom: CLUSTER_INSET + clusterHeight + HELP_PANEL_GAP }}
|
|
456
|
+
className={cn(
|
|
457
|
+
// Anchored to the same corner. A panel that covers its own trigger
|
|
458
|
+
// leaves no way back except Escape.
|
|
459
|
+
"fixed top-6 right-6 z-40 flex w-[min(24rem,calc(100vw-3rem))] flex-col",
|
|
460
|
+
"overflow-hidden rounded-[var(--ds-radius-lg)] border border-[var(--ds-border)]",
|
|
461
|
+
"bg-[var(--ds-card)] shadow-[var(--ds-shadow-lg)]",
|
|
462
|
+
)}
|
|
463
|
+
>
|
|
464
|
+
{panel}
|
|
465
|
+
</aside>
|
|
466
|
+
) : null}
|
|
467
|
+
|
|
468
|
+
<BottomRightCluster ref={clusterRef} agentSlot={agentSlot}>
|
|
469
|
+
<ThemeToggle storageKey={themeStorageKey} />
|
|
470
|
+
{hasHelp ? (
|
|
471
|
+
<HelpButton
|
|
472
|
+
ref={helpButtonRef}
|
|
473
|
+
open={helpOpen}
|
|
474
|
+
onClick={() => setHelpOpen((open) => !open)}
|
|
475
|
+
/>
|
|
476
|
+
) : null}
|
|
477
|
+
</BottomRightCluster>
|
|
478
|
+
</div>
|
|
479
|
+
);
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
/** One or two letters. Two words give two initials; anything else gives one. */
|
|
483
|
+
function initials(name: string): string {
|
|
484
|
+
const words = name.trim().split(/\s+/).filter(Boolean);
|
|
485
|
+
if (words.length === 0) {
|
|
486
|
+
return "?";
|
|
487
|
+
}
|
|
488
|
+
const first = words[0]?.charAt(0) ?? "";
|
|
489
|
+
const last = words.length > 1 ? (words.at(-1)?.charAt(0) ?? "") : "";
|
|
490
|
+
return (first + last).toUpperCase();
|
|
491
|
+
}
|
|
492
|
+
|
|
493
|
+
/**
|
|
494
|
+
* `<title>` for an extension: the extension first, because that is what
|
|
495
|
+
* distinguishes one tab from another, and the product second so a row of tabs
|
|
496
|
+
* reads as one product (§9.5, audit rule P-38).
|
|
497
|
+
*/
|
|
498
|
+
function documentTitle(extensionName: string, page?: string): string {
|
|
499
|
+
return page ? `${page} · ${extensionName} · Cortena` : `${extensionName} · Cortena`;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
export { AppShell, BottomRightCluster, BrandMark, HelpButton, ThemeToggle, documentTitle };
|