cortena-ui 1.4.2 → 1.5.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 (133) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +149 -1
  3. package/dist/a2ui/views.js +2 -2
  4. package/dist/agent-chat/a2ui-block.d.ts +60 -0
  5. package/dist/agent-chat/a2ui-block.js +69 -0
  6. package/dist/agent-chat/a2ui-block.js.map +1 -0
  7. package/dist/agent-chat/agui-client.d.ts +40 -0
  8. package/dist/agent-chat/agui-client.js +251 -0
  9. package/dist/agent-chat/agui-client.js.map +1 -0
  10. package/dist/agent-chat/bridge.d.ts +109 -0
  11. package/dist/agent-chat/bridge.js +349 -0
  12. package/dist/agent-chat/bridge.js.map +1 -0
  13. package/dist/agent-chat/session.d.ts +58 -0
  14. package/dist/agent-chat/session.js +249 -0
  15. package/dist/agent-chat/session.js.map +1 -0
  16. package/dist/agent-chat/store.d.ts +67 -0
  17. package/dist/agent-chat/store.js +548 -0
  18. package/dist/agent-chat/store.js.map +1 -0
  19. package/dist/agent-chat/types.d.ts +187 -0
  20. package/dist/agent-chat/types.js +17 -0
  21. package/dist/agent-chat/types.js.map +1 -0
  22. package/dist/agent-chat.d.ts +10 -0
  23. package/dist/agent-chat.js +10 -0
  24. package/dist/components/admin-permissions/admin-permissions.d.ts +66 -0
  25. package/dist/components/admin-permissions/admin-permissions.js +101 -0
  26. package/dist/components/admin-permissions/admin-permissions.js.map +1 -0
  27. package/dist/components/admin-permissions/context.d.ts +70 -0
  28. package/dist/components/admin-permissions/context.js +258 -0
  29. package/dist/components/admin-permissions/context.js.map +1 -0
  30. package/dist/components/admin-permissions/index.d.ts +10 -0
  31. package/dist/components/admin-permissions/licence.d.ts +15 -0
  32. package/dist/components/admin-permissions/licence.js +78 -0
  33. package/dist/components/admin-permissions/licence.js.map +1 -0
  34. package/dist/components/admin-permissions/matrix.d.ts +20 -0
  35. package/dist/components/admin-permissions/matrix.js +191 -0
  36. package/dist/components/admin-permissions/matrix.js.map +1 -0
  37. package/dist/components/admin-permissions/members.d.ts +18 -0
  38. package/dist/components/admin-permissions/members.js +185 -0
  39. package/dist/components/admin-permissions/members.js.map +1 -0
  40. package/dist/components/admin-permissions/role-assignment.d.ts +35 -0
  41. package/dist/components/admin-permissions/role-assignment.js +174 -0
  42. package/dist/components/admin-permissions/role-assignment.js.map +1 -0
  43. package/dist/components/admin-permissions/roles.d.ts +25 -0
  44. package/dist/components/admin-permissions/roles.js +168 -0
  45. package/dist/components/admin-permissions/roles.js.map +1 -0
  46. package/dist/components/admin-permissions/types.d.ts +152 -0
  47. package/dist/components/admin-permissions/types.js +63 -0
  48. package/dist/components/admin-permissions/types.js.map +1 -0
  49. package/dist/components/agent-chat-popup.d.ts +29 -0
  50. package/dist/components/agent-chat-popup.js +188 -0
  51. package/dist/components/agent-chat-popup.js.map +1 -0
  52. package/dist/components/agent-chat.d.ts +143 -0
  53. package/dist/components/agent-chat.js +578 -0
  54. package/dist/components/agent-chat.js.map +1 -0
  55. package/dist/components/app-shell.d.ts +126 -0
  56. package/dist/components/app-shell.js +297 -0
  57. package/dist/components/app-shell.js.map +1 -0
  58. package/dist/components/badge.d.ts +1 -1
  59. package/dist/components/button-link.js +1 -1
  60. package/dist/components/button.d.ts +2 -2
  61. package/dist/components/checkbox.d.ts +1 -1
  62. package/dist/components/combobox.d.ts +1 -1
  63. package/dist/components/combobox.js +1 -1
  64. package/dist/components/consent-screen.d.ts +65 -0
  65. package/dist/components/consent-screen.js +123 -0
  66. package/dist/components/consent-screen.js.map +1 -0
  67. package/dist/components/data-table/data-table.d.ts +15 -1
  68. package/dist/components/data-table/data-table.js +18 -4
  69. package/dist/components/data-table/data-table.js.map +1 -1
  70. package/dist/components/data-table/index.d.ts +4 -4
  71. package/dist/components/data-table/parts.d.ts +27 -3
  72. package/dist/components/data-table/parts.js +175 -55
  73. package/dist/components/data-table/parts.js.map +1 -1
  74. package/dist/components/data-table/types.d.ts +61 -0
  75. package/dist/components/data-table/use-data-table.js +91 -6
  76. package/dist/components/data-table/use-data-table.js.map +1 -1
  77. package/dist/components/data-table/use-server-source.js +119 -28
  78. package/dist/components/data-table/use-server-source.js.map +1 -1
  79. package/dist/components/help-panel.d.ts +131 -0
  80. package/dist/components/help-panel.js +545 -0
  81. package/dist/components/help-panel.js.map +1 -0
  82. package/dist/components/login-screen.d.ts +127 -0
  83. package/dist/components/login-screen.js +339 -0
  84. package/dist/components/login-screen.js.map +1 -0
  85. package/dist/components/session-guard.d.ts +268 -0
  86. package/dist/components/session-guard.js +632 -0
  87. package/dist/components/session-guard.js.map +1 -0
  88. package/dist/components/toast.d.ts +1 -1
  89. package/dist/core.d.ts +5 -1
  90. package/dist/core.js +11 -7
  91. package/dist/data-table.d.ts +13 -4
  92. package/dist/data-table.js +10 -2
  93. package/dist/hooks/use-cortena-theme.js +49 -3
  94. package/dist/hooks/use-cortena-theme.js.map +1 -1
  95. package/dist/index.d.ts +17 -4
  96. package/dist/index.js +21 -8
  97. package/dist/markdown.d.ts +2 -1
  98. package/dist/markdown.js +2 -1
  99. package/package.json +16 -4
  100. package/src/agent-chat/a2ui-block.ts +118 -0
  101. package/src/agent-chat/agui-client.ts +405 -0
  102. package/src/agent-chat/bridge.ts +433 -0
  103. package/src/agent-chat/session.ts +392 -0
  104. package/src/agent-chat/store.ts +738 -0
  105. package/src/agent-chat/types.ts +213 -0
  106. package/src/components/admin-permissions/admin-permissions.tsx +130 -0
  107. package/src/components/admin-permissions/context.tsx +376 -0
  108. package/src/components/admin-permissions/index.tsx +32 -0
  109. package/src/components/admin-permissions/licence.tsx +84 -0
  110. package/src/components/admin-permissions/matrix.tsx +257 -0
  111. package/src/components/admin-permissions/members.tsx +204 -0
  112. package/src/components/admin-permissions/role-assignment.tsx +239 -0
  113. package/src/components/admin-permissions/roles.tsx +169 -0
  114. package/src/components/admin-permissions/types.ts +231 -0
  115. package/src/components/agent-chat-popup.tsx +289 -0
  116. package/src/components/agent-chat.tsx +843 -0
  117. package/src/components/app-shell.tsx +502 -0
  118. package/src/components/consent-screen.tsx +239 -0
  119. package/src/components/data-table/data-table.tsx +36 -0
  120. package/src/components/data-table/index.tsx +6 -1
  121. package/src/components/data-table/parts.tsx +223 -47
  122. package/src/components/data-table/types.ts +68 -0
  123. package/src/components/data-table/use-data-table.ts +152 -4
  124. package/src/components/data-table/use-server-source.ts +150 -12
  125. package/src/components/help-panel.tsx +765 -0
  126. package/src/components/login-screen.tsx +479 -0
  127. package/src/components/session-guard.tsx +1071 -0
  128. package/src/entries/agent-chat.ts +113 -0
  129. package/src/entries/core.ts +8 -0
  130. package/src/entries/data-table.ts +41 -0
  131. package/src/entries/markdown.ts +25 -0
  132. package/src/hooks/use-cortena-theme.ts +63 -4
  133. package/src/index.ts +6 -0
@@ -0,0 +1,765 @@
1
+ "use client";
2
+
3
+ /**
4
+ * HelpPanel — the extension's own help, answered from its own functional
5
+ * document.
6
+ *
7
+ * §12 of how-to-create-a-cortena-extension, audit rule P-13. Every extension
8
+ * with a UI ships one: anchored bottom-right, collapsed by default, expanding
9
+ * into a right-hand vertical panel. It fills `AppShell`'s help slot, so the
10
+ * shell keeps the button, the open state and the corner geometry (the panel
11
+ * stops 12px above the cluster so the button that opened it stays clickable).
12
+ *
13
+ * ```tsx
14
+ * <AppShell
15
+ * extension={{ id: "tasks", name: "Tasks" }}
16
+ * user={user}
17
+ * help={{ source: "/docs/functional.md" }}
18
+ * helpPanel={helpPanelSlot({
19
+ * // lazy: the document is fetched the first time help is opened
20
+ * source: () => fetch("/docs/functional.md").then((r) => r.text()),
21
+ * documentUrl: "/docs/functional.md",
22
+ * context: location.pathname,
23
+ * })}
24
+ * >
25
+ * <HelpHotkeys />
26
+ * …
27
+ * </AppShell>
28
+ * ```
29
+ *
30
+ * It is NOT the agent pop-up (§17) and the two must not be merged. Help
31
+ * answers *about* the extension from a document; the agent *acts* in the
32
+ * extension through its tools. Same corner, different affordances.
33
+ *
34
+ * Giving the functional document a runtime consumer is the whole point: a
35
+ * stale document becomes a visibly wrong help panel rather than a file nobody
36
+ * opens. So the panel renders the document itself — outline from its headings,
37
+ * search over its text, `#heading` deep links, and "open in new tab" for the
38
+ * whole thing — rather than a hand-written copy of it that can drift.
39
+ *
40
+ * `context` is the current route. On open the panel jumps to the section that
41
+ * route is about, matched against the front matter's `routes:` map first and
42
+ * against the headings second, so help opens on the part of the document the
43
+ * user is looking at rather than at the top of it.
44
+ *
45
+ * Lives on the `cortena-ui/markdown` entry: it renders markdown, so it carries
46
+ * react-markdown, and no extension that only draws a Button may pay for that.
47
+ */
48
+
49
+ import { ExternalLink, Search, X } from "lucide-react";
50
+ import * as React from "react";
51
+ import { Markdown, markdownSanitizeSchema } from "@/components/markdown";
52
+ import { Spinner } from "@/components/spinner";
53
+ import { cn } from "@/lib/cn";
54
+
55
+ /* ── the document ────────────────────────────────────────────────────────── */
56
+
57
+ /**
58
+ * The markdown, or a loader for it. A function is called the first time the
59
+ * panel is opened, which is what keeps a long document out of the initial
60
+ * payload; keep its identity stable (module scope, or `useCallback`) or it is
61
+ * called again on every parent render.
62
+ */
63
+ export type HelpSource = string | (() => Promise<string>);
64
+
65
+ export interface HelpSection {
66
+ /** Slug of the heading — the `#anchor` the outline links to. `""` for the preamble. */
67
+ id: string;
68
+ /** 1–6 for a heading; 0 for the text before the first heading. */
69
+ depth: number;
70
+ /** Heading text with inline markdown removed. */
71
+ title: string;
72
+ /** The heading and everything under it, verbatim, so it can be re-rendered alone. */
73
+ markdown: string;
74
+ }
75
+
76
+ export interface HelpRoute {
77
+ /** A route the extension serves, e.g. `/board`. */
78
+ route: string;
79
+ /** The section it is about: a heading, or that heading's slug. */
80
+ target: string;
81
+ }
82
+
83
+ export interface HelpDocument {
84
+ /** `title:` from the front matter, else the first `#` heading. */
85
+ title?: string;
86
+ /** Every scalar key in the front matter. */
87
+ frontMatter: Record<string, string>;
88
+ /** The `route:` / `routes:` block, longest route first. */
89
+ routes: HelpRoute[];
90
+ /** The document without its front matter. */
91
+ body: string;
92
+ sections: HelpSection[];
93
+ }
94
+
95
+ /** Lowercase, punctuation to hyphens. The same slug the rendered heading gets. */
96
+ function slugify(text: string): string {
97
+ const slug = text
98
+ .toLowerCase()
99
+ .normalize("NFKD")
100
+ .replace(/[\u0300-\u036f]/g, "")
101
+ .replace(/[^a-z0-9]+/g, "-")
102
+ .replace(/^-+|-+$/g, "");
103
+ return slug || "section";
104
+ }
105
+
106
+ /** Slugs in document order, with `-1`, `-2` for repeats, as GitHub numbers them. */
107
+ function createSlugger(): (text: string) => string {
108
+ const seen = new Map<string, number>();
109
+ return (text) => {
110
+ const base = slugify(text);
111
+ const n = seen.get(base) ?? 0;
112
+ seen.set(base, n + 1);
113
+ return n === 0 ? base : `${base}-${n}`;
114
+ };
115
+ }
116
+
117
+ /**
118
+ * Heading text with the inline markdown taken off, so the outline entry and
119
+ * the slug match what the rendered heading actually says. Without this a
120
+ * `## [Reports](/reports)` heading slugs to `reports-reports` in the outline
121
+ * and to `reports` in the document, and the deep link lands nowhere.
122
+ */
123
+ function plainText(md: string): string {
124
+ return md
125
+ .replace(/`([^`]*)`/g, "$1")
126
+ .replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1")
127
+ .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1")
128
+ .replace(/<[^>]*>/g, "")
129
+ .replace(/[*_~]+/g, "")
130
+ .replace(/\s+/g, " ")
131
+ .trim();
132
+ }
133
+
134
+ const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
135
+ const HEADING = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
136
+ const FENCE = /^[ \t]*(```|~~~)/;
137
+
138
+ function unquote(value: string): string {
139
+ const trimmed = value.trim();
140
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(trimmed);
141
+ return quoted ? (quoted[2] ?? "") : trimmed;
142
+ }
143
+
144
+ /**
145
+ * Parse the document once: front matter, then sections split at ATX headings.
146
+ *
147
+ * Exported because the route map and the outline are worth asserting on
148
+ * without a browser, and because an extension that wants its own navigation
149
+ * over the same document should read it the same way rather than write a
150
+ * second parser.
151
+ */
152
+ export function parseHelpDocument(source: string): HelpDocument {
153
+ const matter = FRONT_MATTER.exec(source);
154
+ const body = matter ? source.slice(matter[0].length) : source;
155
+
156
+ const frontMatter: Record<string, string> = {};
157
+ const routes: HelpRoute[] = [];
158
+ if (matter) {
159
+ let inRoutes = false;
160
+ for (const line of (matter[1] ?? "").split(/\r?\n/)) {
161
+ if (!line.trim() || line.trim().startsWith("#")) continue;
162
+ const indented = /^[ \t]+/.test(line);
163
+ const pair = /^[ \t]*([^:]+):[ \t]*(.*)$/.exec(line);
164
+ if (!pair) continue;
165
+ const key = unquote(pair[1] ?? "");
166
+ const value = unquote(pair[2] ?? "");
167
+ if (inRoutes && indented) {
168
+ // ` /board: Working the board` — a route, and the section it is about.
169
+ if (key && value) routes.push({ route: key, target: value });
170
+ continue;
171
+ }
172
+ inRoutes = !indented && (key === "route" || key === "routes") && value === "";
173
+ if (!inRoutes && !indented && key) frontMatter[key] = value;
174
+ }
175
+ }
176
+ // Longest route first, so `/reports/exports` wins over `/reports`.
177
+ routes.sort((a, b) => b.route.length - a.route.length);
178
+
179
+ const slug = createSlugger();
180
+ const sections: HelpSection[] = [];
181
+ let current: HelpSection | undefined;
182
+ let fence: string | undefined;
183
+ for (const line of body.split(/\r?\n/)) {
184
+ const fenced = FENCE.exec(line);
185
+ if (fenced) {
186
+ const mark = fenced[1] ?? "";
187
+ fence = fence === undefined ? mark : fence === mark ? undefined : fence;
188
+ }
189
+ const heading = fence === undefined ? HEADING.exec(line) : null;
190
+ if (heading) {
191
+ const title = plainText(heading[2] ?? "");
192
+ current = { id: slug(title), depth: (heading[1] ?? "").length, title, markdown: "" };
193
+ sections.push(current);
194
+ } else if (!current) {
195
+ // Everything before the first heading is one unnamed section, so a
196
+ // search that matches the opening paragraph still shows it.
197
+ current = { id: "", depth: 0, title: "", markdown: "" };
198
+ sections.push(current);
199
+ }
200
+ current.markdown += `${line}\n`;
201
+ }
202
+ const preamble = sections[0];
203
+ if (preamble && preamble.depth === 0 && !preamble.markdown.trim()) sections.shift();
204
+
205
+ const title = frontMatter.title ?? sections.find((s) => s.depth === 1)?.title;
206
+ return { title, frontMatter, routes, body, sections };
207
+ }
208
+
209
+ function normalizeRoute(route: string): string {
210
+ const path = (route.split(/[?#]/)[0] ?? "").trim().toLowerCase();
211
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
212
+ }
213
+
214
+ function findSection(doc: HelpDocument, target: string): HelpSection | undefined {
215
+ const wanted = target.trim().toLowerCase();
216
+ return doc.sections.find(
217
+ (s) =>
218
+ s.id !== "" &&
219
+ (s.id === wanted || s.id === slugify(wanted) || s.title.toLowerCase() === wanted),
220
+ );
221
+ }
222
+
223
+ /**
224
+ * The section a route is about: the front matter's map first (exact, then the
225
+ * longest matching prefix, so `/tasks/123` finds `/tasks`), then a heading
226
+ * whose slug is the route or its last segment.
227
+ */
228
+ export function resolveHelpTarget(doc: HelpDocument, context?: string): string | undefined {
229
+ if (!context) return undefined;
230
+ const path = normalizeRoute(context);
231
+ if (!path) return undefined;
232
+
233
+ for (const { route, target } of doc.routes) {
234
+ const candidate = normalizeRoute(route);
235
+ const prefix = candidate === "/" ? "/" : `${candidate}/`;
236
+ if (candidate === path || path.startsWith(prefix)) {
237
+ const section = findSection(doc, target);
238
+ if (section) return section.id;
239
+ }
240
+ }
241
+ const segments = path.split("/").filter(Boolean);
242
+ for (const candidate of [path, segments.at(-1)]) {
243
+ if (!candidate) continue;
244
+ const section = findSection(doc, candidate);
245
+ if (section) return section.id;
246
+ }
247
+ return undefined;
248
+ }
249
+
250
+ /* ── heading anchors ─────────────────────────────────────────────────────── */
251
+
252
+ interface HastNode {
253
+ type?: string;
254
+ tagName?: string;
255
+ value?: string;
256
+ properties?: Record<string, unknown>;
257
+ children?: HastNode[];
258
+ }
259
+
260
+ function nodeText(node: HastNode): string {
261
+ if (node.type === "text") return node.value ?? "";
262
+ return (node.children ?? []).map(nodeText).join("");
263
+ }
264
+
265
+ /**
266
+ * Give every heading the slug the outline links to. It runs AFTER sanitize —
267
+ * the ids are ours, not the document's, so they are added to trusted nodes
268
+ * rather than smuggled through the sanitizer.
269
+ */
270
+ function rehypeHelpAnchors() {
271
+ return (tree: HastNode) => {
272
+ const slug = createSlugger();
273
+ const walk = (node: HastNode) => {
274
+ const tag = node.tagName ?? "";
275
+ if (/^h[1-6]$/.test(tag)) {
276
+ node.properties = { ...node.properties, id: slug(plainText(nodeText(node))) };
277
+ }
278
+ for (const child of node.children ?? []) walk(child);
279
+ };
280
+ walk(tree);
281
+ };
282
+ }
283
+
284
+ /* ── keyboard ────────────────────────────────────────────────────────────── */
285
+
286
+ function isTypingTarget(target: EventTarget | null): boolean {
287
+ if (!(target instanceof HTMLElement)) return false;
288
+ if (target.isContentEditable) return true;
289
+ const tag = target.tagName;
290
+ if (tag === "TEXTAREA" || tag === "SELECT") return true;
291
+ if (tag !== "INPUT") return target.closest('[role="textbox"], [role="searchbox"]') !== null;
292
+ const type = (target as HTMLInputElement).type;
293
+ return type !== "checkbox" && type !== "radio" && type !== "button" && type !== "submit";
294
+ }
295
+
296
+ /**
297
+ * The shell owns the open state and exposes only its button, so with no
298
+ * `onOpen` the shortcut presses exactly the control a pointer would. Already
299
+ * open (the button says so) means `?` is a no-op rather than a close.
300
+ */
301
+ function pressHelpButton(): void {
302
+ const button = document.querySelector<HTMLElement>('[data-slot="help-button"]');
303
+ if (!button || button.getAttribute("aria-expanded") === "true") return;
304
+ button.click();
305
+ }
306
+
307
+ export interface HelpHotkeysOptions {
308
+ /** Whether the panel is open. Escape only closes when it is. */
309
+ open?: boolean;
310
+ /** Defaults to pressing the shell's help button. */
311
+ onOpen?: () => void;
312
+ onClose?: () => void;
313
+ /** Turn the shortcuts off without unmounting. */
314
+ disabled?: boolean;
315
+ }
316
+
317
+ /**
318
+ * Escape closes, `?` opens — the second only when focus is not in an input,
319
+ * because `?` is a character before it is a shortcut and stealing it from
320
+ * someone typing a question into a filter box is worse than having no
321
+ * shortcut at all.
322
+ *
323
+ * `HelpPanel` calls this itself for Escape. Mount `<HelpHotkeys />` in the
324
+ * shell's children for `?`, which has to be listening while the panel is
325
+ * closed and therefore unmounted.
326
+ */
327
+ export function useHelpHotkeys({ open, onOpen, onClose, disabled }: HelpHotkeysOptions = {}): void {
328
+ const state = React.useRef({ open, onOpen, onClose });
329
+ state.current = { open, onOpen, onClose };
330
+ React.useEffect(() => {
331
+ if (disabled) return;
332
+ const onKeyDown = (event: KeyboardEvent) => {
333
+ const { open: isOpen, onOpen: openIt, onClose: closeIt } = state.current;
334
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
335
+ if (event.key === "Escape") {
336
+ if (isOpen && closeIt) closeIt();
337
+ return;
338
+ }
339
+ if (event.key !== "?" || isOpen || isTypingTarget(event.target)) return;
340
+ event.preventDefault();
341
+ (openIt ?? pressHelpButton)();
342
+ };
343
+ document.addEventListener("keydown", onKeyDown);
344
+ return () => document.removeEventListener("keydown", onKeyDown);
345
+ }, [disabled]);
346
+ }
347
+
348
+ export interface HelpHotkeysProps extends HelpHotkeysOptions {}
349
+
350
+ /** `useHelpHotkeys` as a component, for a shell whose children are markup. */
351
+ export function HelpHotkeys(props: HelpHotkeysProps): null {
352
+ useHelpHotkeys(props);
353
+ return null;
354
+ }
355
+
356
+ /**
357
+ * `documentUrl` when it is safe to hang off an `href`, `undefined` otherwise.
358
+ *
359
+ * A relative URL is resolved against the current page first, so "/docs/x.md"
360
+ * — the ordinary case — passes on any http(s) origin and is not special-cased
361
+ * into a hole. `javascript:`, `data:`, `blob:` and `vbscript:` all fall out of
362
+ * the same allow-list rather than each needing to be thought of.
363
+ */
364
+ export function httpDocumentUrl(documentUrl: string | undefined): string | undefined {
365
+ if (!documentUrl) return undefined;
366
+ try {
367
+ const base = typeof location === "undefined" ? undefined : location.href;
368
+ const { protocol } = base === undefined ? new URL(documentUrl) : new URL(documentUrl, base);
369
+ return protocol === "http:" || protocol === "https:" ? documentUrl : undefined;
370
+ } catch {
371
+ // Not a URL. No anchor at all beats an anchor that goes nowhere.
372
+ return undefined;
373
+ }
374
+ }
375
+
376
+ /* ── the panel ───────────────────────────────────────────────────────────── */
377
+
378
+ export interface HelpPanelProps extends Omit<React.ComponentProps<"div">, "title" | "onSelect"> {
379
+ /** The functional document, as markdown. A function is loaded on first open. */
380
+ source: HelpSource;
381
+ /** From the shell's help slot. `false` renders nothing and loads nothing. */
382
+ open?: boolean;
383
+ /** Closes the panel: the header button and Escape. */
384
+ onClose?: () => void;
385
+ /** The current route. Opens the panel on the section that route is about. */
386
+ context?: string;
387
+ /** Header title. Defaults to the front matter's `title:`, then the first `#`. */
388
+ title?: string;
389
+ /**
390
+ * Where "open in new tab" goes — the document's own URL, so the tab can be
391
+ * shared. Without one the panel opens the text it already has.
392
+ *
393
+ * **http(s) only.** The value reaches an `href` the user clicks, and the
394
+ * panel's source is the extension's functional document, which an extension
395
+ * may well be fetching from somewhere configurable. A `javascript:` URL in
396
+ * that slot is script execution one click away, and `data:` opens a document
397
+ * of the author's choosing on a tab the user believes is the help page.
398
+ */
399
+ documentUrl?: string;
400
+ /** Deepest heading level in the outline. 3 by default; 6 shows every heading. */
401
+ outlineDepth?: number;
402
+ /** Start with the search box filled. For the guide and for tests. */
403
+ defaultQuery?: string;
404
+ }
405
+
406
+ const headerButton = [
407
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-[var(--ds-radius-md)]",
408
+ "text-[color:var(--ds-muted-foreground)] outline-none",
409
+ "transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
410
+ "hover:bg-[var(--ds-hover)] hover:text-[color:var(--ds-foreground)]",
411
+ "focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)]",
412
+ "[&_svg]:size-4",
413
+ ].join(" ");
414
+
415
+ /**
416
+ * Documents already loaded, by the loader that produced them.
417
+ *
418
+ * `helpPanelSlot` returns `null` while closed, so closing the panel UNMOUNTS
419
+ * it and takes its state with it. Every reopen therefore re-ran the loader:
420
+ * on a `fetch` that is a network round trip per open, and on a slow one the
421
+ * panel shows a skeleton for a document the user read a minute ago. The cache
422
+ * is keyed by the loader itself and held weakly, so a caller that memoises its
423
+ * loader — the documented shape — gets the document back instantly, and one
424
+ * that builds a new closure each render is simply not cached rather than
425
+ * leaking.
426
+ */
427
+ const loadedDocuments = /* @__PURE__ */ new WeakMap<object, string>();
428
+
429
+ function HelpPanel({
430
+ source,
431
+ open = true,
432
+ onClose,
433
+ context,
434
+ title,
435
+ documentUrl,
436
+ outlineDepth = 3,
437
+ defaultQuery = "",
438
+ className,
439
+ ...props
440
+ }: HelpPanelProps) {
441
+ const [text, setText] = React.useState<string | undefined>(
442
+ typeof source === "string" ? source : loadedDocuments.get(source),
443
+ );
444
+ const [failed, setFailed] = React.useState(false);
445
+ const [query, setQuery] = React.useState(defaultQuery);
446
+ const [activeId, setActiveId] = React.useState<string>();
447
+ const bodyRef = React.useRef<HTMLDivElement>(null);
448
+ const panelRef = React.useRef<HTMLDivElement>(null);
449
+
450
+ useHelpHotkeys({ open, onClose });
451
+
452
+ /**
453
+ * Focus moves into the panel when it opens.
454
+ *
455
+ * The shell's help button stays where it is, so without this, focus is
456
+ * still on the button: Escape works (the handler is on the document) but
457
+ * Tab walks the page BEHIND the panel, and a screen reader announces
458
+ * nothing at all — the panel is simply not where the user is. The search
459
+ * box is the target, because searching is what the panel is for.
460
+ */
461
+ React.useEffect(() => {
462
+ if (!open) return;
463
+ // After paint, so the element exists and Base UI's own focus work — the
464
+ // pop-up, a dialog the panel is rendered beside — has settled.
465
+ const frame = requestAnimationFrame(() => {
466
+ const root = panelRef.current;
467
+ if (!root || root.contains(document.activeElement)) return;
468
+ // `preventScroll`: focusing scrolls an element into view by default, and
469
+ // the panel has just opened on the section the current route is about.
470
+ // Scrolling it back to the top would undo exactly that.
471
+ root
472
+ .querySelector<HTMLElement>('[data-slot="help-panel-search-input"]')
473
+ ?.focus({ preventScroll: true });
474
+ });
475
+ return () => cancelAnimationFrame(frame);
476
+ }, [open]);
477
+
478
+ React.useEffect(() => {
479
+ if (!open) return;
480
+ if (typeof source === "string") {
481
+ setText(source);
482
+ setFailed(false);
483
+ return;
484
+ }
485
+ const cached = loadedDocuments.get(source);
486
+ if (cached !== undefined) {
487
+ setText(cached);
488
+ setFailed(false);
489
+ return;
490
+ }
491
+ let cancelled = false;
492
+ setFailed(false);
493
+ source().then(
494
+ (loaded) => {
495
+ loadedDocuments.set(source, loaded);
496
+ if (!cancelled) setText(loaded);
497
+ },
498
+ () => {
499
+ if (!cancelled) setFailed(true);
500
+ },
501
+ );
502
+ return () => {
503
+ cancelled = true;
504
+ };
505
+ }, [open, source]);
506
+
507
+ const doc = React.useMemo(() => parseHelpDocument(text ?? ""), [text]);
508
+ const needle = query.trim().toLowerCase();
509
+ const matches = React.useMemo(
510
+ () =>
511
+ needle
512
+ ? doc.sections.filter((section) => section.markdown.toLowerCase().includes(needle))
513
+ : doc.sections,
514
+ [doc, needle],
515
+ );
516
+ const markdown = needle ? matches.map((section) => section.markdown).join("\n") : doc.body;
517
+ const outline = matches.filter((s) => s.depth >= 1 && s.depth <= outlineDepth);
518
+
519
+ // Where the last programmatic scroll left the panel, so a re-align after the
520
+ // document reflows can tell "nobody has scrolled since" from "the user has".
521
+ const scrolledTo = React.useRef<number | undefined>(undefined);
522
+ const scrollTo = React.useCallback((id: string, onlyIfUntouched = false) => {
523
+ const scroller = bodyRef.current;
524
+ const heading = scroller?.querySelector<HTMLElement>(`#${CSS.escape(id)}`);
525
+ if (!scroller || !heading) return false;
526
+ if (onlyIfUntouched && scrolledTo.current !== scroller.scrollTop) return false;
527
+ // Not `scrollIntoView`: the panel is a scroller inside a fixed aside, and
528
+ // scrollIntoView would scroll the page behind it as well.
529
+ scroller.scrollTop += heading.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
530
+ scrolledTo.current = scroller.scrollTop;
531
+ setActiveId(id);
532
+ return true;
533
+ }, []);
534
+
535
+ // Open on the section the current route is about, once the document is here.
536
+ const target = React.useMemo(() => resolveHelpTarget(doc, context), [doc, context]);
537
+ React.useEffect(() => {
538
+ if (!open || !target || needle) return;
539
+ let cancelled = false;
540
+ scrollTo(target);
541
+ // Twice more, and only while the user has not scrolled since: the markdown
542
+ // mounts in the same commit, and the display face lands after that. A
543
+ // heading that grows 4px once the font arrives leaves the jump short of
544
+ // the section it was supposed to open on.
545
+ const again = () => {
546
+ if (!cancelled) scrollTo(target, scrolledTo.current !== undefined);
547
+ };
548
+ const frame = requestAnimationFrame(again);
549
+ void document.fonts?.ready.then(again);
550
+ return () => {
551
+ cancelled = true;
552
+ cancelAnimationFrame(frame);
553
+ };
554
+ }, [open, target, needle, scrollTo]);
555
+
556
+ // "Open in new tab" without a URL still has the text, so it opens that. A
557
+ // `documentUrl` this panel refused counts as not having one.
558
+ const safeDocumentUrl = httpDocumentUrl(documentUrl);
559
+ const [fallbackUrl, setFallbackUrl] = React.useState<string>();
560
+ React.useEffect(() => {
561
+ if (safeDocumentUrl || text === undefined) return;
562
+ const url = URL.createObjectURL(new Blob([text], { type: "text/markdown;charset=utf-8" }));
563
+ setFallbackUrl(url);
564
+ return () => URL.revokeObjectURL(url);
565
+ }, [safeDocumentUrl, text]);
566
+ // The blob: fallback is ours — made here from text already in the panel —
567
+ // so it is not put through the same check.
568
+ const href = safeDocumentUrl ?? fallbackUrl;
569
+
570
+ if (!open) return null;
571
+
572
+ const heading = title ?? doc.title ?? "Help";
573
+ const loading = text === undefined && !failed;
574
+
575
+ return (
576
+ <div
577
+ ref={panelRef}
578
+ data-slot="help-panel"
579
+ data-loading={loading || undefined}
580
+ className={cn(
581
+ "flex h-full min-h-0 w-full flex-col overflow-hidden",
582
+ "bg-[var(--ds-card)] text-[color:var(--ds-foreground)]",
583
+ className,
584
+ )}
585
+ {...props}
586
+ >
587
+ <header
588
+ data-slot="help-panel-header"
589
+ className="flex shrink-0 items-center gap-2 border-b border-[var(--ds-border-subtle)] px-4 py-3"
590
+ >
591
+ <h2
592
+ data-slot="help-panel-title"
593
+ className={cn(
594
+ "min-w-0 flex-1 truncate font-display font-bold",
595
+ "text-[length:var(--ds-text-body-sm)] leading-[var(--ds-text-body-sm--line-height)]",
596
+ "text-[color:var(--ds-foreground)]",
597
+ )}
598
+ >
599
+ {heading}
600
+ </h2>
601
+ {href ? (
602
+ <a
603
+ data-slot="help-panel-open-full"
604
+ href={href}
605
+ target="_blank"
606
+ rel="noreferrer"
607
+ aria-label="Open the full document in a new tab"
608
+ className={headerButton}
609
+ >
610
+ <ExternalLink aria-hidden />
611
+ </a>
612
+ ) : null}
613
+ {onClose ? (
614
+ <button
615
+ type="button"
616
+ data-slot="help-panel-close"
617
+ aria-label="Close help"
618
+ onClick={onClose}
619
+ className={headerButton}
620
+ >
621
+ <X aria-hidden />
622
+ </button>
623
+ ) : null}
624
+ </header>
625
+
626
+ <div
627
+ data-slot="help-panel-search"
628
+ className="shrink-0 border-b border-[var(--ds-border-subtle)] px-4 py-3"
629
+ >
630
+ <div className="relative">
631
+ <Search
632
+ aria-hidden
633
+ className="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-[color:var(--ds-text-tertiary)]"
634
+ />
635
+ <input
636
+ type="search"
637
+ data-slot="help-panel-search-input"
638
+ aria-label={`Search ${heading}`}
639
+ placeholder="Search this document"
640
+ value={query}
641
+ onChange={(event) => setQuery(event.target.value)}
642
+ className={cn(
643
+ "h-9 w-full rounded-[var(--ds-radius-md)] border border-[var(--ds-border)]",
644
+ "bg-[var(--ds-muted)] py-2 pr-3 pl-8",
645
+ "text-[length:var(--ds-text-caption-lg)] leading-[var(--ds-text-caption-lg--line-height)]",
646
+ "text-[color:var(--ds-foreground)] placeholder:text-[color:var(--ds-text-tertiary)]",
647
+ "transition-[border-color,box-shadow] duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
648
+ "outline-none hover:border-[var(--ds-border-strong)]",
649
+ "focus-visible:border-[var(--ds-ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ds-ring)]/40",
650
+ "[&::-webkit-search-cancel-button]:hidden",
651
+ )}
652
+ />
653
+ </div>
654
+ </div>
655
+
656
+ {outline.length > 0 ? (
657
+ <nav
658
+ data-slot="help-panel-outline"
659
+ aria-label={`${heading} sections`}
660
+ className="max-h-40 shrink-0 overflow-y-auto border-b border-[var(--ds-border-subtle)] px-2 py-2"
661
+ >
662
+ <ol className="flex flex-col">
663
+ {outline.map((section) => (
664
+ <li key={section.id}>
665
+ <a
666
+ data-slot="help-panel-outline-item"
667
+ data-depth={section.depth}
668
+ data-active={section.id === activeId || undefined}
669
+ href={`#${section.id}`}
670
+ onClick={(event) => {
671
+ // The anchor is a real deep link — copyable, and it works
672
+ // in a new tab — but inside an SPA the panel scrolls
673
+ // itself rather than handing the route a hash.
674
+ event.preventDefault();
675
+ scrollTo(section.id);
676
+ }}
677
+ className={cn(
678
+ "block truncate rounded-[var(--ds-radius-sm)] px-2 py-1 no-underline",
679
+ "text-[length:var(--ds-text-caption)] leading-[var(--ds-text-caption--line-height)]",
680
+ "text-[color:var(--ds-muted-foreground)] outline-none",
681
+ "transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
682
+ "hover:bg-[var(--ds-hover)] hover:text-[color:var(--ds-foreground)]",
683
+ "focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)]",
684
+ "data-[active]:bg-[var(--ds-primary-soft)] data-[active]:text-[color:var(--ds-primary-soft-foreground)]",
685
+ section.depth === 2 && "pl-5",
686
+ section.depth >= 3 && "pl-8",
687
+ )}
688
+ >
689
+ {section.title}
690
+ </a>
691
+ </li>
692
+ ))}
693
+ </ol>
694
+ </nav>
695
+ ) : null}
696
+
697
+ <div
698
+ ref={bodyRef}
699
+ data-slot="help-panel-body"
700
+ className="min-h-0 flex-1 overflow-y-auto px-4 py-3"
701
+ >
702
+ {loading ? (
703
+ <div data-slot="help-panel-loading" className="flex items-center gap-2 py-6">
704
+ <Spinner />
705
+ <span className="text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]">
706
+ Loading help…
707
+ </span>
708
+ </div>
709
+ ) : failed ? (
710
+ <p
711
+ data-slot="help-panel-error"
712
+ className="py-6 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-destructive)]"
713
+ >
714
+ The help document could not be loaded.
715
+ </p>
716
+ ) : needle && matches.length === 0 ? (
717
+ <p
718
+ data-slot="help-panel-empty"
719
+ className="py-6 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]"
720
+ >
721
+ No section matches “{query.trim()}”.
722
+ </p>
723
+ ) : (
724
+ <Markdown
725
+ compact
726
+ sanitizeSchema={markdownSanitizeSchema}
727
+ rehypePlugins={[rehypeHelpAnchors]}
728
+ >
729
+ {markdown}
730
+ </Markdown>
731
+ )}
732
+ </div>
733
+ </div>
734
+ );
735
+ }
736
+
737
+ /* ── the shell slot ──────────────────────────────────────────────────────── */
738
+
739
+ export interface HelpPanelSlotConfig extends Omit<HelpPanelProps, "open" | "onClose"> {}
740
+
741
+ /** What `AppShell` hands its `helpPanel` render function. */
742
+ interface ShellHelpSlot {
743
+ open: boolean;
744
+ source?: string;
745
+ onClose: () => void;
746
+ }
747
+
748
+ /**
749
+ * `AppShell`'s `helpPanel` slot, filled.
750
+ *
751
+ * The shell renders whatever the function returns, open or closed, so this
752
+ * returns `null` while closed — a panel that rendered an empty box would draw
753
+ * the shell's bordered aside over the corner with nothing in it. `help.source`
754
+ * from the shell is used when no `source` is passed here.
755
+ */
756
+ export function helpPanelSlot(
757
+ config: HelpPanelSlotConfig,
758
+ ): (slot: ShellHelpSlot) => React.ReactNode {
759
+ return ({ open, source, onClose }) =>
760
+ open ? (
761
+ <HelpPanel {...config} source={config.source ?? source ?? ""} open onClose={onClose} />
762
+ ) : null;
763
+ }
764
+
765
+ export { HelpPanel };