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,131 @@
1
+ "use client";
2
+ import * as React from "react";
3
+ //#region src/components/help-panel.d.ts
4
+ /**
5
+ * The markdown, or a loader for it. A function is called the first time the
6
+ * panel is opened, which is what keeps a long document out of the initial
7
+ * payload; keep its identity stable (module scope, or `useCallback`) or it is
8
+ * called again on every parent render.
9
+ */
10
+ export type HelpSource = string | (() => Promise<string>);
11
+ export interface HelpSection {
12
+ /** Slug of the heading — the `#anchor` the outline links to. `""` for the preamble. */
13
+ id: string;
14
+ /** 1–6 for a heading; 0 for the text before the first heading. */
15
+ depth: number;
16
+ /** Heading text with inline markdown removed. */
17
+ title: string;
18
+ /** The heading and everything under it, verbatim, so it can be re-rendered alone. */
19
+ markdown: string;
20
+ }
21
+ export interface HelpRoute {
22
+ /** A route the extension serves, e.g. `/board`. */
23
+ route: string;
24
+ /** The section it is about: a heading, or that heading's slug. */
25
+ target: string;
26
+ }
27
+ export interface HelpDocument {
28
+ /** `title:` from the front matter, else the first `#` heading. */
29
+ title?: string;
30
+ /** Every scalar key in the front matter. */
31
+ frontMatter: Record<string, string>;
32
+ /** The `route:` / `routes:` block, longest route first. */
33
+ routes: HelpRoute[];
34
+ /** The document without its front matter. */
35
+ body: string;
36
+ sections: HelpSection[];
37
+ }
38
+ /**
39
+ * Parse the document once: front matter, then sections split at ATX headings.
40
+ *
41
+ * Exported because the route map and the outline are worth asserting on
42
+ * without a browser, and because an extension that wants its own navigation
43
+ * over the same document should read it the same way rather than write a
44
+ * second parser.
45
+ */
46
+ export declare function parseHelpDocument(source: string): HelpDocument;
47
+ /**
48
+ * The section a route is about: the front matter's map first (exact, then the
49
+ * longest matching prefix, so `/tasks/123` finds `/tasks`), then a heading
50
+ * whose slug is the route or its last segment.
51
+ */
52
+ export declare function resolveHelpTarget(doc: HelpDocument, context?: string): string | undefined;
53
+ export interface HelpHotkeysOptions {
54
+ /** Whether the panel is open. Escape only closes when it is. */
55
+ open?: boolean;
56
+ /** Defaults to pressing the shell's help button. */
57
+ onOpen?: () => void;
58
+ onClose?: () => void;
59
+ /** Turn the shortcuts off without unmounting. */
60
+ disabled?: boolean;
61
+ }
62
+ /**
63
+ * Escape closes, `?` opens — the second only when focus is not in an input,
64
+ * because `?` is a character before it is a shortcut and stealing it from
65
+ * someone typing a question into a filter box is worse than having no
66
+ * shortcut at all.
67
+ *
68
+ * `HelpPanel` calls this itself for Escape. Mount `<HelpHotkeys />` in the
69
+ * shell's children for `?`, which has to be listening while the panel is
70
+ * closed and therefore unmounted.
71
+ */
72
+ export declare function useHelpHotkeys({ open, onOpen, onClose, disabled }?: HelpHotkeysOptions): void;
73
+ export interface HelpHotkeysProps extends HelpHotkeysOptions {}
74
+ /** `useHelpHotkeys` as a component, for a shell whose children are markup. */
75
+ export declare function HelpHotkeys(props: HelpHotkeysProps): null;
76
+ /**
77
+ * `documentUrl` when it is safe to hang off an `href`, `undefined` otherwise.
78
+ *
79
+ * A relative URL is resolved against the current page first, so "/docs/x.md"
80
+ * — the ordinary case — passes on any http(s) origin and is not special-cased
81
+ * into a hole. `javascript:`, `data:`, `blob:` and `vbscript:` all fall out of
82
+ * the same allow-list rather than each needing to be thought of.
83
+ */
84
+ export declare function httpDocumentUrl(documentUrl: string | undefined): string | undefined;
85
+ export interface HelpPanelProps extends Omit<React.ComponentProps<"div">, "title" | "onSelect"> {
86
+ /** The functional document, as markdown. A function is loaded on first open. */
87
+ source: HelpSource;
88
+ /** From the shell's help slot. `false` renders nothing and loads nothing. */
89
+ open?: boolean;
90
+ /** Closes the panel: the header button and Escape. */
91
+ onClose?: () => void;
92
+ /** The current route. Opens the panel on the section that route is about. */
93
+ context?: string;
94
+ /** Header title. Defaults to the front matter's `title:`, then the first `#`. */
95
+ title?: string;
96
+ /**
97
+ * Where "open in new tab" goes — the document's own URL, so the tab can be
98
+ * shared. Without one the panel opens the text it already has.
99
+ *
100
+ * **http(s) only.** The value reaches an `href` the user clicks, and the
101
+ * panel's source is the extension's functional document, which an extension
102
+ * may well be fetching from somewhere configurable. A `javascript:` URL in
103
+ * that slot is script execution one click away, and `data:` opens a document
104
+ * of the author's choosing on a tab the user believes is the help page.
105
+ */
106
+ documentUrl?: string;
107
+ /** Deepest heading level in the outline. 3 by default; 6 shows every heading. */
108
+ outlineDepth?: number;
109
+ /** Start with the search box filled. For the guide and for tests. */
110
+ defaultQuery?: string;
111
+ }
112
+ declare function HelpPanel({ source, open, onClose, context, title, documentUrl, outlineDepth, defaultQuery, className, ...props }: HelpPanelProps): React.JSX.Element | null;
113
+ export interface HelpPanelSlotConfig extends Omit<HelpPanelProps, "open" | "onClose"> {}
114
+ /** What `AppShell` hands its `helpPanel` render function. */
115
+ interface ShellHelpSlot {
116
+ open: boolean;
117
+ source?: string;
118
+ onClose: () => void;
119
+ }
120
+ /**
121
+ * `AppShell`'s `helpPanel` slot, filled.
122
+ *
123
+ * The shell renders whatever the function returns, open or closed, so this
124
+ * returns `null` while closed — a panel that rendered an empty box would draw
125
+ * the shell's bordered aside over the corner with nothing in it. `help.source`
126
+ * from the shell is used when no `source` is passed here.
127
+ */
128
+ export declare function helpPanelSlot(config: HelpPanelSlotConfig): (slot: ShellHelpSlot) => React.ReactNode;
129
+ //#endregion
130
+ export { HelpPanel };
131
+ //# sourceMappingURL=help-panel.d.ts.map
@@ -0,0 +1,545 @@
1
+ "use client";
2
+ "use client";
3
+ import { cn } from "../lib/cn.js";
4
+ import { Spinner } from "./spinner.js";
5
+ import { Markdown, markdownSanitizeSchema } from "./markdown.js";
6
+ import { ExternalLink, Search, X } from "lucide-react";
7
+ import { jsx, jsxs } from "react/jsx-runtime";
8
+ import * as React from "react";
9
+ //#region src/components/help-panel.tsx
10
+ /**
11
+ * HelpPanel — the extension's own help, answered from its own functional
12
+ * document.
13
+ *
14
+ * §12 of how-to-create-a-cortena-extension, audit rule P-13. Every extension
15
+ * with a UI ships one: anchored bottom-right, collapsed by default, expanding
16
+ * into a right-hand vertical panel. It fills `AppShell`'s help slot, so the
17
+ * shell keeps the button, the open state and the corner geometry (the panel
18
+ * stops 12px above the cluster so the button that opened it stays clickable).
19
+ *
20
+ * ```tsx
21
+ * <AppShell
22
+ * extension={{ id: "tasks", name: "Tasks" }}
23
+ * user={user}
24
+ * help={{ source: "/docs/functional.md" }}
25
+ * helpPanel={helpPanelSlot({
26
+ * // lazy: the document is fetched the first time help is opened
27
+ * source: () => fetch("/docs/functional.md").then((r) => r.text()),
28
+ * documentUrl: "/docs/functional.md",
29
+ * context: location.pathname,
30
+ * })}
31
+ * >
32
+ * <HelpHotkeys />
33
+ * …
34
+ * </AppShell>
35
+ * ```
36
+ *
37
+ * It is NOT the agent pop-up (§17) and the two must not be merged. Help
38
+ * answers *about* the extension from a document; the agent *acts* in the
39
+ * extension through its tools. Same corner, different affordances.
40
+ *
41
+ * Giving the functional document a runtime consumer is the whole point: a
42
+ * stale document becomes a visibly wrong help panel rather than a file nobody
43
+ * opens. So the panel renders the document itself — outline from its headings,
44
+ * search over its text, `#heading` deep links, and "open in new tab" for the
45
+ * whole thing — rather than a hand-written copy of it that can drift.
46
+ *
47
+ * `context` is the current route. On open the panel jumps to the section that
48
+ * route is about, matched against the front matter's `routes:` map first and
49
+ * against the headings second, so help opens on the part of the document the
50
+ * user is looking at rather than at the top of it.
51
+ *
52
+ * Lives on the `cortena-ui/markdown` entry: it renders markdown, so it carries
53
+ * react-markdown, and no extension that only draws a Button may pay for that.
54
+ */
55
+ /** Lowercase, punctuation to hyphens. The same slug the rendered heading gets. */
56
+ function slugify(text) {
57
+ return text.toLowerCase().normalize("NFKD").replace(/[\u0300-\u036f]/g, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "") || "section";
58
+ }
59
+ /** Slugs in document order, with `-1`, `-2` for repeats, as GitHub numbers them. */
60
+ function createSlugger() {
61
+ const seen = /* @__PURE__ */ new Map();
62
+ return (text) => {
63
+ const base = slugify(text);
64
+ const n = seen.get(base) ?? 0;
65
+ seen.set(base, n + 1);
66
+ return n === 0 ? base : `${base}-${n}`;
67
+ };
68
+ }
69
+ /**
70
+ * Heading text with the inline markdown taken off, so the outline entry and
71
+ * the slug match what the rendered heading actually says. Without this a
72
+ * `## [Reports](/reports)` heading slugs to `reports-reports` in the outline
73
+ * and to `reports` in the document, and the deep link lands nowhere.
74
+ */
75
+ function plainText(md) {
76
+ return md.replace(/`([^`]*)`/g, "$1").replace(/!\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/\[([^\]]*)\]\([^)]*\)/g, "$1").replace(/<[^>]*>/g, "").replace(/[*_~]+/g, "").replace(/\s+/g, " ").trim();
77
+ }
78
+ const FRONT_MATTER = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
79
+ const HEADING = /^(#{1,6})[ \t]+(.+?)[ \t]*#*[ \t]*$/;
80
+ const FENCE = /^[ \t]*(```|~~~)/;
81
+ function unquote(value) {
82
+ const trimmed = value.trim();
83
+ const quoted = /^(['"])([\s\S]*)\1$/.exec(trimmed);
84
+ return quoted ? quoted[2] ?? "" : trimmed;
85
+ }
86
+ /**
87
+ * Parse the document once: front matter, then sections split at ATX headings.
88
+ *
89
+ * Exported because the route map and the outline are worth asserting on
90
+ * without a browser, and because an extension that wants its own navigation
91
+ * over the same document should read it the same way rather than write a
92
+ * second parser.
93
+ */
94
+ function parseHelpDocument(source) {
95
+ const matter = FRONT_MATTER.exec(source);
96
+ const body = matter ? source.slice(matter[0].length) : source;
97
+ const frontMatter = {};
98
+ const routes = [];
99
+ if (matter) {
100
+ let inRoutes = false;
101
+ for (const line of (matter[1] ?? "").split(/\r?\n/)) {
102
+ if (!line.trim() || line.trim().startsWith("#")) continue;
103
+ const indented = /^[ \t]+/.test(line);
104
+ const pair = /^[ \t]*([^:]+):[ \t]*(.*)$/.exec(line);
105
+ if (!pair) continue;
106
+ const key = unquote(pair[1] ?? "");
107
+ const value = unquote(pair[2] ?? "");
108
+ if (inRoutes && indented) {
109
+ if (key && value) routes.push({
110
+ route: key,
111
+ target: value
112
+ });
113
+ continue;
114
+ }
115
+ inRoutes = !indented && (key === "route" || key === "routes") && value === "";
116
+ if (!inRoutes && !indented && key) frontMatter[key] = value;
117
+ }
118
+ }
119
+ routes.sort((a, b) => b.route.length - a.route.length);
120
+ const slug = createSlugger();
121
+ const sections = [];
122
+ let current;
123
+ let fence;
124
+ for (const line of body.split(/\r?\n/)) {
125
+ const fenced = FENCE.exec(line);
126
+ if (fenced) {
127
+ const mark = fenced[1] ?? "";
128
+ fence = fence === void 0 ? mark : fence === mark ? void 0 : fence;
129
+ }
130
+ const heading = fence === void 0 ? HEADING.exec(line) : null;
131
+ if (heading) {
132
+ const title = plainText(heading[2] ?? "");
133
+ current = {
134
+ id: slug(title),
135
+ depth: (heading[1] ?? "").length,
136
+ title,
137
+ markdown: ""
138
+ };
139
+ sections.push(current);
140
+ } else if (!current) {
141
+ current = {
142
+ id: "",
143
+ depth: 0,
144
+ title: "",
145
+ markdown: ""
146
+ };
147
+ sections.push(current);
148
+ }
149
+ current.markdown += `${line}\n`;
150
+ }
151
+ const preamble = sections[0];
152
+ if (preamble && preamble.depth === 0 && !preamble.markdown.trim()) sections.shift();
153
+ return {
154
+ title: frontMatter.title ?? sections.find((s) => s.depth === 1)?.title,
155
+ frontMatter,
156
+ routes,
157
+ body,
158
+ sections
159
+ };
160
+ }
161
+ function normalizeRoute(route) {
162
+ const path = (route.split(/[?#]/)[0] ?? "").trim().toLowerCase();
163
+ return path.length > 1 ? path.replace(/\/+$/, "") : path;
164
+ }
165
+ function findSection(doc, target) {
166
+ const wanted = target.trim().toLowerCase();
167
+ return doc.sections.find((s) => s.id !== "" && (s.id === wanted || s.id === slugify(wanted) || s.title.toLowerCase() === wanted));
168
+ }
169
+ /**
170
+ * The section a route is about: the front matter's map first (exact, then the
171
+ * longest matching prefix, so `/tasks/123` finds `/tasks`), then a heading
172
+ * whose slug is the route or its last segment.
173
+ */
174
+ function resolveHelpTarget(doc, context) {
175
+ if (!context) return void 0;
176
+ const path = normalizeRoute(context);
177
+ if (!path) return void 0;
178
+ for (const { route, target } of doc.routes) {
179
+ const candidate = normalizeRoute(route);
180
+ const prefix = candidate === "/" ? "/" : `${candidate}/`;
181
+ if (candidate === path || path.startsWith(prefix)) {
182
+ const section = findSection(doc, target);
183
+ if (section) return section.id;
184
+ }
185
+ }
186
+ const segments = path.split("/").filter(Boolean);
187
+ for (const candidate of [path, segments.at(-1)]) {
188
+ if (!candidate) continue;
189
+ const section = findSection(doc, candidate);
190
+ if (section) return section.id;
191
+ }
192
+ }
193
+ function nodeText(node) {
194
+ if (node.type === "text") return node.value ?? "";
195
+ return (node.children ?? []).map(nodeText).join("");
196
+ }
197
+ /**
198
+ * Give every heading the slug the outline links to. It runs AFTER sanitize —
199
+ * the ids are ours, not the document's, so they are added to trusted nodes
200
+ * rather than smuggled through the sanitizer.
201
+ */
202
+ function rehypeHelpAnchors() {
203
+ return (tree) => {
204
+ const slug = createSlugger();
205
+ const walk = (node) => {
206
+ const tag = node.tagName ?? "";
207
+ if (/^h[1-6]$/.test(tag)) node.properties = {
208
+ ...node.properties,
209
+ id: slug(plainText(nodeText(node)))
210
+ };
211
+ for (const child of node.children ?? []) walk(child);
212
+ };
213
+ walk(tree);
214
+ };
215
+ }
216
+ function isTypingTarget(target) {
217
+ if (!(target instanceof HTMLElement)) return false;
218
+ if (target.isContentEditable) return true;
219
+ const tag = target.tagName;
220
+ if (tag === "TEXTAREA" || tag === "SELECT") return true;
221
+ if (tag !== "INPUT") return target.closest("[role=\"textbox\"], [role=\"searchbox\"]") !== null;
222
+ const type = target.type;
223
+ return type !== "checkbox" && type !== "radio" && type !== "button" && type !== "submit";
224
+ }
225
+ /**
226
+ * The shell owns the open state and exposes only its button, so with no
227
+ * `onOpen` the shortcut presses exactly the control a pointer would. Already
228
+ * open (the button says so) means `?` is a no-op rather than a close.
229
+ */
230
+ function pressHelpButton() {
231
+ const button = document.querySelector("[data-slot=\"help-button\"]");
232
+ if (!button || button.getAttribute("aria-expanded") === "true") return;
233
+ button.click();
234
+ }
235
+ /**
236
+ * Escape closes, `?` opens — the second only when focus is not in an input,
237
+ * because `?` is a character before it is a shortcut and stealing it from
238
+ * someone typing a question into a filter box is worse than having no
239
+ * shortcut at all.
240
+ *
241
+ * `HelpPanel` calls this itself for Escape. Mount `<HelpHotkeys />` in the
242
+ * shell's children for `?`, which has to be listening while the panel is
243
+ * closed and therefore unmounted.
244
+ */
245
+ function useHelpHotkeys({ open, onOpen, onClose, disabled } = {}) {
246
+ const state = React.useRef({
247
+ open,
248
+ onOpen,
249
+ onClose
250
+ });
251
+ state.current = {
252
+ open,
253
+ onOpen,
254
+ onClose
255
+ };
256
+ React.useEffect(() => {
257
+ if (disabled) return;
258
+ const onKeyDown = (event) => {
259
+ const { open: isOpen, onOpen: openIt, onClose: closeIt } = state.current;
260
+ if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
261
+ if (event.key === "Escape") {
262
+ if (isOpen && closeIt) closeIt();
263
+ return;
264
+ }
265
+ if (event.key !== "?" || isOpen || isTypingTarget(event.target)) return;
266
+ event.preventDefault();
267
+ (openIt ?? pressHelpButton)();
268
+ };
269
+ document.addEventListener("keydown", onKeyDown);
270
+ return () => document.removeEventListener("keydown", onKeyDown);
271
+ }, [disabled]);
272
+ }
273
+ /** `useHelpHotkeys` as a component, for a shell whose children are markup. */
274
+ function HelpHotkeys(props) {
275
+ useHelpHotkeys(props);
276
+ return null;
277
+ }
278
+ /**
279
+ * `documentUrl` when it is safe to hang off an `href`, `undefined` otherwise.
280
+ *
281
+ * A relative URL is resolved against the current page first, so "/docs/x.md"
282
+ * — the ordinary case — passes on any http(s) origin and is not special-cased
283
+ * into a hole. `javascript:`, `data:`, `blob:` and `vbscript:` all fall out of
284
+ * the same allow-list rather than each needing to be thought of.
285
+ */
286
+ function httpDocumentUrl(documentUrl) {
287
+ if (!documentUrl) return void 0;
288
+ try {
289
+ const base = typeof location === "undefined" ? void 0 : location.href;
290
+ const { protocol } = base === void 0 ? new URL(documentUrl) : new URL(documentUrl, base);
291
+ return protocol === "http:" || protocol === "https:" ? documentUrl : void 0;
292
+ } catch {
293
+ return;
294
+ }
295
+ }
296
+ const headerButton = [
297
+ "inline-flex size-8 shrink-0 items-center justify-center rounded-[var(--ds-radius-md)]",
298
+ "text-[color:var(--ds-muted-foreground)] outline-none",
299
+ "transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]",
300
+ "hover:bg-[var(--ds-hover)] hover:text-[color:var(--ds-foreground)]",
301
+ "focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)]",
302
+ "[&_svg]:size-4"
303
+ ].join(" ");
304
+ /**
305
+ * Documents already loaded, by the loader that produced them.
306
+ *
307
+ * `helpPanelSlot` returns `null` while closed, so closing the panel UNMOUNTS
308
+ * it and takes its state with it. Every reopen therefore re-ran the loader:
309
+ * on a `fetch` that is a network round trip per open, and on a slow one the
310
+ * panel shows a skeleton for a document the user read a minute ago. The cache
311
+ * is keyed by the loader itself and held weakly, so a caller that memoises its
312
+ * loader — the documented shape — gets the document back instantly, and one
313
+ * that builds a new closure each render is simply not cached rather than
314
+ * leaking.
315
+ */
316
+ const loadedDocuments = /* @__PURE__ */ new WeakMap();
317
+ function HelpPanel({ source, open = true, onClose, context, title, documentUrl, outlineDepth = 3, defaultQuery = "", className, ...props }) {
318
+ const [text, setText] = React.useState(typeof source === "string" ? source : loadedDocuments.get(source));
319
+ const [failed, setFailed] = React.useState(false);
320
+ const [query, setQuery] = React.useState(defaultQuery);
321
+ const [activeId, setActiveId] = React.useState();
322
+ const bodyRef = React.useRef(null);
323
+ const panelRef = React.useRef(null);
324
+ useHelpHotkeys({
325
+ open,
326
+ onClose
327
+ });
328
+ /**
329
+ * Focus moves into the panel when it opens.
330
+ *
331
+ * The shell's help button stays where it is, so without this, focus is
332
+ * still on the button: Escape works (the handler is on the document) but
333
+ * Tab walks the page BEHIND the panel, and a screen reader announces
334
+ * nothing at all — the panel is simply not where the user is. The search
335
+ * box is the target, because searching is what the panel is for.
336
+ */
337
+ React.useEffect(() => {
338
+ if (!open) return;
339
+ const frame = requestAnimationFrame(() => {
340
+ const root = panelRef.current;
341
+ if (!root || root.contains(document.activeElement)) return;
342
+ root.querySelector("[data-slot=\"help-panel-search-input\"]")?.focus({ preventScroll: true });
343
+ });
344
+ return () => cancelAnimationFrame(frame);
345
+ }, [open]);
346
+ React.useEffect(() => {
347
+ if (!open) return;
348
+ if (typeof source === "string") {
349
+ setText(source);
350
+ setFailed(false);
351
+ return;
352
+ }
353
+ const cached = loadedDocuments.get(source);
354
+ if (cached !== void 0) {
355
+ setText(cached);
356
+ setFailed(false);
357
+ return;
358
+ }
359
+ let cancelled = false;
360
+ setFailed(false);
361
+ source().then((loaded) => {
362
+ loadedDocuments.set(source, loaded);
363
+ if (!cancelled) setText(loaded);
364
+ }, () => {
365
+ if (!cancelled) setFailed(true);
366
+ });
367
+ return () => {
368
+ cancelled = true;
369
+ };
370
+ }, [open, source]);
371
+ const doc = React.useMemo(() => parseHelpDocument(text ?? ""), [text]);
372
+ const needle = query.trim().toLowerCase();
373
+ const matches = React.useMemo(() => needle ? doc.sections.filter((section) => section.markdown.toLowerCase().includes(needle)) : doc.sections, [doc, needle]);
374
+ const markdown = needle ? matches.map((section) => section.markdown).join("\n") : doc.body;
375
+ const outline = matches.filter((s) => s.depth >= 1 && s.depth <= outlineDepth);
376
+ const scrolledTo = React.useRef(void 0);
377
+ const scrollTo = React.useCallback((id, onlyIfUntouched = false) => {
378
+ const scroller = bodyRef.current;
379
+ const heading = scroller?.querySelector(`#${CSS.escape(id)}`);
380
+ if (!scroller || !heading) return false;
381
+ if (onlyIfUntouched && scrolledTo.current !== scroller.scrollTop) return false;
382
+ scroller.scrollTop += heading.getBoundingClientRect().top - scroller.getBoundingClientRect().top;
383
+ scrolledTo.current = scroller.scrollTop;
384
+ setActiveId(id);
385
+ return true;
386
+ }, []);
387
+ const target = React.useMemo(() => resolveHelpTarget(doc, context), [doc, context]);
388
+ React.useEffect(() => {
389
+ if (!open || !target || needle) return;
390
+ let cancelled = false;
391
+ scrollTo(target);
392
+ const again = () => {
393
+ if (!cancelled) scrollTo(target, scrolledTo.current !== void 0);
394
+ };
395
+ const frame = requestAnimationFrame(again);
396
+ document.fonts?.ready.then(again);
397
+ return () => {
398
+ cancelled = true;
399
+ cancelAnimationFrame(frame);
400
+ };
401
+ }, [
402
+ open,
403
+ target,
404
+ needle,
405
+ scrollTo
406
+ ]);
407
+ const safeDocumentUrl = httpDocumentUrl(documentUrl);
408
+ const [fallbackUrl, setFallbackUrl] = React.useState();
409
+ React.useEffect(() => {
410
+ if (safeDocumentUrl || text === void 0) return;
411
+ const url = URL.createObjectURL(new Blob([text], { type: "text/markdown;charset=utf-8" }));
412
+ setFallbackUrl(url);
413
+ return () => URL.revokeObjectURL(url);
414
+ }, [safeDocumentUrl, text]);
415
+ const href = safeDocumentUrl ?? fallbackUrl;
416
+ if (!open) return null;
417
+ const heading = title ?? doc.title ?? "Help";
418
+ const loading = text === void 0 && !failed;
419
+ return /* @__PURE__ */ jsxs("div", {
420
+ ref: panelRef,
421
+ "data-slot": "help-panel",
422
+ "data-loading": loading || void 0,
423
+ className: cn("flex h-full min-h-0 w-full flex-col overflow-hidden", "bg-[var(--ds-card)] text-[color:var(--ds-foreground)]", className),
424
+ ...props,
425
+ children: [
426
+ /* @__PURE__ */ jsxs("header", {
427
+ "data-slot": "help-panel-header",
428
+ className: "flex shrink-0 items-center gap-2 border-b border-[var(--ds-border-subtle)] px-4 py-3",
429
+ children: [
430
+ /* @__PURE__ */ jsx("h2", {
431
+ "data-slot": "help-panel-title",
432
+ className: cn("min-w-0 flex-1 truncate font-display font-bold", "text-[length:var(--ds-text-body-sm)] leading-[var(--ds-text-body-sm--line-height)]", "text-[color:var(--ds-foreground)]"),
433
+ children: heading
434
+ }),
435
+ href ? /* @__PURE__ */ jsx("a", {
436
+ "data-slot": "help-panel-open-full",
437
+ href,
438
+ target: "_blank",
439
+ rel: "noreferrer",
440
+ "aria-label": "Open the full document in a new tab",
441
+ className: headerButton,
442
+ children: /* @__PURE__ */ jsx(ExternalLink, { "aria-hidden": true })
443
+ }) : null,
444
+ onClose ? /* @__PURE__ */ jsx("button", {
445
+ type: "button",
446
+ "data-slot": "help-panel-close",
447
+ "aria-label": "Close help",
448
+ onClick: onClose,
449
+ className: headerButton,
450
+ children: /* @__PURE__ */ jsx(X, { "aria-hidden": true })
451
+ }) : null
452
+ ]
453
+ }),
454
+ /* @__PURE__ */ jsx("div", {
455
+ "data-slot": "help-panel-search",
456
+ className: "shrink-0 border-b border-[var(--ds-border-subtle)] px-4 py-3",
457
+ children: /* @__PURE__ */ jsxs("div", {
458
+ className: "relative",
459
+ children: [/* @__PURE__ */ jsx(Search, {
460
+ "aria-hidden": true,
461
+ className: "pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-[color:var(--ds-text-tertiary)]"
462
+ }), /* @__PURE__ */ jsx("input", {
463
+ type: "search",
464
+ "data-slot": "help-panel-search-input",
465
+ "aria-label": `Search ${heading}`,
466
+ placeholder: "Search this document",
467
+ value: query,
468
+ onChange: (event) => setQuery(event.target.value),
469
+ className: cn("h-9 w-full rounded-[var(--ds-radius-md)] border border-[var(--ds-border)]", "bg-[var(--ds-muted)] py-2 pr-3 pl-8", "text-[length:var(--ds-text-caption-lg)] leading-[var(--ds-text-caption-lg--line-height)]", "text-[color:var(--ds-foreground)] placeholder:text-[color:var(--ds-text-tertiary)]", "transition-[border-color,box-shadow] duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]", "outline-none hover:border-[var(--ds-border-strong)]", "focus-visible:border-[var(--ds-ring)] focus-visible:ring-[3px] focus-visible:ring-[var(--ds-ring)]/40", "[&::-webkit-search-cancel-button]:hidden")
470
+ })]
471
+ })
472
+ }),
473
+ outline.length > 0 ? /* @__PURE__ */ jsx("nav", {
474
+ "data-slot": "help-panel-outline",
475
+ "aria-label": `${heading} sections`,
476
+ className: "max-h-40 shrink-0 overflow-y-auto border-b border-[var(--ds-border-subtle)] px-2 py-2",
477
+ children: /* @__PURE__ */ jsx("ol", {
478
+ className: "flex flex-col",
479
+ children: outline.map((section) => /* @__PURE__ */ jsx("li", { children: /* @__PURE__ */ jsx("a", {
480
+ "data-slot": "help-panel-outline-item",
481
+ "data-depth": section.depth,
482
+ "data-active": section.id === activeId || void 0,
483
+ href: `#${section.id}`,
484
+ onClick: (event) => {
485
+ event.preventDefault();
486
+ scrollTo(section.id);
487
+ },
488
+ className: cn("block truncate rounded-[var(--ds-radius-sm)] px-2 py-1 no-underline", "text-[length:var(--ds-text-caption)] leading-[var(--ds-text-caption--line-height)]", "text-[color:var(--ds-muted-foreground)] outline-none", "transition-colors duration-[var(--ds-duration-fast)] ease-[var(--ds-ease-out)]", "hover:bg-[var(--ds-hover)] hover:text-[color:var(--ds-foreground)]", "focus-visible:ring-2 focus-visible:ring-[var(--ds-ring)]", "data-[active]:bg-[var(--ds-primary-soft)] data-[active]:text-[color:var(--ds-primary-soft-foreground)]", section.depth === 2 && "pl-5", section.depth >= 3 && "pl-8"),
489
+ children: section.title
490
+ }) }, section.id))
491
+ })
492
+ }) : null,
493
+ /* @__PURE__ */ jsx("div", {
494
+ ref: bodyRef,
495
+ "data-slot": "help-panel-body",
496
+ className: "min-h-0 flex-1 overflow-y-auto px-4 py-3",
497
+ children: loading ? /* @__PURE__ */ jsxs("div", {
498
+ "data-slot": "help-panel-loading",
499
+ className: "flex items-center gap-2 py-6",
500
+ children: [/* @__PURE__ */ jsx(Spinner, {}), /* @__PURE__ */ jsx("span", {
501
+ className: "text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]",
502
+ children: "Loading help…"
503
+ })]
504
+ }) : failed ? /* @__PURE__ */ jsx("p", {
505
+ "data-slot": "help-panel-error",
506
+ className: "py-6 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-destructive)]",
507
+ children: "The help document could not be loaded."
508
+ }) : needle && matches.length === 0 ? /* @__PURE__ */ jsxs("p", {
509
+ "data-slot": "help-panel-empty",
510
+ className: "py-6 text-[length:var(--ds-text-caption-lg)] text-[color:var(--ds-muted-foreground)]",
511
+ children: [
512
+ "No section matches “",
513
+ query.trim(),
514
+ "”."
515
+ ]
516
+ }) : /* @__PURE__ */ jsx(Markdown, {
517
+ compact: true,
518
+ sanitizeSchema: markdownSanitizeSchema,
519
+ rehypePlugins: [rehypeHelpAnchors],
520
+ children: markdown
521
+ })
522
+ })
523
+ ]
524
+ });
525
+ }
526
+ /**
527
+ * `AppShell`'s `helpPanel` slot, filled.
528
+ *
529
+ * The shell renders whatever the function returns, open or closed, so this
530
+ * returns `null` while closed — a panel that rendered an empty box would draw
531
+ * the shell's bordered aside over the corner with nothing in it. `help.source`
532
+ * from the shell is used when no `source` is passed here.
533
+ */
534
+ function helpPanelSlot(config) {
535
+ return ({ open, source, onClose }) => open ? /* @__PURE__ */ jsx(HelpPanel, {
536
+ ...config,
537
+ source: config.source ?? source ?? "",
538
+ open: true,
539
+ onClose
540
+ }) : null;
541
+ }
542
+ //#endregion
543
+ export { HelpHotkeys, HelpPanel, helpPanelSlot, httpDocumentUrl, parseHelpDocument, resolveHelpTarget, useHelpHotkeys };
544
+
545
+ //# sourceMappingURL=help-panel.js.map