@k2b/cloud 0.33.0 → 0.35.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 (68) hide show
  1. package/package.json +3 -3
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +1 -0
  4. package/src/_internal/registry.ts +10 -1
  5. package/src/_internal/widget-declarations.ts +16 -3
  6. package/src/_internal/widget-settings.ts +93 -0
  7. package/src/ai/capabilities.ts +130 -16
  8. package/src/ai/firecrawl-tools.ts +3 -30
  9. package/src/ai/resource-refs.ts +35 -3
  10. package/src/ai/system-prompt.ts +3 -2
  11. package/src/api/index.ts +2 -0
  12. package/src/api/mcp.ts +48 -17
  13. package/src/api/pwa.ts +7 -2
  14. package/src/api/resource-links.ts +43 -0
  15. package/src/api/widgets.ts +163 -82
  16. package/src/browser/CloudResourceSearch.tsx +6 -6
  17. package/src/browser/command-shortcuts.ts +2 -11
  18. package/src/browser/resource-picker.tsx +1 -0
  19. package/src/browser/resource-search.ts +7 -2
  20. package/src/browser/widgets.ts +23 -5
  21. package/src/capabilities/testing.ts +13 -1
  22. package/src/cli/capabilities.ts +61 -17
  23. package/src/cli/index.ts +2 -0
  24. package/src/cli/resource-links.ts +13 -0
  25. package/src/contracts/app.ts +7 -1
  26. package/src/contracts/capabilities.ts +25 -5
  27. package/src/contracts/registry.ts +3 -1
  28. package/src/contracts/resource-href.ts +7 -0
  29. package/src/contracts/widgets.ts +80 -6
  30. package/src/desktop/index.ts +3 -2
  31. package/src/desktop/solid.tsx +7 -5
  32. package/src/server/widget-request.ts +34 -5
  33. package/src/services/app-approval.ts +4 -2
  34. package/src/services/approvals/index.ts +40 -11
  35. package/src/services/approvals/list.ts +1 -0
  36. package/src/services/approvals/migrate.ts +10 -0
  37. package/src/services/network-security.ts +33 -9
  38. package/src/services/pdf/markdown.ts +11 -7
  39. package/src/services/pwa-devices.ts +68 -15
  40. package/src/shared/app-presentation.ts +49 -1
  41. package/src/shared/index.ts +1 -0
  42. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  43. package/src/shared/markdown/index.ts +8 -2
  44. package/src/shared/network-address.ts +46 -45
  45. package/src/ssr/Layout.tsx +3 -15
  46. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  47. package/src/ssr/LayoutHelp.tsx +320 -183
  48. package/src/ssr/LayoutHelpTrigger.tsx +1 -3
  49. package/src/ssr/LayoutPreferences.tsx +15 -5
  50. package/src/ssr/MinimalLayoutPreferences.island.tsx +3 -9
  51. package/src/ssr/MobileProfileActions.tsx +3 -8
  52. package/src/ssr/ProfilePreferences.tsx +8 -10
  53. package/src/ssr/ShellRuntime.island.tsx +1 -5
  54. package/src/ssr/ThemeCycleButton.tsx +46 -0
  55. package/src/ssr/app-appearance.ts +6 -1
  56. package/src/ssr/layout-help-url.ts +39 -0
  57. package/src/ssr/platform-messages.ts +0 -8
  58. package/src/ssr/preference-controller.ts +53 -19
  59. package/src/ssr/profile-actions.ts +12 -16
  60. package/src/ssr/profile-preferences-messages.ts +4 -2
  61. package/src/ssr/rail-navigation.ts +3 -0
  62. package/src/ssr/shell-data.tsx +2 -2
  63. package/src/styles/effects.css +13 -3
  64. package/src/styles/global.css +47 -12
  65. package/src/styles/input.css +0 -8
  66. package/src/styles/tokens.css +0 -2
  67. package/src/styles/utilities-navigation.css +10 -13
  68. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
@@ -1,29 +1,42 @@
1
- import type { HelpDocumentManifest, HelpDocumentPayload, HelpSearchPayload } from "@k2b/cloud/shared";
1
+ import type { HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
2
2
  import { clipboard } from "@k2b/stdlib/solid";
3
- import { Button, IconButton, IconButtonLink, MarkdownView, NoticeCard, Placeholder, prompts, ScrollArea, useLocale } from "@k2b/ui";
4
- import { createEffect, createMemo, createSignal, For, onCleanup, onMount, Show } from "solid-js";
5
- import { COMMANDS_CHANGED, contextCommandsWithShortcuts, shortcutLabel } from "../browser/command-shortcuts";
3
+ import {
4
+ Button,
5
+ IconButton,
6
+ IconButtonLink,
7
+ Kbd,
8
+ MarkdownView,
9
+ NoticeCard,
10
+ Placeholder,
11
+ prefersReducedMotion,
12
+ prompts,
13
+ ScrollArea,
14
+ TextInput,
15
+ useLocale,
16
+ } from "@k2b/ui";
17
+ import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
18
+ import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "../browser/command-shortcuts";
6
19
  import { appAccentStyle } from "./app-appearance";
7
- import { type GlobalSearchHelpApp, openGlobalSearchHelpDialog } from "./GlobalSearchHelpDialog";
8
20
  import { helpMessages } from "./help-messages";
9
21
  import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-help-markdown";
10
22
  import { adjacentHelpDocuments, focusHelpArticleHeading, resetHelpArticleScroll } from "./layout-help-navigation";
11
23
  import { HELP_TOPICS_EVENT, type HelpTopic, registry } from "./layout-help-registry";
12
24
  import { searchHelpTopics } from "./layout-help-search";
13
- import { layoutHelpTopicHref } from "./layout-help-url";
25
+ import { helpLinkTarget, layoutHelpTopicHref } from "./layout-help-url";
26
+ import { readShellData, useShellData } from "./shell-data";
14
27
 
15
28
  export type LayoutHelpPageProps = {
16
29
  documents: readonly HelpDocumentManifest[];
17
30
  initialTopic?: string;
18
31
  includeShortcuts?: boolean;
19
32
  accent?: string;
20
- /** Render inside an app-owned pane instead of occupying a standalone page. */
33
+ /** Render inside an app-owned pane instead of occupying a full page. */
21
34
  embedded?: boolean;
22
- /** Canonical standalone Help route owned by the current app. */
35
+ /** Canonical full-page Help route owned by the current app. */
23
36
  pageBase?: string;
24
37
  };
25
38
 
26
- type HelpView = "hub" | "search" | "article";
39
+ type HelpView = "hub" | "article";
27
40
  type HelpSession = {
28
41
  view: HelpView;
29
42
  query: string;
@@ -52,7 +65,7 @@ const mergeTopics = (registered: readonly HelpTopic[], documents: readonly HelpD
52
65
  return [...topics.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));
53
66
  };
54
67
 
55
- const Shortcuts = (props: { openSearchHelp: () => void }) => {
68
+ const Shortcuts = (props: { focusSearch: () => void }) => {
56
69
  const locale = useLocale();
57
70
  const t = () => helpMessages.resolve([locale()]).t;
58
71
  const [commands, setCommands] = createSignal(contextCommandsWithShortcuts());
@@ -65,7 +78,7 @@ const Shortcuts = (props: { openSearchHelp: () => void }) => {
65
78
  return (
66
79
  <div class="flex flex-col gap-3">
67
80
  <p class="text-sm leading-relaxed text-dimmed">{t().shortcutsIntro}</p>
68
- <Button size="sm" variant="secondary" class="self-start" onClick={props.openSearchHelp}>
81
+ <Button size="sm" variant="secondary" class="self-start" onClick={props.focusSearch}>
69
82
  <i class="ti ti-search" /> {t().searchHelp}
70
83
  </Button>
71
84
  <div class="flex flex-col gap-2">
@@ -78,9 +91,7 @@ const Shortcuts = (props: { openSearchHelp: () => void }) => {
78
91
  <p class="mt-0.5 text-xs text-dimmed">{entry.description}</p>
79
92
  </Show>
80
93
  </div>
81
- <kbd class="shrink-0 rounded bg-[var(--ui-surface-raised)] px-1.5 py-1 text-[11px] ring-1 ring-black/10 dark:ring-white/10">
82
- {shortcutLabel(entry.shortcut!)}
83
- </kbd>
94
+ <Kbd keys={entry.shortcut!} />
84
95
  </div>
85
96
  )}
86
97
  </For>
@@ -93,7 +104,6 @@ const HelpShell = (props: {
93
104
  session: HelpSession;
94
105
  close: () => void;
95
106
  pageBase?: string;
96
- searchHelpApps: GlobalSearchHelpApp[];
97
107
  documents?: readonly HelpDocumentManifest[];
98
108
  includeShortcuts?: boolean;
99
109
  accent?: string;
@@ -102,6 +112,8 @@ const HelpShell = (props: {
102
112
  }) => {
103
113
  const locale = useLocale();
104
114
  const t = () => helpMessages.resolve([locale()]).t;
115
+ // Links to this app's Help, by its own route or Core's `/help/apps/<id>`, open in place.
116
+ const appId = useShellData()?.appId;
105
117
  const [externalTopics, setExternalTopics] = createSignal(mergeTopics(sortedTopics(), props.documents));
106
118
  const [view, setView] = createSignal<HelpView>(props.session.view);
107
119
  const [query, setQuery] = createSignal(props.session.query);
@@ -122,20 +134,45 @@ const HelpShell = (props: {
122
134
  let scrollArea: HTMLDivElement | undefined;
123
135
  let articleContent: HTMLDivElement | undefined;
124
136
  let articleHeading: HTMLHeadingElement | undefined;
137
+ let searchInput: HTMLInputElement | undefined;
138
+ let resultsRegion: HTMLDivElement | undefined;
139
+ const [resultsMinHeight, setResultsMinHeight] = createSignal<string>();
125
140
  let pendingArticleFocus = false;
141
+ let pendingNavigation = false;
142
+ /** Where the page's own address points in this Help; a query string, such as a campaign tag, does not matter here. */
143
+ const currentHelpTarget = () =>
144
+ helpLinkTarget(`${location.pathname}${location.hash}`, location.href, { appId, pageBase: props.pageBase });
145
+ let pendingHash = props.syncPageUrl && typeof location !== "undefined" ? (currentHelpTarget()?.hash ?? "") : "";
146
+ /**
147
+ * The address Help shows now. The phone menu and dialogs with a history entry put a second entry on the same address
148
+ * and take it away again; that is not Help navigation and must not move the article or the search.
149
+ */
150
+ const pageAddress = () => `${location.pathname}${location.hash}`;
151
+ let shownAddress = props.syncPageUrl && typeof location !== "undefined" ? pageAddress() : "";
126
152
  let requestVersion = 0;
127
153
  let searchVersion = 0;
128
154
  let copyAllController: AbortController | undefined;
129
155
 
130
- const restoreArticleScroll = () =>
156
+ const articleScroller = () => {
157
+ if (props.surface !== "page") return scrollArea;
158
+ for (let element = root?.parentElement; element; element = element.parentElement) {
159
+ if (/(auto|scroll)/.test(getComputedStyle(element).overflowY)) return element;
160
+ }
161
+ return document.scrollingElement;
162
+ };
163
+
164
+ const restoreArticleScroll = () => {
165
+ if (props.surface !== "modal" || pendingNavigation || pendingHash) return;
131
166
  requestAnimationFrame(() => {
132
167
  if (scrollArea && view() === "article") scrollArea.scrollTop = props.session.articleScrollTop;
133
168
  });
169
+ };
134
170
 
135
- const syncActiveSection = () => {
171
+ const syncActiveSection = (scroller = articleScroller()) => {
136
172
  const sections = articleSections();
137
- if (!scrollArea || sections.length === 0) return;
138
- const readingLine = scrollArea.getBoundingClientRect().top + Math.min(160, scrollArea.clientHeight * 0.22);
173
+ if (!scroller || sections.length === 0) return;
174
+ const top = scroller === document.scrollingElement ? 0 : scroller.getBoundingClientRect().top;
175
+ const readingLine = top + Math.min(160, scroller.clientHeight * 0.22);
139
176
  let active = sections[0];
140
177
  for (const section of sections) {
141
178
  if (section.element.getBoundingClientRect().top <= readingLine) active = section;
@@ -144,6 +181,16 @@ const HelpShell = (props: {
144
181
  setActiveSectionId(active?.id ?? null);
145
182
  };
146
183
 
184
+ const scrollToHash = (hash: string) => {
185
+ const heading = Array.from(
186
+ articleContent?.querySelectorAll<HTMLHeadingElement>("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") ?? [],
187
+ ).find((element) => element.id === hash);
188
+ if (!heading) return false;
189
+ setActiveSectionId(hash);
190
+ heading.scrollIntoView({ block: "start", behavior: prefersReducedMotion() ? "auto" : "smooth" });
191
+ return true;
192
+ };
193
+
147
194
  const collectArticleSections = () => {
148
195
  const sections = Array.from(articleContent?.querySelectorAll<HTMLHeadingElement>("h2[id]") ?? []).map((element) => ({
149
196
  element,
@@ -153,15 +200,25 @@ const HelpShell = (props: {
153
200
  }));
154
201
  setArticleSections(sections);
155
202
  setActiveSectionId(sections[0]?.id ?? null);
156
- requestAnimationFrame(syncActiveSection);
203
+ const openedSection = pendingHash ? scrollToHash(pendingHash) : false;
204
+ pendingHash = "";
205
+ pendingNavigation = false;
206
+ if (!openedSection) syncActiveSection();
157
207
  };
158
208
 
159
209
  const openArticleSection = (section: HelpSection) => {
160
- setActiveSectionId(section.id);
161
- section.element.scrollIntoView({
162
- behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
163
- block: "start",
164
- });
210
+ scrollToHash(section.id);
211
+ if (props.syncPageUrl) {
212
+ const url = new URL(location.href);
213
+ url.hash = encodeURIComponent(section.id);
214
+ history.replaceState(history.state, "", url);
215
+ shownAddress = pageAddress();
216
+ }
217
+ };
218
+
219
+ const focusSearch = () => {
220
+ showHub();
221
+ requestAnimationFrame(() => searchInput?.focus({ preventScroll: true }));
165
222
  };
166
223
 
167
224
  const shortcutsTopic = createMemo<HelpTopic>(() => ({
@@ -171,7 +228,7 @@ const HelpShell = (props: {
171
228
  description: t().shortcutsDescription,
172
229
  order: 0,
173
230
  kind: "content",
174
- children: <Shortcuts openSearchHelp={() => openGlobalSearchHelpDialog(props.searchHelpApps, locale())} />,
231
+ children: <Shortcuts focusSearch={focusSearch} />,
175
232
  }));
176
233
  const topics = createMemo(() => (props.includeShortcuts === false ? externalTopics() : [shortcutsTopic(), ...externalTopics()]));
177
234
  const documentTopics = createMemo(() =>
@@ -210,8 +267,10 @@ const HelpShell = (props: {
210
267
  headers: { Accept: "application/json" },
211
268
  });
212
269
  if (!response.ok) throw new Error(t().searchFailed);
213
- const payload = (await response.json()) as Partial<HelpSearchPayload>;
214
- return Array.isArray(payload.ids) ? payload.ids.filter((id): id is string => typeof id === "string") : [];
270
+ const payload: unknown = await response.json();
271
+ return payload && typeof payload === "object" && "ids" in payload && Array.isArray(payload.ids)
272
+ ? payload.ids.filter((id): id is string => typeof id === "string")
273
+ : [];
215
274
  }),
216
275
  )
217
276
  .then((groups) => {
@@ -240,7 +299,13 @@ const HelpShell = (props: {
240
299
  setActiveSectionId(null);
241
300
  return;
242
301
  }
243
- const frame = requestAnimationFrame(collectArticleSections);
302
+ const frame = requestAnimationFrame(() => {
303
+ if (pendingNavigation && !pendingHash) {
304
+ const scroller = articleScroller();
305
+ if (scroller) scroller.scrollTop = 0;
306
+ }
307
+ collectArticleSections();
308
+ });
244
309
  onCleanup(() => cancelAnimationFrame(frame));
245
310
  });
246
311
 
@@ -272,6 +337,35 @@ const HelpShell = (props: {
272
337
  update();
273
338
  onCleanup(() => window.removeEventListener(HELP_TOPICS_EVENT, update));
274
339
  restoreArticleScroll();
340
+ let scrollFrame: number | undefined;
341
+ const onScroll = (event: Event) => {
342
+ const scroller = event.target === document ? document.scrollingElement : event.target;
343
+ if (!(scroller instanceof Element) || !articleContent || !scroller.contains(articleContent) || view() !== "article") return;
344
+ if (props.surface === "modal" && scroller === scrollArea) props.session.articleScrollTop = scroller.scrollTop;
345
+ if (scrollFrame !== undefined) return;
346
+ scrollFrame = requestAnimationFrame(() => {
347
+ scrollFrame = undefined;
348
+ syncActiveSection(scroller);
349
+ });
350
+ };
351
+ document.addEventListener("scroll", onScroll, { capture: true, passive: true });
352
+ const onPopState = () => {
353
+ const address = pageAddress();
354
+ if (address === shownAddress) return;
355
+ const target = currentHelpTarget();
356
+ if (target?.kind !== "topic") return;
357
+ shownAddress = address;
358
+ const id = target.topicId;
359
+ if (id !== null && topics().some((topic) => topic.id === id)) openTopic(id, target.hash, false);
360
+ // The hub keeps its search, as the reader's own Back button does. An unknown topic shows the hub, as the server does.
361
+ else setView("hub");
362
+ };
363
+ if (props.syncPageUrl) window.addEventListener("popstate", onPopState);
364
+ onCleanup(() => {
365
+ document.removeEventListener("scroll", onScroll, true);
366
+ window.removeEventListener("popstate", onPopState);
367
+ if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame);
368
+ });
275
369
  });
276
370
  onCleanup(() => copyAllController?.abort());
277
371
 
@@ -282,18 +376,30 @@ const HelpShell = (props: {
282
376
 
283
377
  const response = await fetch(topic.url, { signal, headers: { Accept: "application/json" } });
284
378
  if (!response.ok) throw new Error(t().requestFailed);
285
- const value = (await response.json()) as Partial<HelpDocumentPayload>;
379
+ const value: unknown = await response.json();
286
380
  if (
381
+ !value ||
382
+ typeof value !== "object" ||
383
+ !("id" in value) ||
287
384
  value.id !== topic.id ||
288
- (value.locale !== undefined && typeof value.locale !== "string") ||
289
- (topic.locale !== undefined && value.locale !== undefined && value.locale !== topic.locale) ||
385
+ !("html" in value) ||
386
+ !("markdown" in value) ||
387
+ !("title" in value) ||
388
+ ("locale" in value && value.locale !== undefined && typeof value.locale !== "string") ||
389
+ (topic.locale !== undefined && "locale" in value && value.locale !== undefined && value.locale !== topic.locale) ||
290
390
  typeof value.html !== "string" ||
291
391
  typeof value.markdown !== "string" ||
292
392
  typeof value.title !== "string"
293
393
  ) {
294
394
  throw new Error(t().invalidDocument);
295
395
  }
296
- const document: HelpDocumentPayload = { ...(value as HelpDocumentPayload), locale: value.locale ?? topic.locale ?? "en" };
396
+ const document: HelpDocumentPayload = {
397
+ id: topic.id,
398
+ title: value.title,
399
+ html: value.html,
400
+ markdown: value.markdown,
401
+ locale: "locale" in value && typeof value.locale === "string" ? value.locale : (topic.locale ?? "en"),
402
+ };
297
403
  props.session.articleCache.set(cacheKey, document);
298
404
  return document;
299
405
  };
@@ -369,44 +475,79 @@ const HelpShell = (props: {
369
475
  }
370
476
  };
371
477
 
372
- const openTopic = (id: string) => {
373
- resetHelpArticleScroll(props.session, scrollArea);
374
- pendingArticleFocus = true;
375
- setActiveId(id);
376
- setView("article");
377
- if (props.syncPageUrl) {
378
- if (props.pageBase) history.replaceState(history.state, "", layoutHelpTopicHref(props.pageBase, id));
478
+ const pushHelpUrl = (id: string | null, hash = "") => {
479
+ if (props.syncPageUrl && props.pageBase) {
480
+ history.pushState(history.state, "", layoutHelpTopicHref(props.pageBase, id) + (hash ? `#${encodeURIComponent(hash)}` : ""));
481
+ shownAddress = pageAddress();
379
482
  }
483
+ };
484
+ const openTopic = (id: string, hash = "", push = true) => {
485
+ if (push) pushHelpUrl(id, hash);
380
486
  try {
381
487
  localStorage.setItem(LAST_TOPIC_KEY, id);
382
488
  } catch {
383
489
  /* Help does not depend on storage. */
384
490
  }
385
- };
386
- const goBack = () => {
387
- setView(query().trim() ? "search" : "hub");
388
- if (props.syncPageUrl) {
389
- if (props.pageBase) history.replaceState(history.state, "", layoutHelpTopicHref(props.pageBase, null));
491
+ // The open article does not load again: go to its section, or to its top.
492
+ if (view() === "article" && activeId() === id && payload()) {
493
+ if (!hash || !scrollToHash(hash)) resetHelpArticleScroll(props.session, articleScroller() ?? undefined);
494
+ return;
390
495
  }
496
+ resetHelpArticleScroll(props.session, articleScroller() ?? undefined);
497
+ pendingArticleFocus = true;
498
+ pendingNavigation = true;
499
+ pendingHash = hash;
500
+ setActiveId(id);
501
+ setView("article");
391
502
  };
392
- const showHub = () => {
503
+ const showHub = (push = true) => {
393
504
  setQuery("");
505
+ setResultsMinHeight(undefined);
394
506
  setRemoteIds(null);
395
507
  setView("hub");
508
+ if (push) pushHelpUrl(null);
396
509
  };
510
+ const goBack = () => {
511
+ setView("hub");
512
+ pushHelpUrl(null);
513
+ };
514
+
515
+ const followHelpLink = (event: MouseEvent) => {
516
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
517
+ if (!(event.target instanceof Element)) return;
518
+ const anchor = event.target.closest("a");
519
+ if (!anchor || !articleContent?.contains(anchor) || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download"))
520
+ return;
521
+ const target = helpLinkTarget(anchor.getAttribute("href") ?? "", location.href, { appId, pageBase: props.pageBase });
522
+ if (!target) return;
523
+ if (target.kind === "section") {
524
+ if (!scrollToHash(target.hash)) return;
525
+ event.preventDefault();
526
+ if (props.syncPageUrl) {
527
+ const url = new URL(location.href);
528
+ url.hash = encodeURIComponent(target.hash);
529
+ history.replaceState(history.state, "", url);
530
+ shownAddress = pageAddress();
531
+ }
532
+ } else if (target.topicId === null) {
533
+ event.preventDefault();
534
+ showHub();
535
+ } else if (topics().some((topic) => topic.id === target.topicId)) {
536
+ event.preventDefault();
537
+ openTopic(target.topicId, target.hash);
538
+ }
539
+ };
540
+
397
541
  const modalTitle = createMemo(() => {
398
542
  if (view() === "article") return activeTopic()?.title ?? t().help;
399
- if (view() === "search") return t().searchHelp;
400
543
  return t().help;
401
544
  });
402
545
  const modalDescription = createMemo(() => {
403
546
  if (view() === "article") return activeTopic()?.description ?? t().description;
404
- if (view() === "search") return t().searchDescription;
405
547
  return t().description;
406
548
  });
407
549
  const modalIcon = createMemo(() => {
408
550
  if (view() === "article") return iconClass(activeTopic()?.icon);
409
- if (view() === "search") return "ti ti-search";
410
551
  return "ti ti-help";
411
552
  });
412
553
 
@@ -472,14 +613,23 @@ const HelpShell = (props: {
472
613
  </Show>
473
614
  );
474
615
 
616
+ const ReaderContent = (content: { children: JSX.Element }) =>
617
+ props.surface === "page" ? (
618
+ <div class="p-4 sm:p-5">{content.children}</div>
619
+ ) : (
620
+ <ScrollArea ref={scrollArea} class="min-h-0 flex-1 p-4 sm:p-5">
621
+ {content.children}
622
+ </ScrollArea>
623
+ );
624
+
475
625
  return (
476
626
  <div
477
627
  ref={root}
478
- class={`app-accent-scope flex min-h-0 flex-col bg-[var(--ui-surface-raised)] ${
628
+ class={`app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
479
629
  props.surface === "modal"
480
630
  ? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
481
631
  : props.surface === "page"
482
- ? "min-h-dvh"
632
+ ? ""
483
633
  : "h-full"
484
634
  }`}
485
635
  style={appAccentStyle(props.accent)}
@@ -487,12 +637,7 @@ const HelpShell = (props: {
487
637
  <Show when={props.surface === "modal"}>
488
638
  <header class="flex min-h-16 shrink-0 items-center gap-2 px-5 py-3">
489
639
  <Show when={view() !== "hub"}>
490
- <IconButton
491
- class="shrink-0"
492
- label={view() === "article" ? t().backToTopics : t().backToHelp}
493
- title={t().back}
494
- onClick={() => (view() === "article" ? goBack() : showHub())}
495
- >
640
+ <IconButton class="shrink-0" label={t().backToTopics} title={t().back} onClick={goBack}>
496
641
  <i class="ti ti-arrow-left" />
497
642
  </IconButton>
498
643
  </Show>
@@ -565,16 +710,7 @@ const HelpShell = (props: {
565
710
  </header>
566
711
  </Show>
567
712
 
568
- <ScrollArea
569
- ref={scrollArea}
570
- class="min-h-0 flex-1 p-4 sm:p-5"
571
- onScroll={(event) => {
572
- if (view() === "article") {
573
- props.session.articleScrollTop = event.currentTarget.scrollTop;
574
- syncActiveSection();
575
- }
576
- }}
577
- >
713
+ <ReaderContent>
578
714
  <Show when={view() === "hub"}>
579
715
  <div class="mx-auto flex max-w-3xl flex-col gap-6">
580
716
  <div class="flex flex-col items-start justify-between gap-3 sm:flex-row">
@@ -596,70 +732,70 @@ const HelpShell = (props: {
596
732
  </p>
597
733
  )}
598
734
  </Show>
599
- <label class="field flex h-11 items-center gap-2 px-3">
600
- <i class="ti ti-search text-dimmed" />
601
- <input
602
- class="min-w-0 flex-1 bg-transparent text-sm outline-none"
603
- value={query()}
604
- placeholder={t().searchPlaceholder}
605
- onInput={(event) => {
606
- setQuery(event.currentTarget.value);
607
- if (event.currentTarget.value.trim()) setView("search");
608
- }}
609
- />
610
- </label>
611
- <section aria-labelledby="help-start-title">
612
- <h4 id="help-start-title" class="mb-2 text-xs font-semibold uppercase tracking-wide text-dimmed">
613
- {t().startHere}
614
- </h4>
615
- <TopicList items={topics().slice(0, 5)} />
616
- </section>
617
- <Show when={topics().length > 5}>
618
- <section aria-labelledby="help-all-title">
619
- <h4 id="help-all-title" class="mb-2 text-xs font-semibold uppercase tracking-wide text-dimmed">
620
- {t().allTopics}
621
- </h4>
622
- <TopicList items={topics().slice(5)} />
623
- </section>
624
- </Show>
625
- </div>
626
- </Show>
627
-
628
- <Show when={view() === "search"}>
629
- <div class="mx-auto flex max-w-3xl flex-col gap-4">
630
- <div class="flex items-center gap-2">
631
- <Show when={props.surface !== "modal"}>
632
- <IconButton label={t().backToHelp} onClick={showHub}>
633
- <i class="ti ti-arrow-left" />
634
- </IconButton>
735
+ <TextInput
736
+ ref={searchInput}
737
+ type="search"
738
+ icon="ti ti-search"
739
+ aria-label={t().searchHelp}
740
+ data-help-search=""
741
+ value={query()}
742
+ placeholder={t().searchPlaceholder}
743
+ onValueChange={(value) => {
744
+ // Results replace the start lists below the field; the area keeps its height so nothing moves.
745
+ if (!query().trim() && value.trim()) setResultsMinHeight(`${resultsRegion?.offsetHeight ?? 0}px`);
746
+ if (!value.trim()) setResultsMinHeight(undefined);
747
+ setQuery(value);
748
+ }}
749
+ />
750
+ <div ref={resultsRegion} data-help-results="" class="flex flex-col gap-4" style={{ "min-height": resultsMinHeight() }}>
751
+ <Show
752
+ when={normalizedQuery()}
753
+ fallback={
754
+ <>
755
+ <section aria-labelledby="help-start-title">
756
+ <h4 id="help-start-title" class="mb-2 text-xs font-semibold uppercase tracking-wide text-dimmed">
757
+ {t().startHere}
758
+ </h4>
759
+ <TopicList items={topics().slice(0, 5)} />
760
+ </section>
761
+ <Show when={topics().length > 5}>
762
+ <section aria-labelledby="help-all-title">
763
+ <h4 id="help-all-title" class="mb-2 text-xs font-semibold uppercase tracking-wide text-dimmed">
764
+ {t().allTopics}
765
+ </h4>
766
+ <TopicList items={topics().slice(5)} />
767
+ </section>
768
+ </Show>
769
+ </>
770
+ }
771
+ >
772
+ {/* The count takes the place of the "Start here" title, so the first row stays where it was. */}
773
+ <section>
774
+ <p class="mb-2 flex items-center gap-1.5 text-xs font-semibold uppercase tracking-wide text-dimmed" aria-live="polite">
775
+ {t().results({ count: results().length })}
776
+ <Show when={searching()}>
777
+ <i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
778
+ </Show>
779
+ </p>
780
+ <Show
781
+ when={results().length > 0}
782
+ fallback={
783
+ <Show when={!searching()}>
784
+ <Placeholder icon="ti ti-search-off" title={t().noResults} />
785
+ </Show>
786
+ }
787
+ >
788
+ <TopicList items={results()} />
789
+ </Show>
790
+ </section>
635
791
  </Show>
636
- <label class="field flex h-11 flex-1 items-center gap-2 px-3">
637
- <i class="ti ti-search text-dimmed" />
638
- <input
639
- autofocus
640
- class="min-w-0 flex-1 bg-transparent text-sm outline-none"
641
- value={query()}
642
- placeholder={t().searchPlaceholder}
643
- onInput={(event) => setQuery(event.currentTarget.value)}
644
- />
645
- </label>
646
792
  </div>
647
- <p class="flex items-center gap-1.5 text-xs text-dimmed" aria-live="polite">
648
- <Show when={searching()}>
649
- <i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
650
- </Show>
651
- {t().results({ count: results().length })}
652
- </p>
653
- <TopicList items={results()} />
654
- <Show when={results().length === 0 && !searching()}>
655
- <Placeholder icon="ti ti-search-off" title={t().noResults} />
656
- </Show>
657
793
  </div>
658
794
  </Show>
659
795
 
660
796
  <Show when={view() === "article" && activeTopic()}>
661
797
  {(topic) => (
662
- <article class="mx-auto flex min-h-full w-full max-w-7xl flex-col">
798
+ <article class="help-article mx-auto flex min-h-full w-full max-w-7xl flex-col">
663
799
  <Show when={props.surface !== "modal"}>
664
800
  <button
665
801
  type="button"
@@ -695,54 +831,63 @@ const HelpShell = (props: {
695
831
  </Show>
696
832
  <Show when={topic().kind === "content"}>{legacyTopicContent(topic())}</Show>
697
833
  <Show when={topic().kind === "document"}>
698
- <Show when={loading()}>
699
- <Placeholder state="loading" variant="panel" title={t().loading} />
700
- </Show>
701
- <Show when={loadError()}>
702
- {(message) => (
703
- <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
704
- <Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
705
- {t().tryAgain}
706
- </Button>
707
- </NoticeCard>
708
- )}
709
- </Show>
710
- <Show when={payload()}>
711
- {(document) => (
712
- <div class="help-article-layout">
713
- <Show when={articleSections().length > 0}>
714
- <nav class="help-article-toc" aria-label={t().onThisPage}>
715
- <p class="help-article-toc-label">{t().onThisPage}</p>
716
- <ol>
717
- <For each={articleSections()}>
718
- {(section) => (
719
- <li>
720
- <button
721
- type="button"
722
- aria-current={activeSectionId() === section.id ? "location" : undefined}
723
- title={section.title}
724
- onClick={() => openArticleSection(section)}
725
- >
726
- <span class="help-article-toc-icon" aria-hidden="true">
727
- <i class={section.icon} />
728
- </span>
729
- <span class="help-article-toc-title">{section.title}</span>
730
- </button>
731
- </li>
732
- )}
733
- </For>
734
- </ol>
735
- </nav>
736
- </Show>
737
- <div class="help-article-copy">
738
- <div ref={articleContent}>
739
- <MarkdownView trustedHtml={document().html} class="help-document" />
740
- </div>
741
- <TopicNavigation />
834
+ <div class="help-article-layout">
835
+ <nav
836
+ class="help-article-toc"
837
+ aria-label={t().onThisPage}
838
+ aria-hidden={articleSections().length === 0 ? "true" : undefined}
839
+ data-empty={articleSections().length === 0 ? "" : undefined}
840
+ >
841
+ <p class="help-article-toc-label">{t().onThisPage}</p>
842
+ <ol>
843
+ <For each={articleSections()}>
844
+ {(section) => (
845
+ <li>
846
+ <button
847
+ type="button"
848
+ aria-current={activeSectionId() === section.id ? "location" : undefined}
849
+ title={section.title}
850
+ onClick={() => openArticleSection(section)}
851
+ >
852
+ <span class="help-article-toc-icon" aria-hidden="true">
853
+ <i class={section.icon} />
854
+ </span>
855
+ <span class="help-article-toc-title">{section.title}</span>
856
+ </button>
857
+ </li>
858
+ )}
859
+ </For>
860
+ </ol>
861
+ </nav>
862
+ <div class="help-article-copy">
863
+ <Show when={loading()}>
864
+ <div class="min-h-[24rem]">
865
+ <Placeholder state="loading" variant="panel" title={t().loading} />
742
866
  </div>
867
+ </Show>
868
+ <Show when={loadError()}>
869
+ {(message) => (
870
+ <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
871
+ <Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
872
+ {t().tryAgain}
873
+ </Button>
874
+ </NoticeCard>
875
+ )}
876
+ </Show>
877
+ <div
878
+ ref={(element) => {
879
+ articleContent = element;
880
+ element.addEventListener("click", followHelpLink);
881
+ onCleanup(() => element.removeEventListener("click", followHelpLink));
882
+ }}
883
+ >
884
+ <Show when={payload()}>{(document) => <MarkdownView trustedHtml={document().html} class="help-document" />}</Show>
743
885
  </div>
744
- )}
745
- </Show>
886
+ <Show when={payload()}>
887
+ <TopicNavigation />
888
+ </Show>
889
+ </div>
890
+ </div>
746
891
  </Show>
747
892
  </article>
748
893
  )}
@@ -750,7 +895,7 @@ const HelpShell = (props: {
750
895
  <span class="sr-only" aria-live="polite">
751
896
  {articleClipboard.wasCopied() ? t().articleCopiedAnnouncement : allHelpClipboard.wasCopied() ? t().allCopiedAnnouncement : ""}
752
897
  </span>
753
- </ScrollArea>
898
+ </ReaderContent>
754
899
  </div>
755
900
  );
756
901
  };
@@ -784,7 +929,6 @@ export function LayoutHelpPage(props: LayoutHelpPageProps) {
784
929
  <HelpShell
785
930
  session={session}
786
931
  close={() => {}}
787
- searchHelpApps={[]}
788
932
  documents={props.documents}
789
933
  includeShortcuts={props.includeShortcuts}
790
934
  accent={props.accent}
@@ -795,19 +939,12 @@ export function LayoutHelpPage(props: LayoutHelpPageProps) {
795
939
  );
796
940
  }
797
941
 
798
- export function openLayoutHelpDialog(searchHelpApps: GlobalSearchHelpApp[] = [], accent?: string) {
942
+ /** Open the current app's Help over the page; the dialog sits outside the app canvas, so it takes the app's accent along. */
943
+ export function openLayoutHelpDialog() {
799
944
  const session = createSession();
945
+ const accent = readShellData()?.accent;
800
946
  void prompts.dialog<void>(
801
- (close) => (
802
- <HelpShell
803
- session={session}
804
- close={close}
805
- searchHelpApps={searchHelpApps}
806
- accent={accent}
807
- surface="modal"
808
- pageBase={window.__cloudLayoutHelpPageBase}
809
- />
810
- ),
947
+ (close) => <HelpShell session={session} close={close} accent={accent} surface="modal" pageBase={window.__cloudLayoutHelpPageBase} />,
811
948
  { surface: "bare", header: false, size: "wide" },
812
949
  );
813
950
  }