@k2b/cloud 0.40.0 → 0.41.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/_internal/capability-sentences.ts +2 -1
  3. package/src/_internal/define-app.ts +13 -3
  4. package/src/_internal/heartbeat.ts +203 -14
  5. package/src/_internal/registry.ts +28 -1
  6. package/src/_internal/setup-lock.ts +103 -0
  7. package/src/ai/chat/action-card.ts +69 -3
  8. package/src/ai/chat/action-question.tsx +12 -1
  9. package/src/ai/chat/action-text.ts +288 -0
  10. package/src/ai/chat/tool-action-words.ts +31 -0
  11. package/src/ai/chat/tool-action.tsx +1 -26
  12. package/src/ai/executor.ts +10 -2
  13. package/src/ai/firecrawl-tools.ts +15 -2
  14. package/src/ai/index.ts +1 -0
  15. package/src/ai/migrate.ts +15 -0
  16. package/src/ai/open-questions.ts +172 -0
  17. package/src/ai/question-text.ts +8 -0
  18. package/src/ai/routes.ts +51 -5
  19. package/src/ai/store.ts +18 -0
  20. package/src/ai/testing.ts +36 -0
  21. package/src/ai/tool-sentences.ts +152 -34
  22. package/src/ai/tool-wording.ts +12 -6
  23. package/src/ai/tools.ts +8 -3
  24. package/src/ai/types.ts +16 -2
  25. package/src/api/app-approval-actions.ts +31 -7
  26. package/src/api/auth/schemas.ts +3 -0
  27. package/src/api/auth.ts +54 -18
  28. package/src/api/mcp.ts +27 -3
  29. package/src/api/me.ts +79 -3
  30. package/src/approvals/Approvals.tsx +25 -63
  31. package/src/approvals/approval-card.ts +68 -0
  32. package/src/contracts/app-approval.ts +3 -3
  33. package/src/contracts/capabilities.ts +1 -1
  34. package/src/contracts/shared.ts +3 -0
  35. package/src/server/middleware/auth.ts +4 -2
  36. package/src/services/accounts/app.ts +23 -3
  37. package/src/services/accounts/users.ts +12 -1
  38. package/src/services/approvals/device.ts +49 -33
  39. package/src/services/approvals/index.ts +1 -0
  40. package/src/services/approvals/list.ts +1 -1
  41. package/src/services/approvals/question.ts +68 -0
  42. package/src/services/audit/index.ts +1 -0
  43. package/src/services/auth-flows/ipa.ts +39 -2
  44. package/src/services/auth-flows/password-reset.ts +32 -6
  45. package/src/services/index.ts +2 -0
  46. package/src/services/pdf/markdown.ts +160 -2
  47. package/src/services/session/index.ts +203 -29
  48. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  49. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  50. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  51. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  52. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  53. package/src/ssr/HelpPanelView.island.tsx +27 -0
  54. package/src/ssr/LayoutHelp.tsx +182 -540
  55. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  56. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  57. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  58. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  59. package/src/ssr/help.ts +4 -3
  60. package/src/ssr/index.ts +1 -0
  61. package/src/ssr/islands/index.ts +3 -4
  62. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  63. package/src/ssr/layout-help-panel-document.ts +67 -0
  64. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  65. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  66. package/src/ssr/layout-help-panel-modules.ts +7 -0
  67. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  68. package/src/ssr/layout-help-panel-search.tsx +69 -0
  69. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  70. package/src/ssr/layout-help-panel-state.ts +141 -0
  71. package/src/ssr/layout-help-panel-types.ts +19 -0
  72. package/src/ssr/layout-help-views.tsx +484 -0
  73. package/src/ssr/panel-idle.ts +9 -0
  74. package/src/styles/approvals.css +6 -0
  75. package/src/styles/effects.css +15 -0
@@ -0,0 +1,308 @@
1
+ import { Button, NoticeCard, Placeholder, prefersReducedMotion, useLocale } from "@k2b/ui";
2
+ import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js";
3
+ import type { HelpDocumentPayload } from "../shared/help";
4
+ import { helpMessages } from "./help-messages";
5
+ import { panelElement } from "./layout-help-panel-lifecycle";
6
+ import { helpPanelModules } from "./layout-help-panel-modules";
7
+ import { type HelpPanelState, helpPanelState } from "./layout-help-panel-state";
8
+ import type { HelpTopic } from "./layout-help-registry";
9
+ import { helpLinkTarget } from "./layout-help-url";
10
+ import { HelpArticleView } from "./layout-help-views";
11
+ import { panelIdle } from "./panel-idle";
12
+
13
+ export default function HelpPanelArticle(props: { topic?: HelpTopic }) {
14
+ const locale = useLocale();
15
+ const t = () => helpMessages.resolve([locale()]).t;
16
+ const [state, setState] = createSignal<HelpPanelState>();
17
+ const [reader, setReader] = createSignal<typeof import("./layout-help-panel-reader")>();
18
+ const [payload, setPayload] = createSignal<HelpDocumentPayload | null>(null);
19
+ const [error, setError] = createSignal<string | null>(null);
20
+ const [reloadRequired, setReloadRequired] = createSignal(false);
21
+ const [attempt, setAttempt] = createSignal(0);
22
+ let pending: Promise<typeof import("./layout-help-panel-reader")> | undefined;
23
+ let disposed = false;
24
+ let sectionPressed = false;
25
+ let queuedSection: { trigger: HTMLButtonElement; key: string } | undefined;
26
+ const sections = new Map<Element, () => void>();
27
+ const load = () =>
28
+ (pending ??= helpPanelModules
29
+ .reader()
30
+ .then((module) => {
31
+ if (!disposed) setReader(module);
32
+ return module;
33
+ })
34
+ .catch((error: unknown) => {
35
+ pending = undefined;
36
+ throw error;
37
+ }));
38
+ const warm = () => {
39
+ void load().catch(() => {});
40
+ };
41
+ const topic = () => (state() ? state()?.data.documents.find((topic) => topic.id === state()?.activeId()) : props.topic);
42
+ const clientVisible = () =>
43
+ state() ? state()?.view() === "article" && state()?.activeId() !== state()?.data.article?.id : !!props.topic;
44
+ const scrollToHash = (hash: string) => {
45
+ const current = state();
46
+ const heading = Array.from(
47
+ current?.root.querySelectorAll<HTMLElement>(
48
+ "article:not([hidden]) h1[id], article:not([hidden]) h2[id], article:not([hidden]) h3[id], article:not([hidden]) h4[id], article:not([hidden]) h5[id], article:not([hidden]) h6[id]",
49
+ ) ?? [],
50
+ ).find((element) => element.id === hash);
51
+ if (!heading) return false;
52
+ for (let details = heading.closest("details"); details; details = details.parentElement?.closest("details") ?? null)
53
+ details.open = true;
54
+ heading.scrollIntoView({ block: "start", behavior: prefersReducedMotion() ? "auto" : "smooth" });
55
+ return true;
56
+ };
57
+ onMount(() => {
58
+ const current = helpPanelState(panelElement());
59
+ setState(current);
60
+ const upgrade = (trigger: HTMLButtonElement, key: string) => {
61
+ // Safari keeps focus where it was when a button is clicked; only a focus move after the activation cancels it.
62
+ const start = document.activeElement;
63
+ const article = current.activeId();
64
+ void load()
65
+ .then((module) => {
66
+ // Back or another article while the chunk loads cancels the activation: the hidden article opens no menu.
67
+ if (disposed || !trigger.isConnected || current.view() !== "article" || current.activeId() !== article) return;
68
+ if (trigger.closest("[hidden]")) return;
69
+ // A pending chunk must not replace the trigger between pointerdown and pointerup.
70
+ if (sectionPressed) {
71
+ queuedSection = { trigger, key };
72
+ return;
73
+ }
74
+ const container = trigger.parentElement;
75
+ if (!container || sections.has(container)) return;
76
+ const focused = document.activeElement === trigger;
77
+ if (!focused && document.activeElement !== start && document.activeElement !== document.body) return;
78
+ setError(null);
79
+ sections.set(
80
+ container,
81
+ module.mountPanelSections(container, { locale: locale(), state: current, key, focused, onSection: scrollToHash }),
82
+ );
83
+ })
84
+ .catch((reason: unknown) => {
85
+ if (!disposed) {
86
+ setReloadRequired(true);
87
+ setError(reason instanceof Error ? reason.message : t().loadFallback);
88
+ trigger.title = t().loadFailed + ": " + t().reload;
89
+ }
90
+ });
91
+ };
92
+ const click = (event: MouseEvent) => {
93
+ if (
94
+ event.defaultPrevented ||
95
+ event.button !== 0 ||
96
+ event.metaKey ||
97
+ event.ctrlKey ||
98
+ event.shiftKey ||
99
+ event.altKey ||
100
+ !(event.target instanceof Element)
101
+ )
102
+ return;
103
+ const sectionTrigger = event.target.closest<HTMLButtonElement>("[data-help-sections]");
104
+ if (sectionTrigger) {
105
+ event.preventDefault();
106
+ event.stopPropagation();
107
+ upgrade(sectionTrigger, "ArrowDown");
108
+ return;
109
+ }
110
+ const button = event.target.closest<HTMLElement>("[data-help-topic]");
111
+ if (button) {
112
+ event.preventDefault();
113
+ current.open(button.dataset.helpTopic ?? "");
114
+ return;
115
+ }
116
+ if (event.target.closest("[data-help-unavailable] button")) {
117
+ location.reload();
118
+ return;
119
+ }
120
+ const anchor = event.target.closest<HTMLAnchorElement>(".help-article a[href]");
121
+ if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) return;
122
+ const target = helpLinkTarget(anchor.getAttribute("href") ?? "", location.href, current.data);
123
+ if (!target) return;
124
+ if (target.kind === "section") {
125
+ if (scrollToHash(target.hash)) event.preventDefault();
126
+ } else if (target.topicId === null) {
127
+ event.preventDefault();
128
+ current.changeQuery("");
129
+ current.back();
130
+ const input = current.root.querySelector<HTMLInputElement>("[data-help-search]");
131
+ if (input) input.value = "";
132
+ } else if (current.data.documents.some((topic) => topic.id === target.topicId)) {
133
+ event.preventDefault();
134
+ current.open(target.topicId, target.hash);
135
+ }
136
+ };
137
+ const endPress = () => {
138
+ sectionPressed = false;
139
+ const next = queuedSection;
140
+ queuedSection = undefined;
141
+ if (next) upgrade(next.trigger, next.key);
142
+ };
143
+ const cancelPress = () => {
144
+ sectionPressed = false;
145
+ queuedSection = undefined;
146
+ };
147
+ const intent = (event: Event) => {
148
+ if (event.type === "pointerdown" && event.target instanceof Element && event.target.closest("[data-help-sections]"))
149
+ sectionPressed = true;
150
+ if (event.target instanceof Element && event.target.closest("[data-help-topic], .help-article a[href], [data-help-sections]")) warm();
151
+ };
152
+ const key = (event: KeyboardEvent) => {
153
+ intent(event);
154
+ const trigger = event.target instanceof Element ? event.target.closest<HTMLButtonElement>("[data-help-sections]") : null;
155
+ if (event.defaultPrevented || !trigger || !["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) return;
156
+ event.preventDefault();
157
+ event.stopPropagation();
158
+ upgrade(trigger, event.key);
159
+ };
160
+ window.addEventListener("pointerup", endPress);
161
+ window.addEventListener("pointercancel", cancelPress);
162
+ current.root.addEventListener("click", click);
163
+ current.root.addEventListener("keydown", key);
164
+ const intents = ["pointerover", "pointerdown", "focusin"] as const;
165
+ for (const event of intents) current.root.addEventListener(event, intent);
166
+ onCleanup(panelIdle(warm));
167
+ onCleanup(() => {
168
+ window.removeEventListener("pointerup", endPress);
169
+ window.removeEventListener("pointercancel", cancelPress);
170
+ current.root.removeEventListener("click", click);
171
+ current.root.removeEventListener("keydown", key);
172
+ for (const event of intents) current.root.removeEventListener(event, intent);
173
+ for (const dispose of sections.values()) dispose();
174
+ });
175
+ });
176
+ onCleanup(() => {
177
+ disposed = true;
178
+ });
179
+ createEffect(() => {
180
+ const current = state();
181
+ const active = topic();
182
+ attempt();
183
+ setPayload(null);
184
+ setError(null);
185
+ setReloadRequired(false);
186
+ if (!current || !active || !clientVisible()) return;
187
+ const controller = new AbortController();
188
+ let cancelled = false;
189
+ const chunkFailed = (reason: unknown): never => {
190
+ if (!cancelled) setReloadRequired(true);
191
+ throw reason;
192
+ };
193
+ void Promise.all([
194
+ load().catch(chunkFailed),
195
+ helpPanelModules
196
+ .document()
197
+ .catch(chunkFailed)
198
+ .then((module) => module.loadPanelDocument(current, active, controller.signal, t())),
199
+ ])
200
+ .then(([, document]) => {
201
+ if (!cancelled) setPayload(document);
202
+ })
203
+ .catch((reason: unknown) => {
204
+ if (!cancelled && !(reason instanceof DOMException && reason.name === "AbortError"))
205
+ setError(reason instanceof Error ? reason.message : t().loadFallback);
206
+ });
207
+ onCleanup(() => {
208
+ cancelled = true;
209
+ controller.abort();
210
+ });
211
+ });
212
+ /** The navigation whose heading focus and section jump are done; returning from search must not replay them. */
213
+ let settled = 0;
214
+ createEffect(() => {
215
+ const current = state();
216
+ const navigation = current?.navigation();
217
+ // The loaded reader replaces the loading frame and its heading; follow it unless the reader moved focus elsewhere.
218
+ const body = current?.activeId() === current?.data.article?.id || (!!payload() && !!reader());
219
+ if (!current || !navigation?.version || navigation.version === settled || current.view() !== "article") return;
220
+ const frame = requestAnimationFrame(() => {
221
+ const focused = document.activeElement;
222
+ if (!focused || focused === document.body || focused.matches(".help-article-title") || focused.closest("[hidden]"))
223
+ current.focusHeading();
224
+ if (!body) return;
225
+ settled = navigation.version;
226
+ if (navigation.hash) scrollToHash(navigation.hash);
227
+ });
228
+ onCleanup(() => cancelAnimationFrame(frame));
229
+ });
230
+ createEffect(() => {
231
+ const current = state();
232
+ const live = current?.root.querySelector("[data-help-announcement]");
233
+ if (live) live.textContent = current?.announcement() ?? "";
234
+ });
235
+ return (
236
+ <>
237
+ <Show when={clientVisible() && topic()}>
238
+ {(active) => (
239
+ <article class="help-article" data-surface="panel">
240
+ <Show
241
+ when={payload() ? reader() : undefined}
242
+ fallback={
243
+ <HelpArticleView
244
+ surface="panel"
245
+ topic={active()}
246
+ parts={null}
247
+ document={null}
248
+ Markdown={() => null}
249
+ showTools={false}
250
+ sectionMenu={null}
251
+ copyButton={null}
252
+ loading={
253
+ <Show when={!error()}>
254
+ <div class="min-h-[24rem]">
255
+ <Placeholder state="loading" variant="panel" title={t().loading} />
256
+ </div>
257
+ </Show>
258
+ }
259
+ failure={
260
+ <Show when={error()}>
261
+ {(message) => (
262
+ <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
263
+ <Button
264
+ size="sm"
265
+ variant="secondary"
266
+ onClick={() => (reloadRequired() ? location.reload() : setAttempt((value) => value + 1))}
267
+ >
268
+ {reloadRequired() ? t().reload : t().tryAgain}
269
+ </Button>
270
+ </NoticeCard>
271
+ )}
272
+ </Show>
273
+ }
274
+ toc={null}
275
+ copyAction={null}
276
+ headingRef={() => {}}
277
+ contentRef={() => {}}
278
+ onBack={() => state()?.back()}
279
+ />
280
+ }
281
+ >
282
+ {(module) => {
283
+ const Reader = module().HelpPanelReader;
284
+ return (
285
+ <Show when={payload()}>
286
+ {(document) => {
287
+ const current = state();
288
+ return current && <Reader state={current} topic={active()} document={document()} onSection={scrollToHash} />;
289
+ }}
290
+ </Show>
291
+ );
292
+ }}
293
+ </Show>
294
+ </article>
295
+ )}
296
+ </Show>
297
+ <Show when={state()?.activeId() === state()?.data.article?.id && error()}>
298
+ {(message) => (
299
+ <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
300
+ <Button size="sm" variant="secondary" onClick={() => location.reload()}>
301
+ {t().reload}
302
+ </Button>
303
+ </NoticeCard>
304
+ )}
305
+ </Show>
306
+ </>
307
+ );
308
+ }
@@ -0,0 +1,32 @@
1
+ import { IconButton, useLocale } from "@k2b/ui";
2
+ import { createSignal, onMount, Show } from "solid-js";
3
+ import { helpMessages } from "./help-messages";
4
+ import { capturePanelFocus, panelElement, restorePanelFocus } from "./layout-help-panel-lifecycle";
5
+ import { type HelpPanelState, helpPanelState } from "./layout-help-panel-state";
6
+
7
+ capturePanelFocus();
8
+ export default function HelpPanelBack(props: { back: boolean; tile: string }) {
9
+ const locale = useLocale();
10
+ const t = () => helpMessages.resolve([locale()]).t;
11
+ const [state, setState] = createSignal<HelpPanelState>();
12
+ onMount(() => {
13
+ const wrapper = panelElement();
14
+ setState(helpPanelState(wrapper));
15
+ const button = wrapper.querySelector(".help-head-back");
16
+ if (button) restorePanelFocus(button);
17
+ });
18
+ return (
19
+ <Show
20
+ when={state()?.view() === "article" || (!state() && props.back)}
21
+ fallback={
22
+ <span class="help-head-tile" aria-hidden="true">
23
+ <i class={props.tile} />
24
+ </span>
25
+ }
26
+ >
27
+ <IconButton class="help-head-back" label={t().backToTopics} title={t().back} onClick={() => state()?.back()}>
28
+ <i class="ti ti-arrow-left" />
29
+ </IconButton>
30
+ </Show>
31
+ );
32
+ }
@@ -0,0 +1,133 @@
1
+ import { Button, NoticeCard, useLocale } from "@k2b/ui";
2
+ import { createSignal, onCleanup, onMount, Show } from "solid-js";
3
+ import { helpMessages } from "./help-messages";
4
+ import { HelpPanelMenuTrigger } from "./layout-help-panel-controls";
5
+ import { capturePanelFocus, panelElement, restorePanelFocus } from "./layout-help-panel-lifecycle";
6
+ import type { MenuActivation } from "./layout-help-panel-menu";
7
+ import { helpPanelModules } from "./layout-help-panel-modules";
8
+ import { type HelpPanelState, helpPanelState } from "./layout-help-panel-state";
9
+ import { panelIdle } from "./panel-idle";
10
+
11
+ capturePanelFocus();
12
+ export default function HelpPanelMenu() {
13
+ const locale = useLocale();
14
+ const t = () => helpMessages.resolve([locale()]).t;
15
+ const [menu, setMenu] = createSignal<typeof import("./layout-help-panel-menu")>();
16
+ const [error, setError] = createSignal(false);
17
+ let state: HelpPanelState | undefined;
18
+ let loaded: typeof import("./layout-help-panel-menu") | undefined;
19
+ let pending: Promise<typeof import("./layout-help-panel-menu")> | undefined;
20
+ let activation: MenuActivation = { key: "ArrowDown", focus: false };
21
+ let disposed = false;
22
+ let pressed = false;
23
+ let queued: { key: string; trigger: HTMLButtonElement; pointer: boolean } | undefined;
24
+ const load = () =>
25
+ (pending ??= helpPanelModules
26
+ .menu()
27
+ .then((module) => {
28
+ loaded = module;
29
+ return module;
30
+ })
31
+ .catch((error: unknown) => {
32
+ pending = undefined;
33
+ throw error;
34
+ }));
35
+ const warm = () => {
36
+ void load().catch(() => {});
37
+ };
38
+ const activate = (key: string, trigger: HTMLButtonElement, pointer = false) => {
39
+ if (pressed) {
40
+ queued = { key, trigger, pointer };
41
+ return;
42
+ }
43
+ // Safari keeps focus where it was when a button is clicked; only a focus move after the activation cancels it.
44
+ const start = document.activeElement;
45
+ const swap = (module: typeof import("./layout-help-panel-menu")) => {
46
+ const focused = document.activeElement;
47
+ if (focused !== start && focused !== trigger && focused !== document.body) return;
48
+ activation = { key, focus: focused === trigger, pointer };
49
+ setMenu(module);
50
+ };
51
+ setError(false);
52
+ if (loaded) {
53
+ swap(loaded);
54
+ return;
55
+ }
56
+ void load()
57
+ .then((module) => {
58
+ if (disposed) return;
59
+ if (pressed) {
60
+ queued = { key, trigger, pointer };
61
+ return;
62
+ }
63
+ swap(module);
64
+ })
65
+ .catch(() => {
66
+ if (!disposed) setError(true);
67
+ });
68
+ };
69
+ const endPress = () => {
70
+ pressed = false;
71
+ const next = queued;
72
+ queued = undefined;
73
+ if (next) activate(next.key, next.trigger, next.pointer);
74
+ };
75
+ const cancelPress = () => {
76
+ pressed = false;
77
+ queued = undefined;
78
+ };
79
+ onMount(() => {
80
+ const wrapper = panelElement();
81
+ state = helpPanelState(wrapper);
82
+ const button = wrapper.querySelector("[data-help-menu]");
83
+ if (button) restorePanelFocus(button);
84
+ onCleanup(panelIdle(warm));
85
+ window.addEventListener("pointerup", endPress);
86
+ window.addEventListener("pointercancel", cancelPress);
87
+ onCleanup(() => {
88
+ window.removeEventListener("pointerup", endPress);
89
+ window.removeEventListener("pointercancel", cancelPress);
90
+ });
91
+ });
92
+ onCleanup(() => {
93
+ disposed = true;
94
+ });
95
+ return (
96
+ <Show
97
+ when={menu()}
98
+ fallback={
99
+ <>
100
+ <HelpPanelMenuTrigger
101
+ label={t().moreActions}
102
+ onPointerEnter={warm}
103
+ onPointerDown={() => {
104
+ pressed = true;
105
+ warm();
106
+ }}
107
+ onFocus={warm}
108
+ onClick={(event) => activate("ArrowDown", event.currentTarget, event.detail > 0)}
109
+ onKeyDown={(event) => {
110
+ warm();
111
+ if (!["Enter", " ", "ArrowDown", "ArrowUp"].includes(event.key)) return;
112
+ event.preventDefault();
113
+ event.stopPropagation();
114
+ activate(event.key, event.currentTarget);
115
+ }}
116
+ />
117
+ <Show when={error()}>
118
+ <NoticeCard class="absolute z-50" tone="danger" title={t().helpUnavailable} detail={t().helpUnavailableDetail}>
119
+ <Button size="sm" variant="secondary" onClick={() => location.reload()}>
120
+ {t().reload}
121
+ </Button>
122
+ </NoticeCard>
123
+ </Show>
124
+ </>
125
+ }
126
+ >
127
+ {(module) => {
128
+ const Menu = module().HelpPanelMenu;
129
+ return state && <Menu state={state} activation={activation} />;
130
+ }}
131
+ </Show>
132
+ );
133
+ }
@@ -0,0 +1,123 @@
1
+ import { Button, NoticeCard, useLocale } from "@k2b/ui";
2
+ import { createEffect, createSignal, onCleanup, onMount, Show } from "solid-js";
3
+ import { helpMessages } from "./help-messages";
4
+ import { panelElement } from "./layout-help-panel-lifecycle";
5
+ import { helpPanelModules } from "./layout-help-panel-modules";
6
+ import { type HelpPanelState, helpPanelState } from "./layout-help-panel-state";
7
+ import { panelIdle } from "./panel-idle";
8
+
9
+ export default function HelpPanelSearch() {
10
+ const locale = useLocale();
11
+ const t = () => helpMessages.resolve([locale()]).t;
12
+ const [state, setState] = createSignal<HelpPanelState>();
13
+ const [engine, setEngine] = createSignal<typeof import("./layout-help-panel-search")>();
14
+ const [failed, setFailed] = createSignal(false);
15
+ let input: HTMLInputElement | null = null;
16
+ let pending: Promise<void> | undefined;
17
+ let disposed = false;
18
+ let lastValue = "";
19
+ const reserve = () => {
20
+ if (!input) return;
21
+ const region = state()?.results();
22
+ if (input.value.trim() && !lastValue.trim() && region) region.style.minHeight = `${region.offsetHeight}px`;
23
+ if (!input.value.trim() && region) region.style.minHeight = "";
24
+ lastValue = input.value;
25
+ };
26
+ const adopt = () => {
27
+ reserve();
28
+ // An empty field shows the start again, also after the search code failed to load.
29
+ if (input && !input.value.trim()) setFailed(false);
30
+ if (input && (engine() || failed() || !input.value.trim())) state()?.changeQuery(input.value);
31
+ };
32
+ const load = () =>
33
+ (pending ??= helpPanelModules
34
+ .search()
35
+ .then((module) => {
36
+ if (disposed) return;
37
+ setFailed(false);
38
+ setEngine(module);
39
+ adopt();
40
+ })
41
+ .catch(() => {
42
+ pending = undefined;
43
+ if (!disposed && input?.value.trim()) {
44
+ state()?.changeQuery(input.value);
45
+ setFailed(true);
46
+ }
47
+ }));
48
+ const intent = () => {
49
+ void load();
50
+ };
51
+ onMount(() => {
52
+ const current = helpPanelState(panelElement());
53
+ setState(current);
54
+ input = current.root.querySelector<HTMLInputElement>("[data-help-search]");
55
+ if (!input) return;
56
+ const typed = () => {
57
+ reserve();
58
+ intent();
59
+ adopt();
60
+ };
61
+ // TextInput publishes every input event, including IME input; keep that existing behavior.
62
+ input.addEventListener("input", typed);
63
+ input.addEventListener("compositionend", typed);
64
+ const intents = ["focus", "pointerdown", "keydown", "compositionstart"] as const;
65
+ for (const event of intents) input.addEventListener(event, intent);
66
+ const key = (event: KeyboardEvent) => {
67
+ if (event.defaultPrevented || event.isComposing) return;
68
+ if (event.key === "Escape" && event.target === input && input?.value) {
69
+ event.preventDefault();
70
+ input.value = "";
71
+ adopt();
72
+ setFailed(false);
73
+ return;
74
+ }
75
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
76
+ if (
77
+ event.key === "/" &&
78
+ !(
79
+ event.target instanceof Element &&
80
+ event.target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])')
81
+ )
82
+ ) {
83
+ event.preventDefault();
84
+ input?.focus();
85
+ }
86
+ };
87
+ document.addEventListener("keydown", key);
88
+ // A user can type before even the eager island arrives. The field itself has never been replaced.
89
+ if (input.value) typed();
90
+ onCleanup(panelIdle(intent));
91
+ onCleanup(() => {
92
+ input?.removeEventListener("input", typed);
93
+ input?.removeEventListener("compositionend", typed);
94
+ for (const event of intents) input?.removeEventListener(event, intent);
95
+ document.removeEventListener("keydown", key);
96
+ });
97
+ });
98
+ onCleanup(() => {
99
+ disposed = true;
100
+ });
101
+ createEffect(() => {
102
+ const current = state();
103
+ const content = current?.root.querySelector<HTMLElement>("[data-help-start-content]");
104
+ if (content) content.hidden = !!current?.query().trim() || failed();
105
+ });
106
+ return (
107
+ <>
108
+ <Show when={engine()}>
109
+ {(module) => {
110
+ const Results = module().HelpPanelSearchResults;
111
+ return <Show when={state()}>{(current) => <Results state={current()} />}</Show>;
112
+ }}
113
+ </Show>
114
+ <Show when={failed()}>
115
+ <NoticeCard tone="danger" title={t().helpUnavailable} detail={t().helpUnavailableDetail}>
116
+ <Button size="sm" variant="secondary" onClick={() => location.reload()}>
117
+ {t().reload}
118
+ </Button>
119
+ </NoticeCard>
120
+ </Show>
121
+ </>
122
+ );
123
+ }
@@ -0,0 +1,22 @@
1
+ import { type KeyboardPlatform, useLocale } from "@k2b/ui";
2
+ import { createSignal, onCleanup, onMount } from "solid-js";
3
+ import { onContextChange } from "../browser/panel";
4
+ import { onPanelShortcutsChange } from "../browser/panel-shortcuts";
5
+ import { readHelpPanelShortcuts } from "./layout-help-panel-shortcuts";
6
+ import { type HelpShortcutCommand, helpShortcutGroups } from "./layout-help-start";
7
+ import { HelpShortcutsView } from "./layout-help-views";
8
+ import { platformMessages } from "./platform-messages";
9
+ import { shellShortcuts } from "./shell-shortcuts";
10
+
11
+ export default function HelpPanelShortcuts(props: { appName?: string; platform: KeyboardPlatform }) {
12
+ const locale = useLocale();
13
+ const seed = shellShortcuts(platformMessages.resolve([locale()]).t, { panel: true });
14
+ const [commands, setCommands] = createSignal<HelpShortcutCommand[]>(seed);
15
+ onMount(() => {
16
+ const update = () => setCommands(readHelpPanelShortcuts(seed));
17
+ onCleanup(onPanelShortcutsChange(update));
18
+ onCleanup(onContextChange(update));
19
+ update();
20
+ });
21
+ return <HelpShortcutsView groups={helpShortcutGroups(commands())} appName={props.appName} platform={props.platform} />;
22
+ }
@@ -0,0 +1,27 @@
1
+ import { createSignal, onCleanup, onMount } from "solid-js";
2
+ import { getContext, onContextChange } from "../browser/panel";
3
+ import { capturePanelFocus, panelElement, restorePanelFocus } from "./layout-help-panel-lifecycle";
4
+ import { helpPanelState } from "./layout-help-panel-state";
5
+ import type { HelpTopic } from "./layout-help-registry";
6
+ import { helpStart } from "./layout-help-start";
7
+ import { HelpViewSection } from "./layout-help-views";
8
+
9
+ capturePanelFocus();
10
+ export default function HelpPanelView(props: { topics: readonly HelpTopic[] }) {
11
+ const [topics, setTopics] = createSignal(props.topics);
12
+ onMount(() => {
13
+ const wrapper = panelElement();
14
+ const state = helpPanelState(wrapper);
15
+ const update = (context: ReturnType<typeof getContext>) => {
16
+ const next = helpStart(state.data.documents, context && context.app === state.data.appId ? (context.help ?? []) : []).view;
17
+ // Retain row identity when the blob and island props name the same topic, including the first host snapshot.
18
+ setTopics((previous) => next.map((topic) => previous.find((candidate) => candidate.id === topic.id) ?? topic));
19
+ };
20
+ onCleanup(onContextChange(update));
21
+ const section = wrapper.querySelector("[data-help-view]");
22
+ if (section) restorePanelFocus(section);
23
+ const context = getContext();
24
+ if (context) update(context);
25
+ });
26
+ return <HelpViewSection items={topics()} onOpen={() => {}} />;
27
+ }