@k2b/cloud 0.32.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 (113) hide show
  1. package/package.json +5 -5
  2. package/src/_internal/app-presentation.ts +40 -11
  3. package/src/_internal/define-app.ts +18 -8
  4. package/src/_internal/page-responses.ts +5 -1
  5. package/src/_internal/registry.ts +10 -1
  6. package/src/_internal/widget-declarations.ts +16 -3
  7. package/src/_internal/widget-settings.ts +93 -0
  8. package/src/ai/capabilities.ts +8 -5
  9. package/src/ai/code-source-contracts.ts +17 -2
  10. package/src/ai/code-source-tools.ts +1 -0
  11. package/src/ai/default-tools.ts +4 -4
  12. package/src/ai/memories.ts +48 -1
  13. package/src/ai/memory-learning.ts +17 -6
  14. package/src/ai/memory-tool.ts +1 -0
  15. package/src/ai/migrate.ts +8 -0
  16. package/src/ai/provider.ts +4 -3
  17. package/src/ai/resource-refs.ts +35 -3
  18. package/src/ai/system-prompt.ts +3 -2
  19. package/src/api/index.ts +2 -0
  20. package/src/api/mcp.ts +48 -17
  21. package/src/api/pwa.ts +7 -2
  22. package/src/api/resource-links.ts +43 -0
  23. package/src/api/widgets.ts +163 -82
  24. package/src/browser/CloudResourceSearch.tsx +6 -6
  25. package/src/browser/command-shortcuts.ts +2 -11
  26. package/src/browser/navigation-search.ts +4 -1
  27. package/src/browser/resource-picker.tsx +1 -0
  28. package/src/browser/resource-search.ts +7 -2
  29. package/src/browser/widgets.ts +23 -5
  30. package/src/capabilities/testing.ts +13 -1
  31. package/src/cli/capabilities.ts +61 -17
  32. package/src/cli/index.ts +2 -0
  33. package/src/cli/resource-links.ts +13 -0
  34. package/src/contracts/app.ts +7 -1
  35. package/src/contracts/capabilities.ts +25 -5
  36. package/src/contracts/registry.ts +3 -1
  37. package/src/contracts/resource-href.ts +7 -0
  38. package/src/contracts/widgets.ts +80 -6
  39. package/src/desktop/index.ts +3 -2
  40. package/src/desktop/solid.tsx +7 -5
  41. package/src/server/widget-request.ts +34 -5
  42. package/src/services/app-approval.ts +4 -2
  43. package/src/services/approvals/index.ts +40 -11
  44. package/src/services/approvals/list.ts +1 -0
  45. package/src/services/approvals/migrate.ts +10 -0
  46. package/src/services/outbox.ts +13 -6
  47. package/src/services/pdf/markdown.ts +11 -7
  48. package/src/services/pwa-devices.ts +68 -15
  49. package/src/shared/ai-platform-prompt.ts +3 -2
  50. package/src/shared/app-presentation.ts +49 -1
  51. package/src/shared/index.ts +1 -0
  52. package/src/shared/markdown/callout-sanitizer.ts +37 -0
  53. package/src/shared/markdown/index.ts +8 -2
  54. package/src/shared/theme.ts +41 -20
  55. package/src/ssr/AnnouncementBanners.tsx +30 -0
  56. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  57. package/src/ssr/CloudShell.island.tsx +91 -0
  58. package/src/ssr/GlobalSearchTrigger.tsx +51 -0
  59. package/src/ssr/Layout.tsx +58 -99
  60. package/src/ssr/LayoutHeader.tsx +31 -77
  61. package/src/ssr/LayoutHelp.browser-harness.tsx +43 -0
  62. package/src/ssr/LayoutHelp.tsx +319 -238
  63. package/src/ssr/LayoutHelpTrigger.tsx +49 -0
  64. package/src/ssr/LayoutPreferences.tsx +31 -0
  65. package/src/ssr/LayoutRail.tsx +15 -69
  66. package/src/ssr/MinimalLayout.tsx +2 -3
  67. package/src/ssr/MinimalLayoutPreferences.island.tsx +6 -15
  68. package/src/ssr/MobileNavigation.tsx +13 -8
  69. package/src/ssr/MobileProfileActions.tsx +4 -6
  70. package/src/ssr/PageTrailRow.tsx +14 -0
  71. package/src/ssr/{ProfilePreferences.island.tsx → ProfilePreferences.tsx} +8 -5
  72. package/src/ssr/PwaLayout.tsx +0 -2
  73. package/src/ssr/PwaRuntime.island.tsx +1 -1
  74. package/src/ssr/ShellRuntime.island.tsx +166 -0
  75. package/src/ssr/ThemeCycleButton.tsx +46 -0
  76. package/src/ssr/TimezoneCookie.island.tsx +3 -19
  77. package/src/ssr/WorkspaceNavigationProvider.tsx +20 -1
  78. package/src/ssr/announcements-dialog.tsx +66 -0
  79. package/src/ssr/app-appearance.ts +6 -1
  80. package/src/ssr/{AppLaunchpad.island.tsx → app-launchpad.tsx} +42 -62
  81. package/src/ssr/busy-action.ts +19 -0
  82. package/src/ssr/islands/index.ts +9 -6
  83. package/src/ssr/launchpad-surface.tsx +15 -0
  84. package/src/ssr/layout-help-registry.tsx +60 -0
  85. package/src/ssr/layout-help-url.ts +39 -0
  86. package/src/ssr/mobile-navigation.browser-harness.tsx +20 -0
  87. package/src/ssr/platform-messages.ts +0 -8
  88. package/src/ssr/preference-controller.ts +62 -19
  89. package/src/ssr/profile-actions.ts +12 -5
  90. package/src/ssr/profile-preferences-messages.ts +10 -4
  91. package/src/ssr/rail-context.ts +8 -12
  92. package/src/ssr/rail-navigation.ts +3 -0
  93. package/src/ssr/shell-data.tsx +48 -0
  94. package/src/ssr/timezone-cookie.ts +22 -0
  95. package/src/ssr/workspace-navigation.ts +25 -2
  96. package/src/styles/effects.css +13 -3
  97. package/src/styles/global.css +43 -5
  98. package/src/styles/input.css +0 -8
  99. package/src/styles/tokens.css +0 -2
  100. package/src/styles/utilities-navigation.css +10 -0
  101. package/src/workflows/language/compiler.ts +1 -1
  102. package/src/ssr/AppWorkspaceController.island.tsx +0 -21
  103. package/src/ssr/BrowserPushRegistration.island.tsx +0 -12
  104. package/src/ssr/GlobalAnnouncements.island.tsx +0 -117
  105. package/src/ssr/GlobalSearchHelpDialog.tsx +0 -104
  106. package/src/ssr/GlobalSearchTrigger.island.tsx +0 -75
  107. package/src/ssr/LayoutHelpTrigger.island.tsx +0 -61
  108. package/src/ssr/LayoutPreferences.island.tsx +0 -21
  109. package/src/ssr/PageTrailRow.island.tsx +0 -20
  110. package/src/ssr/PageTrailRuntime.island.tsx +0 -26
  111. package/src/ssr/RegisteredHelpDocuments.island.tsx +0 -3
  112. /package/src/ssr/{LayoutBreadcrumbs.island.tsx → LayoutBreadcrumbs.tsx} +0 -0
  113. /package/src/ssr/{RailApps.island.tsx → RailApps.tsx} +0 -0
@@ -1,42 +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";
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";
4
16
  import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
5
- import { COMMANDS_CHANGED, contextCommandsWithShortcuts, shortcutLabel } from "../browser/command-shortcuts";
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";
22
+ import { HELP_TOPICS_EVENT, type HelpTopic, registry } from "./layout-help-registry";
11
23
  import { searchHelpTopics } from "./layout-help-search";
12
- import { layoutHelpTopicHref } from "./layout-help-url";
24
+ import { helpLinkTarget, layoutHelpTopicHref } from "./layout-help-url";
25
+ import { readShellData, useShellData } from "./shell-data";
13
26
 
14
- type HelpTopicBase = {
15
- id: string;
16
- title: string;
17
- icon?: string;
18
- description?: string;
19
- order?: number;
20
- };
21
-
22
- export type LayoutHelpDocumentsProps = {
23
- documents: readonly HelpDocumentManifest[];
24
- /** Canonical standalone Help route owned by the current app. */
25
- pageBase: string;
26
- };
27
27
  export type LayoutHelpPageProps = {
28
28
  documents: readonly HelpDocumentManifest[];
29
29
  initialTopic?: string;
30
30
  includeShortcuts?: boolean;
31
31
  accent?: string;
32
- /** 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. */
33
33
  embedded?: boolean;
34
- /** Canonical standalone Help route owned by the current app. */
34
+ /** Canonical full-page Help route owned by the current app. */
35
35
  pageBase?: string;
36
36
  };
37
37
 
38
- type HelpTopic = (HelpTopicBase & { kind: "content"; children: JSX.Element }) | (HelpDocumentManifest & { kind: "document" });
39
- type HelpView = "hub" | "search" | "article";
38
+ type HelpView = "hub" | "article";
40
39
  type HelpSession = {
41
40
  view: HelpView;
42
41
  query: string;
@@ -51,23 +50,8 @@ type HelpSection = {
51
50
  title: string;
52
51
  };
53
52
 
54
- const HELP_TOPICS_EVENT = "cloud:layout-help-topics";
55
53
  const LAST_TOPIC_KEY = "cloud.layoutHelp.activeTopic";
56
54
 
57
- declare global {
58
- interface Window {
59
- __cloudLayoutHelpTopics?: Map<string, HelpTopic>;
60
- __cloudLayoutHelpPageBase?: string;
61
- }
62
- }
63
-
64
- const registry = () => {
65
- if (typeof window === "undefined") return null;
66
- window.__cloudLayoutHelpTopics ??= new Map<string, HelpTopic>();
67
- return window.__cloudLayoutHelpTopics;
68
- };
69
-
70
- const emitTopicsChanged = () => window.dispatchEvent(new Event(HELP_TOPICS_EVENT));
71
55
  const iconClass = (icon?: string) => (icon?.startsWith("ti ") ? icon : `ti ${icon ?? "ti-circle"}`);
72
56
  const legacyTopicContent = (topic: HelpTopic) => (topic.kind === "content" ? topic.children : null);
73
57
  const sortedTopics = () =>
@@ -80,34 +64,7 @@ const mergeTopics = (registered: readonly HelpTopic[], documents: readonly HelpD
80
64
  return [...topics.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));
81
65
  };
82
66
 
83
- const registerTopic = (topic: HelpTopic) => {
84
- const topics = registry();
85
- if (!topics) return () => {};
86
- topics.set(topic.id, topic);
87
- emitTopicsChanged();
88
- return () => {
89
- if (topics.get(topic.id) !== topic) return;
90
- topics.delete(topic.id);
91
- emitTopicsChanged();
92
- };
93
- };
94
-
95
- /** Register an app's server-prepared Markdown manifest with the shared help UI. */
96
- export function LayoutHelpDocuments(props: LayoutHelpDocumentsProps) {
97
- onMount(() => {
98
- // Cleanup runs while this unmounts, when a parent `<Show>` may already have dropped the props' source.
99
- const pageBase = props.pageBase;
100
- window.__cloudLayoutHelpPageBase = pageBase;
101
- const disposers = props.documents.map((document) => registerTopic({ ...document, kind: "document" }));
102
- onCleanup(() => {
103
- disposers.forEach((dispose) => dispose());
104
- if (window.__cloudLayoutHelpPageBase === pageBase) delete window.__cloudLayoutHelpPageBase;
105
- });
106
- });
107
- return null;
108
- }
109
-
110
- const Shortcuts = (props: { openSearchHelp: () => void }) => {
67
+ const Shortcuts = (props: { focusSearch: () => void }) => {
111
68
  const locale = useLocale();
112
69
  const t = () => helpMessages.resolve([locale()]).t;
113
70
  const [commands, setCommands] = createSignal(contextCommandsWithShortcuts());
@@ -120,7 +77,7 @@ const Shortcuts = (props: { openSearchHelp: () => void }) => {
120
77
  return (
121
78
  <div class="flex flex-col gap-3">
122
79
  <p class="text-sm leading-relaxed text-dimmed">{t().shortcutsIntro}</p>
123
- <Button size="sm" variant="secondary" class="self-start" onClick={props.openSearchHelp}>
80
+ <Button size="sm" variant="secondary" class="self-start" onClick={props.focusSearch}>
124
81
  <i class="ti ti-search" /> {t().searchHelp}
125
82
  </Button>
126
83
  <div class="flex flex-col gap-2">
@@ -133,9 +90,7 @@ const Shortcuts = (props: { openSearchHelp: () => void }) => {
133
90
  <p class="mt-0.5 text-xs text-dimmed">{entry.description}</p>
134
91
  </Show>
135
92
  </div>
136
- <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">
137
- {shortcutLabel(entry.shortcut!)}
138
- </kbd>
93
+ <Kbd keys={entry.shortcut!} />
139
94
  </div>
140
95
  )}
141
96
  </For>
@@ -148,7 +103,6 @@ const HelpShell = (props: {
148
103
  session: HelpSession;
149
104
  close: () => void;
150
105
  pageBase?: string;
151
- searchHelpApps: GlobalSearchHelpApp[];
152
106
  documents?: readonly HelpDocumentManifest[];
153
107
  includeShortcuts?: boolean;
154
108
  accent?: string;
@@ -157,6 +111,8 @@ const HelpShell = (props: {
157
111
  }) => {
158
112
  const locale = useLocale();
159
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;
160
116
  const [externalTopics, setExternalTopics] = createSignal(mergeTopics(sortedTopics(), props.documents));
161
117
  const [view, setView] = createSignal<HelpView>(props.session.view);
162
118
  const [query, setQuery] = createSignal(props.session.query);
@@ -177,20 +133,45 @@ const HelpShell = (props: {
177
133
  let scrollArea: HTMLDivElement | undefined;
178
134
  let articleContent: HTMLDivElement | undefined;
179
135
  let articleHeading: HTMLHeadingElement | undefined;
136
+ let searchInput: HTMLInputElement | undefined;
137
+ let resultsRegion: HTMLDivElement | undefined;
138
+ const [resultsMinHeight, setResultsMinHeight] = createSignal<string>();
180
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() : "";
181
151
  let requestVersion = 0;
182
152
  let searchVersion = 0;
183
153
  let copyAllController: AbortController | undefined;
184
154
 
185
- 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;
186
165
  requestAnimationFrame(() => {
187
166
  if (scrollArea && view() === "article") scrollArea.scrollTop = props.session.articleScrollTop;
188
167
  });
168
+ };
189
169
 
190
- const syncActiveSection = () => {
170
+ const syncActiveSection = (scroller = articleScroller()) => {
191
171
  const sections = articleSections();
192
- if (!scrollArea || sections.length === 0) return;
193
- 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);
194
175
  let active = sections[0];
195
176
  for (const section of sections) {
196
177
  if (section.element.getBoundingClientRect().top <= readingLine) active = section;
@@ -199,6 +180,16 @@ const HelpShell = (props: {
199
180
  setActiveSectionId(active?.id ?? null);
200
181
  };
201
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
+
202
193
  const collectArticleSections = () => {
203
194
  const sections = Array.from(articleContent?.querySelectorAll<HTMLHeadingElement>("h2[id]") ?? []).map((element) => ({
204
195
  element,
@@ -208,15 +199,25 @@ const HelpShell = (props: {
208
199
  }));
209
200
  setArticleSections(sections);
210
201
  setActiveSectionId(sections[0]?.id ?? null);
211
- requestAnimationFrame(syncActiveSection);
202
+ const openedSection = pendingHash ? scrollToHash(pendingHash) : false;
203
+ pendingHash = "";
204
+ pendingNavigation = false;
205
+ if (!openedSection) syncActiveSection();
212
206
  };
213
207
 
214
208
  const openArticleSection = (section: HelpSection) => {
215
- setActiveSectionId(section.id);
216
- section.element.scrollIntoView({
217
- behavior: window.matchMedia("(prefers-reduced-motion: reduce)").matches ? "auto" : "smooth",
218
- block: "start",
219
- });
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 }));
220
221
  };
221
222
 
222
223
  const shortcutsTopic = createMemo<HelpTopic>(() => ({
@@ -226,7 +227,7 @@ const HelpShell = (props: {
226
227
  description: t().shortcutsDescription,
227
228
  order: 0,
228
229
  kind: "content",
229
- children: <Shortcuts openSearchHelp={() => openGlobalSearchHelpDialog(props.searchHelpApps, locale())} />,
230
+ children: <Shortcuts focusSearch={focusSearch} />,
230
231
  }));
231
232
  const topics = createMemo(() => (props.includeShortcuts === false ? externalTopics() : [shortcutsTopic(), ...externalTopics()]));
232
233
  const documentTopics = createMemo(() =>
@@ -265,8 +266,10 @@ const HelpShell = (props: {
265
266
  headers: { Accept: "application/json" },
266
267
  });
267
268
  if (!response.ok) throw new Error(t().searchFailed);
268
- const payload = (await response.json()) as Partial<HelpSearchPayload>;
269
- 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
+ : [];
270
273
  }),
271
274
  )
272
275
  .then((groups) => {
@@ -295,7 +298,13 @@ const HelpShell = (props: {
295
298
  setActiveSectionId(null);
296
299
  return;
297
300
  }
298
- 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
+ });
299
308
  onCleanup(() => cancelAnimationFrame(frame));
300
309
  });
301
310
 
@@ -327,6 +336,35 @@ const HelpShell = (props: {
327
336
  update();
328
337
  onCleanup(() => window.removeEventListener(HELP_TOPICS_EVENT, update));
329
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
+ });
330
368
  });
331
369
  onCleanup(() => copyAllController?.abort());
332
370
 
@@ -337,18 +375,30 @@ const HelpShell = (props: {
337
375
 
338
376
  const response = await fetch(topic.url, { signal, headers: { Accept: "application/json" } });
339
377
  if (!response.ok) throw new Error(t().requestFailed);
340
- const value = (await response.json()) as Partial<HelpDocumentPayload>;
378
+ const value: unknown = await response.json();
341
379
  if (
380
+ !value ||
381
+ typeof value !== "object" ||
382
+ !("id" in value) ||
342
383
  value.id !== topic.id ||
343
- (value.locale !== undefined && typeof value.locale !== "string") ||
344
- (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) ||
345
389
  typeof value.html !== "string" ||
346
390
  typeof value.markdown !== "string" ||
347
391
  typeof value.title !== "string"
348
392
  ) {
349
393
  throw new Error(t().invalidDocument);
350
394
  }
351
- 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
+ };
352
402
  props.session.articleCache.set(cacheKey, document);
353
403
  return document;
354
404
  };
@@ -424,44 +474,79 @@ const HelpShell = (props: {
424
474
  }
425
475
  };
426
476
 
427
- const openTopic = (id: string) => {
428
- resetHelpArticleScroll(props.session, scrollArea);
429
- pendingArticleFocus = true;
430
- setActiveId(id);
431
- setView("article");
432
- if (props.syncPageUrl) {
433
- 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();
434
481
  }
482
+ };
483
+ const openTopic = (id: string, hash = "", push = true) => {
484
+ if (push) pushHelpUrl(id, hash);
435
485
  try {
436
486
  localStorage.setItem(LAST_TOPIC_KEY, id);
437
487
  } catch {
438
488
  /* Help does not depend on storage. */
439
489
  }
440
- };
441
- const goBack = () => {
442
- setView(query().trim() ? "search" : "hub");
443
- if (props.syncPageUrl) {
444
- 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;
445
494
  }
495
+ resetHelpArticleScroll(props.session, articleScroller() ?? undefined);
496
+ pendingArticleFocus = true;
497
+ pendingNavigation = true;
498
+ pendingHash = hash;
499
+ setActiveId(id);
500
+ setView("article");
446
501
  };
447
- const showHub = () => {
502
+ const showHub = (push = true) => {
448
503
  setQuery("");
504
+ setResultsMinHeight(undefined);
449
505
  setRemoteIds(null);
450
506
  setView("hub");
507
+ if (push) pushHelpUrl(null);
451
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
+
452
540
  const modalTitle = createMemo(() => {
453
541
  if (view() === "article") return activeTopic()?.title ?? t().help;
454
- if (view() === "search") return t().searchHelp;
455
542
  return t().help;
456
543
  });
457
544
  const modalDescription = createMemo(() => {
458
545
  if (view() === "article") return activeTopic()?.description ?? t().description;
459
- if (view() === "search") return t().searchDescription;
460
546
  return t().description;
461
547
  });
462
548
  const modalIcon = createMemo(() => {
463
549
  if (view() === "article") return iconClass(activeTopic()?.icon);
464
- if (view() === "search") return "ti ti-search";
465
550
  return "ti ti-help";
466
551
  });
467
552
 
@@ -527,14 +612,23 @@ const HelpShell = (props: {
527
612
  </Show>
528
613
  );
529
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
+
530
624
  return (
531
625
  <div
532
626
  ref={root}
533
- 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)]"} ${
534
628
  props.surface === "modal"
535
629
  ? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
536
630
  : props.surface === "page"
537
- ? "min-h-dvh"
631
+ ? ""
538
632
  : "h-full"
539
633
  }`}
540
634
  style={appAccentStyle(props.accent)}
@@ -542,12 +636,7 @@ const HelpShell = (props: {
542
636
  <Show when={props.surface === "modal"}>
543
637
  <header class="flex min-h-16 shrink-0 items-center gap-2 px-5 py-3">
544
638
  <Show when={view() !== "hub"}>
545
- <IconButton
546
- class="shrink-0"
547
- label={view() === "article" ? t().backToTopics : t().backToHelp}
548
- title={t().back}
549
- onClick={() => (view() === "article" ? goBack() : showHub())}
550
- >
639
+ <IconButton class="shrink-0" label={t().backToTopics} title={t().back} onClick={goBack}>
551
640
  <i class="ti ti-arrow-left" />
552
641
  </IconButton>
553
642
  </Show>
@@ -620,16 +709,7 @@ const HelpShell = (props: {
620
709
  </header>
621
710
  </Show>
622
711
 
623
- <ScrollArea
624
- ref={scrollArea}
625
- class="min-h-0 flex-1 p-4 sm:p-5"
626
- onScroll={(event) => {
627
- if (view() === "article") {
628
- props.session.articleScrollTop = event.currentTarget.scrollTop;
629
- syncActiveSection();
630
- }
631
- }}
632
- >
712
+ <ReaderContent>
633
713
  <Show when={view() === "hub"}>
634
714
  <div class="mx-auto flex max-w-3xl flex-col gap-6">
635
715
  <div class="flex flex-col items-start justify-between gap-3 sm:flex-row">
@@ -651,70 +731,70 @@ const HelpShell = (props: {
651
731
  </p>
652
732
  )}
653
733
  </Show>
654
- <label class="field flex h-11 items-center gap-2 px-3">
655
- <i class="ti ti-search text-dimmed" />
656
- <input
657
- class="min-w-0 flex-1 bg-transparent text-sm outline-none"
658
- value={query()}
659
- placeholder={t().searchPlaceholder}
660
- onInput={(event) => {
661
- setQuery(event.currentTarget.value);
662
- if (event.currentTarget.value.trim()) setView("search");
663
- }}
664
- />
665
- </label>
666
- <section aria-labelledby="help-start-title">
667
- <h4 id="help-start-title" class="mb-2 text-xs font-semibold uppercase tracking-wide text-dimmed">
668
- {t().startHere}
669
- </h4>
670
- <TopicList items={topics().slice(0, 5)} />
671
- </section>
672
- <Show when={topics().length > 5}>
673
- <section aria-labelledby="help-all-title">
674
- <h4 id="help-all-title" class="mb-2 text-xs font-semibold uppercase tracking-wide text-dimmed">
675
- {t().allTopics}
676
- </h4>
677
- <TopicList items={topics().slice(5)} />
678
- </section>
679
- </Show>
680
- </div>
681
- </Show>
682
-
683
- <Show when={view() === "search"}>
684
- <div class="mx-auto flex max-w-3xl flex-col gap-4">
685
- <div class="flex items-center gap-2">
686
- <Show when={props.surface !== "modal"}>
687
- <IconButton label={t().backToHelp} onClick={showHub}>
688
- <i class="ti ti-arrow-left" />
689
- </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>
690
790
  </Show>
691
- <label class="field flex h-11 flex-1 items-center gap-2 px-3">
692
- <i class="ti ti-search text-dimmed" />
693
- <input
694
- autofocus
695
- class="min-w-0 flex-1 bg-transparent text-sm outline-none"
696
- value={query()}
697
- placeholder={t().searchPlaceholder}
698
- onInput={(event) => setQuery(event.currentTarget.value)}
699
- />
700
- </label>
701
791
  </div>
702
- <p class="flex items-center gap-1.5 text-xs text-dimmed" aria-live="polite">
703
- <Show when={searching()}>
704
- <i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
705
- </Show>
706
- {t().results({ count: results().length })}
707
- </p>
708
- <TopicList items={results()} />
709
- <Show when={results().length === 0 && !searching()}>
710
- <Placeholder icon="ti ti-search-off" title={t().noResults} />
711
- </Show>
712
792
  </div>
713
793
  </Show>
714
794
 
715
795
  <Show when={view() === "article" && activeTopic()}>
716
796
  {(topic) => (
717
- <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">
718
798
  <Show when={props.surface !== "modal"}>
719
799
  <button
720
800
  type="button"
@@ -750,54 +830,63 @@ const HelpShell = (props: {
750
830
  </Show>
751
831
  <Show when={topic().kind === "content"}>{legacyTopicContent(topic())}</Show>
752
832
  <Show when={topic().kind === "document"}>
753
- <Show when={loading()}>
754
- <Placeholder state="loading" variant="panel" title={t().loading} />
755
- </Show>
756
- <Show when={loadError()}>
757
- {(message) => (
758
- <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
759
- <Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
760
- {t().tryAgain}
761
- </Button>
762
- </NoticeCard>
763
- )}
764
- </Show>
765
- <Show when={payload()}>
766
- {(document) => (
767
- <div class="help-article-layout">
768
- <Show when={articleSections().length > 0}>
769
- <nav class="help-article-toc" aria-label={t().onThisPage}>
770
- <p class="help-article-toc-label">{t().onThisPage}</p>
771
- <ol>
772
- <For each={articleSections()}>
773
- {(section) => (
774
- <li>
775
- <button
776
- type="button"
777
- aria-current={activeSectionId() === section.id ? "location" : undefined}
778
- title={section.title}
779
- onClick={() => openArticleSection(section)}
780
- >
781
- <span class="help-article-toc-icon" aria-hidden="true">
782
- <i class={section.icon} />
783
- </span>
784
- <span class="help-article-toc-title">{section.title}</span>
785
- </button>
786
- </li>
787
- )}
788
- </For>
789
- </ol>
790
- </nav>
791
- </Show>
792
- <div class="help-article-copy">
793
- <div ref={articleContent}>
794
- <MarkdownView trustedHtml={document().html} class="help-document" />
795
- </div>
796
- <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} />
797
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>
798
884
  </div>
799
- )}
800
- </Show>
885
+ <Show when={payload()}>
886
+ <TopicNavigation />
887
+ </Show>
888
+ </div>
889
+ </div>
801
890
  </Show>
802
891
  </article>
803
892
  )}
@@ -805,7 +894,7 @@ const HelpShell = (props: {
805
894
  <span class="sr-only" aria-live="polite">
806
895
  {articleClipboard.wasCopied() ? t().articleCopiedAnnouncement : allHelpClipboard.wasCopied() ? t().allCopiedAnnouncement : ""}
807
896
  </span>
808
- </ScrollArea>
897
+ </ReaderContent>
809
898
  </div>
810
899
  );
811
900
  };
@@ -839,7 +928,6 @@ export function LayoutHelpPage(props: LayoutHelpPageProps) {
839
928
  <HelpShell
840
929
  session={session}
841
930
  close={() => {}}
842
- searchHelpApps={[]}
843
931
  documents={props.documents}
844
932
  includeShortcuts={props.includeShortcuts}
845
933
  accent={props.accent}
@@ -850,19 +938,12 @@ export function LayoutHelpPage(props: LayoutHelpPageProps) {
850
938
  );
851
939
  }
852
940
 
853
- 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() {
854
943
  const session = createSession();
944
+ const accent = readShellData()?.accent;
855
945
  void prompts.dialog<void>(
856
- (close) => (
857
- <HelpShell
858
- session={session}
859
- close={close}
860
- searchHelpApps={searchHelpApps}
861
- accent={accent}
862
- surface="modal"
863
- pageBase={window.__cloudLayoutHelpPageBase}
864
- />
865
- ),
946
+ (close) => <HelpShell session={session} close={close} accent={accent} surface="modal" pageBase={window.__cloudLayoutHelpPageBase} />,
866
947
  { surface: "bare", header: false, size: "wide" },
867
948
  );
868
949
  }