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.
Files changed (138) hide show
  1. package/CHANGELOG.md +107 -0
  2. package/LICENSE +7 -0
  3. package/README.md +235 -3
  4. package/dist/a2ui/views.js +2 -2
  5. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  6. package/dist/agent-chat/a2ui-block.js +69 -0
  7. package/dist/agent-chat/a2ui-block.js.map +1 -0
  8. package/dist/agent-chat/agui-client.d.ts +40 -0
  9. package/dist/agent-chat/agui-client.js +251 -0
  10. package/dist/agent-chat/agui-client.js.map +1 -0
  11. package/dist/agent-chat/bridge.d.ts +109 -0
  12. package/dist/agent-chat/bridge.js +353 -0
  13. package/dist/agent-chat/bridge.js.map +1 -0
  14. package/dist/agent-chat/session.d.ts +79 -0
  15. package/dist/agent-chat/session.js +391 -0
  16. package/dist/agent-chat/session.js.map +1 -0
  17. package/dist/agent-chat/step-label.d.ts +99 -0
  18. package/dist/agent-chat/step-label.js +116 -0
  19. package/dist/agent-chat/step-label.js.map +1 -0
  20. package/dist/agent-chat/store.d.ts +102 -0
  21. package/dist/agent-chat/store.js +876 -0
  22. package/dist/agent-chat/store.js.map +1 -0
  23. package/dist/agent-chat/types.d.ts +277 -0
  24. package/dist/agent-chat/types.js +17 -0
  25. package/dist/agent-chat/types.js.map +1 -0
  26. package/dist/agent-chat.d.ts +11 -0
  27. package/dist/agent-chat.js +11 -0
  28. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  29. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  30. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  31. package/dist/components/admin-permissions/context.d.ts +70 -0
  32. package/dist/components/admin-permissions/context.js +258 -0
  33. package/dist/components/admin-permissions/context.js.map +1 -0
  34. package/dist/components/admin-permissions/index.d.ts +10 -0
  35. package/dist/components/admin-permissions/licence.d.ts +15 -0
  36. package/dist/components/admin-permissions/licence.js +78 -0
  37. package/dist/components/admin-permissions/licence.js.map +1 -0
  38. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  39. package/dist/components/admin-permissions/matrix.js +191 -0
  40. package/dist/components/admin-permissions/matrix.js.map +1 -0
  41. package/dist/components/admin-permissions/members.d.ts +18 -0
  42. package/dist/components/admin-permissions/members.js +185 -0
  43. package/dist/components/admin-permissions/members.js.map +1 -0
  44. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  45. package/dist/components/admin-permissions/role-assignment.js +174 -0
  46. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  47. package/dist/components/admin-permissions/roles.d.ts +25 -0
  48. package/dist/components/admin-permissions/roles.js +168 -0
  49. package/dist/components/admin-permissions/roles.js.map +1 -0
  50. package/dist/components/admin-permissions/types.d.ts +152 -0
  51. package/dist/components/admin-permissions/types.js +63 -0
  52. package/dist/components/admin-permissions/types.js.map +1 -0
  53. package/dist/components/agent-chat-popup.d.ts +29 -0
  54. package/dist/components/agent-chat-popup.js +188 -0
  55. package/dist/components/agent-chat-popup.js.map +1 -0
  56. package/dist/components/agent-chat.d.ts +163 -0
  57. package/dist/components/agent-chat.js +673 -0
  58. package/dist/components/agent-chat.js.map +1 -0
  59. package/dist/components/app-shell.d.ts +126 -0
  60. package/dist/components/app-shell.js +297 -0
  61. package/dist/components/app-shell.js.map +1 -0
  62. package/dist/components/badge.d.ts +1 -1
  63. package/dist/components/button-link.js +1 -1
  64. package/dist/components/button.d.ts +1 -1
  65. package/dist/components/checkbox.d.ts +1 -1
  66. package/dist/components/combobox.d.ts +1 -1
  67. package/dist/components/combobox.js +1 -1
  68. package/dist/components/consent-screen.d.ts +65 -0
  69. package/dist/components/consent-screen.js +123 -0
  70. package/dist/components/consent-screen.js.map +1 -0
  71. package/dist/components/data-table/data-table.d.ts +15 -1
  72. package/dist/components/data-table/data-table.js +18 -4
  73. package/dist/components/data-table/data-table.js.map +1 -1
  74. package/dist/components/data-table/index.d.ts +4 -4
  75. package/dist/components/data-table/parts.d.ts +27 -3
  76. package/dist/components/data-table/parts.js +175 -55
  77. package/dist/components/data-table/parts.js.map +1 -1
  78. package/dist/components/data-table/types.d.ts +61 -0
  79. package/dist/components/data-table/use-data-table.js +91 -6
  80. package/dist/components/data-table/use-data-table.js.map +1 -1
  81. package/dist/components/data-table/use-server-source.js +119 -28
  82. package/dist/components/data-table/use-server-source.js.map +1 -1
  83. package/dist/components/help-panel.d.ts +131 -0
  84. package/dist/components/help-panel.js +545 -0
  85. package/dist/components/help-panel.js.map +1 -0
  86. package/dist/components/login-screen.d.ts +127 -0
  87. package/dist/components/login-screen.js +339 -0
  88. package/dist/components/login-screen.js.map +1 -0
  89. package/dist/components/session-guard.d.ts +268 -0
  90. package/dist/components/session-guard.js +632 -0
  91. package/dist/components/session-guard.js.map +1 -0
  92. package/dist/components/toast.d.ts +1 -1
  93. package/dist/core.d.ts +5 -1
  94. package/dist/core.js +11 -7
  95. package/dist/data-table.d.ts +13 -4
  96. package/dist/data-table.js +10 -2
  97. package/dist/hooks/use-cortena-theme.js +49 -3
  98. package/dist/hooks/use-cortena-theme.js.map +1 -1
  99. package/dist/index.d.ts +17 -4
  100. package/dist/index.js +21 -8
  101. package/dist/markdown.d.ts +2 -1
  102. package/dist/markdown.js +2 -1
  103. package/package.json +18 -5
  104. package/src/agent-chat/a2ui-block.ts +118 -0
  105. package/src/agent-chat/agui-client.ts +405 -0
  106. package/src/agent-chat/bridge.ts +445 -0
  107. package/src/agent-chat/session.ts +549 -0
  108. package/src/agent-chat/step-label.ts +177 -0
  109. package/src/agent-chat/store.ts +1234 -0
  110. package/src/agent-chat/types.ts +308 -0
  111. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  112. package/src/components/admin-permissions/context.tsx +376 -0
  113. package/src/components/admin-permissions/index.tsx +32 -0
  114. package/src/components/admin-permissions/licence.tsx +84 -0
  115. package/src/components/admin-permissions/matrix.tsx +257 -0
  116. package/src/components/admin-permissions/members.tsx +204 -0
  117. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  118. package/src/components/admin-permissions/roles.tsx +169 -0
  119. package/src/components/admin-permissions/types.ts +231 -0
  120. package/src/components/agent-chat-popup.tsx +289 -0
  121. package/src/components/agent-chat.tsx +1006 -0
  122. package/src/components/app-shell.tsx +502 -0
  123. package/src/components/consent-screen.tsx +239 -0
  124. package/src/components/data-table/data-table.tsx +36 -0
  125. package/src/components/data-table/index.tsx +6 -1
  126. package/src/components/data-table/parts.tsx +223 -47
  127. package/src/components/data-table/types.ts +68 -0
  128. package/src/components/data-table/use-data-table.ts +152 -4
  129. package/src/components/data-table/use-server-source.ts +150 -12
  130. package/src/components/help-panel.tsx +765 -0
  131. package/src/components/login-screen.tsx +479 -0
  132. package/src/components/session-guard.tsx +1071 -0
  133. package/src/entries/agent-chat.ts +137 -0
  134. package/src/entries/core.ts +8 -0
  135. package/src/entries/data-table.ts +41 -0
  136. package/src/entries/markdown.ts +25 -0
  137. package/src/hooks/use-cortena-theme.ts +63 -4
  138. 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 };