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