@k2b/cloud 0.37.0 → 0.39.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 (149) hide show
  1. package/package.json +8 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/help-catalog.ts +123 -4
  9. package/src/_internal/help.ts +27 -6
  10. package/src/_internal/panel-cookie.ts +39 -0
  11. package/src/_internal/registry-validation.ts +21 -26
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  14. package/src/access/PermissionEditor.tsx +30 -5
  15. package/src/access/ResourceApiKeys.tsx +5 -1
  16. package/src/account/Avatar.tsx +20 -2
  17. package/src/account/ui.ts +1 -1
  18. package/src/ai/approval-routes.ts +5 -2
  19. package/src/ai/approvals.ts +139 -28
  20. package/src/ai/browser.ts +11 -0
  21. package/src/ai/chart-block.ts +44 -13
  22. package/src/ai/chat/action-sentences.ts +1 -44
  23. package/src/ai/chat/chart-block.tsx +2 -11
  24. package/src/ai/chat/messages.ts +10 -0
  25. package/src/ai/chat/turn-view.tsx +35 -3
  26. package/src/ai/chat/view-block.tsx +7 -2
  27. package/src/ai/chat/web-tools.tsx +23 -6
  28. package/src/ai/chat-task-contracts.ts +28 -3
  29. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  30. package/src/ai/chat-task-reasons.ts +92 -0
  31. package/src/ai/chat-tasks.ts +460 -66
  32. package/src/ai/default-tool-contracts.ts +30 -13
  33. package/src/ai/fetch-file-tool.ts +37 -3
  34. package/src/ai/index.ts +12 -0
  35. package/src/ai/migrate.ts +46 -3
  36. package/src/ai/readable-html.ts +1057 -0
  37. package/src/ai/request-context.ts +7 -1
  38. package/src/ai/routes.ts +27 -1
  39. package/src/ai/runtime.ts +12 -27
  40. package/src/ai/store.ts +5 -3
  41. package/src/ai/timeline.ts +9 -1
  42. package/src/ai/tool-wording.ts +45 -0
  43. package/src/ai/turn-abort.ts +33 -0
  44. package/src/ai/types.ts +12 -1
  45. package/src/api/admin-core-settings.ts +190 -139
  46. package/src/api/admin-outgoing-mail.ts +4 -2
  47. package/src/api/app-approval.ts +9 -2
  48. package/src/api/approvals.ts +1 -1
  49. package/src/api/help.ts +23 -4
  50. package/src/api/index.ts +2 -1
  51. package/src/api/mcp.ts +8 -2
  52. package/src/browser/CloudResourceSearch.tsx +10 -6
  53. package/src/browser/FileChooser.tsx +11 -3
  54. package/src/browser/app-approval.ts +7 -2
  55. package/src/browser/choose-files.browser-harness.tsx +4 -1
  56. package/src/browser/composer-catalog.ts +103 -0
  57. package/src/browser/composer-insert-messages.ts +31 -0
  58. package/src/browser/composer-insert.ts +150 -0
  59. package/src/browser/file-chooser-messages.ts +4 -0
  60. package/src/browser/file-providers.ts +14 -1
  61. package/src/browser/panel-messages.ts +24 -0
  62. package/src/browser/panel-slot.ts +354 -0
  63. package/src/browser/panel.ts +28 -0
  64. package/src/browser/provider-folder.tsx +58 -5
  65. package/src/browser/resource-picker.tsx +17 -2
  66. package/src/cli/admin/workflows.ts +1 -0
  67. package/src/config/env.ts +14 -0
  68. package/src/contracts/app-approval.ts +49 -9
  69. package/src/contracts/app.ts +14 -0
  70. package/src/contracts/file-provider.ts +48 -1
  71. package/src/contracts/outgoing-mail.ts +8 -1
  72. package/src/contracts/registry.ts +3 -1
  73. package/src/contracts/shared.ts +2 -0
  74. package/src/server/help.ts +136 -10
  75. package/src/server/services/access.ts +5 -1
  76. package/src/server/services/geo.ts +21 -4
  77. package/src/services/accounts/posix.ts +2 -1
  78. package/src/services/allowed-network-fetch.ts +189 -0
  79. package/src/services/app-approval.ts +158 -38
  80. package/src/services/approvals/device.ts +2 -0
  81. package/src/services/help/index.ts +46 -5
  82. package/src/services/help/store.ts +26 -5
  83. package/src/services/help/types.ts +10 -2
  84. package/src/services/index.ts +22 -3
  85. package/src/services/notifications/browser.ts +16 -5
  86. package/src/services/outgoing-mail/dispatcher.ts +7 -2
  87. package/src/services/outgoing-mail/resolve-host.ts +39 -0
  88. package/src/services/outgoing-mail/test-send.ts +18 -3
  89. package/src/services/outgoing-mail/transport.ts +24 -13
  90. package/src/services/pdf/image-protocol.ts +135 -0
  91. package/src/services/pdf/image-variants.ts +124 -0
  92. package/src/services/pdf/image-worker.ts +31 -0
  93. package/src/services/pdf/image.ts +109 -0
  94. package/src/services/pdf/index.ts +9 -0
  95. package/src/services/settings/api.ts +11 -6
  96. package/src/services/settings/app.ts +5 -4
  97. package/src/services/settings/core-settings.ts +4 -3
  98. package/src/services/settings/index.ts +67 -43
  99. package/src/services/settings/namespace.ts +2 -1
  100. package/src/services/settings/store.ts +83 -11
  101. package/src/shared/ai-platform-prompt.ts +1 -1
  102. package/src/shared/app-presentation.ts +1 -0
  103. package/src/shared/help.ts +41 -0
  104. package/src/shared/index.ts +9 -1
  105. package/src/shared/markdown/extensions/guided-help.ts +208 -64
  106. package/src/shared/markdown/formula.ts +8 -8
  107. package/src/shared/markdown/index.ts +72 -11
  108. package/src/shared/redirect.ts +12 -0
  109. package/src/ssr/CloudShell.island.tsx +86 -9
  110. package/src/ssr/Layout.tsx +62 -11
  111. package/src/ssr/LayoutHeader.tsx +15 -9
  112. package/src/ssr/LayoutHelp.browser-harness.tsx +130 -27
  113. package/src/ssr/LayoutHelp.tsx +569 -410
  114. package/src/ssr/LayoutRail.tsx +2 -2
  115. package/src/ssr/LayoutTabBar.tsx +29 -0
  116. package/src/ssr/PanelLayout.tsx +27 -0
  117. package/src/ssr/PanelRuntime.island.tsx +87 -0
  118. package/src/ssr/PanelSlot.tsx +108 -0
  119. package/src/ssr/PanelTrigger.tsx +49 -0
  120. package/src/ssr/ShellRuntime.island.tsx +72 -25
  121. package/src/ssr/app-appearance.ts +3 -2
  122. package/src/ssr/app-color.ts +100 -0
  123. package/src/ssr/app-launchpad.tsx +18 -1
  124. package/src/ssr/app-navigation.ts +15 -0
  125. package/src/ssr/compact-shell.baseline.json +5270 -0
  126. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  127. package/src/ssr/help-messages.ts +60 -38
  128. package/src/ssr/help.ts +3 -0
  129. package/src/ssr/index.ts +3 -0
  130. package/src/ssr/layout-help-article.ts +41 -0
  131. package/src/ssr/layout-help-navigation.ts +0 -12
  132. package/src/ssr/layout-help-registry.tsx +4 -11
  133. package/src/ssr/layout-help-search.ts +9 -12
  134. package/src/ssr/layout-help-start.ts +68 -0
  135. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  136. package/src/ssr/platform-messages.ts +34 -0
  137. package/src/ssr/rail-navigation.ts +44 -0
  138. package/src/ssr/shell-data.tsx +11 -0
  139. package/src/ssr/shell-shortcuts.ts +19 -0
  140. package/src/styles/effects.css +689 -185
  141. package/src/styles/global.css +269 -55
  142. package/src/styles/panel.css +373 -0
  143. package/src/styles/resource-search.css +25 -0
  144. package/src/styles/tokens.css +23 -6
  145. package/src/styles/utilities-navigation.css +5 -21
  146. package/src/workflows/store/events.ts +24 -0
  147. package/src/workflows/store/index.ts +8 -0
  148. package/src/workflows/store/observability.ts +6 -1
  149. package/src/workflows/store/retention.ts +157 -0
@@ -1,9 +1,9 @@
1
- import type { HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
1
+ import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
2
2
  import { clipboard } from "@k2b/stdlib/solid";
3
3
  import {
4
4
  Button,
5
+ Dropdown,
5
6
  IconButton,
6
- IconButtonLink,
7
7
  Kbd,
8
8
  MarkdownView,
9
9
  NoticeCard,
@@ -11,19 +11,32 @@ import {
11
11
  prefersReducedMotion,
12
12
  prompts,
13
13
  ScrollArea,
14
+ TableOfContents,
14
15
  TextInput,
16
+ toast,
15
17
  useLocale,
16
18
  } from "@k2b/ui";
17
19
  import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
18
20
  import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "../browser/command-shortcuts";
21
+ import { HELP_ACCESS_LEVELS, HELP_KINDS } from "../shared/help";
19
22
  import { appAccentStyle } from "./app-appearance";
20
23
  import { helpMessages } from "./help-messages";
24
+ import { helpArticleParts } from "./layout-help-article";
21
25
  import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-help-markdown";
22
- import { adjacentHelpDocuments, focusHelpArticleHeading, resetHelpArticleScroll } from "./layout-help-navigation";
26
+ import { focusHelpArticleHeading, resetHelpArticleScroll } from "./layout-help-navigation";
23
27
  import { HELP_TOPICS_EVENT, type HelpTopic, registry } from "./layout-help-registry";
24
28
  import { searchHelpTopics } from "./layout-help-search";
29
+ import {
30
+ type HelpShortcutCommand,
31
+ type HelpShortcutScope,
32
+ type HelpStartGroupKind,
33
+ helpShortcutGroups,
34
+ helpStart,
35
+ } from "./layout-help-start";
25
36
  import { helpLinkTarget, layoutHelpTopicHref } from "./layout-help-url";
37
+ import { platformMessages } from "./platform-messages";
26
38
  import { readShellData, useShellData } from "./shell-data";
39
+ import { shellShortcuts } from "./shell-shortcuts";
27
40
 
28
41
  export type LayoutHelpPageProps = {
29
42
  documents: readonly HelpDocumentManifest[];
@@ -44,59 +57,98 @@ type HelpSession = {
44
57
  articleScrollTop: number;
45
58
  articleCache: Map<string, HelpDocumentPayload>;
46
59
  };
47
- type HelpSection = {
48
- element: HTMLHeadingElement;
49
- icon: string;
50
- id: string;
51
- title: string;
52
- };
60
+ type HelpSection = { id: string; label: string };
53
61
 
54
62
  const LAST_TOPIC_KEY = "cloud.layoutHelp.activeTopic";
55
63
 
56
- const iconClass = (icon?: string) => (icon?.startsWith("ti ") ? icon : `ti ${icon ?? "ti-circle"}`);
57
- const legacyTopicContent = (topic: HelpTopic) => (topic.kind === "content" ? topic.children : null);
58
- const sortedTopics = () =>
59
- [...(registry()?.values() ?? [])].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));
60
- const documentTopics = (documents: readonly HelpDocumentManifest[] = []): HelpTopic[] =>
61
- documents.map((document) => ({ ...document, kind: "document" }));
64
+ const optionalString = (value: object, key: string): string | undefined => {
65
+ const field = Reflect.get(value, key);
66
+ return typeof field === "string" ? field : undefined;
67
+ };
68
+ /** A linked article from the Help API, or nothing when the server sent something else. */
69
+ const articleLink = (value: unknown): HelpArticleLink | undefined => {
70
+ if (!value || typeof value !== "object") return undefined;
71
+ const [appId, appName, documentId, title, url] = ["appId", "appName", "documentId", "title", "url"].map((key) =>
72
+ optionalString(value, key),
73
+ );
74
+ if (!appId || !appName || !documentId || !title || !url?.startsWith("/")) return undefined;
75
+ const [accent, description, icon] = ["accent", "description", "icon"].map((key) => optionalString(value, key));
76
+ return { appId, appName, accent, documentId, title, description, icon, url };
77
+ };
78
+
79
+ /** An article without its own icon gets a neutral page tile. */
80
+ const iconClass = (icon?: string) => (icon?.startsWith("ti ") ? icon : `ti ${icon ?? "ti-file-text"}`);
81
+ const byOrder = (a: HelpTopic, b: HelpTopic) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title);
82
+ const sortedTopics = () => [...(registry()?.values() ?? [])].sort(byOrder);
62
83
  const mergeTopics = (registered: readonly HelpTopic[], documents: readonly HelpDocumentManifest[] = []): HelpTopic[] => {
63
84
  const topics = new Map(registered.map((topic) => [topic.id, topic]));
64
- for (const topic of documentTopics(documents)) topics.set(topic.id, topic);
65
- return [...topics.values()].sort((a, b) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title));
85
+ for (const topic of documents) topics.set(topic.id, topic);
86
+ return [...topics.values()].sort(byOrder);
87
+ };
88
+ const GROUP_ICONS: Record<HelpStartGroupKind, string> = {
89
+ start: "ti ti-rocket",
90
+ reference: "ti ti-book-2",
91
+ troubleshoot: "ti ti-lifebuoy",
66
92
  };
67
93
 
68
- const Shortcuts = (props: { focusSearch: () => void }) => {
94
+ /**
95
+ * Keyboard shortcuts come last in the start. The browser knows the page's commands. On a Layout page (it has shell
96
+ * data) the shell always registers its own Search and Help shortcuts, so they show from the server render and every
97
+ * first client render on, also before the shell has registered them. The groups reserve their height and place in CSS,
98
+ * so the ones the browser adds fill space that is already there.
99
+ */
100
+ const StartShortcuts = (props: { appName?: string }) => {
69
101
  const locale = useLocale();
70
102
  const t = () => helpMessages.resolve([locale()]).t;
71
- const [commands, setCommands] = createSignal(contextCommandsWithShortcuts());
103
+ const shell = useShellData();
104
+ const seed: HelpShortcutCommand[] = shell ? shellShortcuts(platformMessages.resolve([locale()]).t, { panel: !!shell.panel }) : [];
105
+ const read = (): HelpShortcutCommand[] => {
106
+ const commands = contextCommandsWithShortcuts();
107
+ return [...seed.filter((command) => !commands.some((other) => other.id === command.id)), ...commands];
108
+ };
109
+ const [commands, setCommands] = createSignal<HelpShortcutCommand[]>(read());
72
110
  onMount(() => {
73
- const update = () => setCommands(contextCommandsWithShortcuts());
111
+ const update = () => setCommands(read());
112
+ update();
74
113
  window.addEventListener(COMMANDS_CHANGED, update);
75
114
  onCleanup(() => window.removeEventListener(COMMANDS_CHANGED, update));
76
115
  });
77
- const entries = createMemo(() => commands().filter((command) => command.shortcut));
116
+ const label = (scope: HelpShortcutScope) =>
117
+ scope === "everywhere"
118
+ ? t().shortcutsEverywhere
119
+ : scope === "selection"
120
+ ? t().shortcutsWithSelection
121
+ : props.appName
122
+ ? t().shortcutsInApp({ app: props.appName })
123
+ : t().shortcutsInThisApp;
78
124
  return (
79
- <div class="flex flex-col gap-3">
80
- <p class="text-sm leading-relaxed text-dimmed">{t().shortcutsIntro}</p>
81
- <Button size="sm" variant="secondary" class="self-start" onClick={props.focusSearch}>
82
- <i class="ti ti-search" /> {t().searchHelp}
83
- </Button>
84
- <div class="flex flex-col gap-2">
85
- <For each={entries()}>
86
- {(entry) => (
87
- <div class="flex items-start justify-between gap-4 rounded-[var(--ui-radius-surface)] bg-[var(--ui-surface-subtle)] px-3 py-2.5">
88
- <div class="min-w-0">
89
- <p class="text-sm font-medium text-primary">{entry.title}</p>
90
- <Show when={entry.description}>
91
- <p class="mt-0.5 text-xs text-dimmed">{entry.description}</p>
92
- </Show>
93
- </div>
94
- <Kbd keys={entry.shortcut!} />
95
- </div>
125
+ <section class="help-start-section help-start-shortcuts" data-help-shortcuts="" aria-labelledby="help-start-shortcuts-title">
126
+ <h3 id="help-start-shortcuts-title" class="help-start-heading">
127
+ <i class="ti ti-keyboard" aria-hidden="true" />
128
+ {t().keyboardShortcuts}
129
+ </h3>
130
+ <div class="help-shortcut-groups">
131
+ <For each={helpShortcutGroups(commands())}>
132
+ {(group) => (
133
+ <section class="help-shortcut-group" data-help-shortcut-group={group.scope} aria-label={label(group.scope)}>
134
+ <h4 class="help-shortcut-group-title" aria-hidden="true">
135
+ {label(group.scope)}
136
+ </h4>
137
+ <ul>
138
+ <For each={group.shortcuts}>
139
+ {(command) => (
140
+ <li class="help-shortcut" title={command.description}>
141
+ <span class="help-shortcut-title">{command.title}</span>
142
+ <Kbd keys={command.shortcut} platform={shell?.platform} />
143
+ </li>
144
+ )}
145
+ </For>
146
+ </ul>
147
+ </section>
96
148
  )}
97
149
  </For>
98
150
  </div>
99
- </div>
151
+ </section>
100
152
  );
101
153
  };
102
154
 
@@ -113,7 +165,11 @@ const HelpShell = (props: {
113
165
  const locale = useLocale();
114
166
  const t = () => helpMessages.resolve([locale()]).t;
115
167
  // Links to this app's Help, by its own route or Core's `/help/apps/<id>`, open in place.
116
- const appId = useShellData()?.appId;
168
+ const shell = useShellData();
169
+ const appId = shell?.appId;
170
+ const app = shell?.apps.find((candidate) => candidate.id === appId);
171
+ const appName = app?.label;
172
+ const accessLabel = (level: HelpAccessLevel) => ({ view: t().accessView, edit: t().accessEdit, manage: t().accessManage })[level];
117
173
  const [externalTopics, setExternalTopics] = createSignal(mergeTopics(sortedTopics(), props.documents));
118
174
  const [view, setView] = createSignal<HelpView>(props.session.view);
119
175
  const [query, setQuery] = createSignal(props.session.query);
@@ -124,12 +180,16 @@ const HelpShell = (props: {
124
180
  const [loadAttempt, setLoadAttempt] = createSignal(0);
125
181
  const [remoteIds, setRemoteIds] = createSignal<readonly string[] | null>(null);
126
182
  const [searching, setSearching] = createSignal(false);
183
+ const [searchFailed, setSearchFailed] = createSignal(false);
184
+ const [searchAttempt, setSearchAttempt] = createSignal(0);
127
185
  const [copyingAll, setCopyingAll] = createSignal(false);
128
- const [copyAllError, setCopyAllError] = createSignal<string | null>(null);
129
- const [articleSections, setArticleSections] = createSignal<HelpSection[]>([]);
130
186
  const [activeSectionId, setActiveSectionId] = createSignal<string | null>(null);
131
- const articleClipboard = clipboard.create(1800);
132
- const allHelpClipboard = clipboard.create(1800);
187
+ const [tocScrollRoot, setTocScrollRoot] = createSignal<HTMLElement>();
188
+ const writeText = (text: string) => navigator.clipboard.writeText(text);
189
+ const articleClipboard = clipboard.createWriter({ write: writeText, copiedFor: 1800 });
190
+ const allHelpClipboard = clipboard.createWriter({ write: writeText, copiedFor: 1800 });
191
+ /** The article a search started from; clearing the search returns to it. */
192
+ let searchReturnId: string | null = null;
133
193
  let root: HTMLDivElement | undefined;
134
194
  let scrollArea: HTMLDivElement | undefined;
135
195
  let articleContent: HTMLDivElement | undefined;
@@ -168,42 +228,32 @@ const HelpShell = (props: {
168
228
  });
169
229
  };
170
230
 
171
- const syncActiveSection = (scroller = articleScroller()) => {
172
- const sections = articleSections();
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);
176
- let active = sections[0];
177
- for (const section of sections) {
178
- if (section.element.getBoundingClientRect().top <= readingLine) active = section;
179
- else break;
180
- }
181
- setActiveSectionId(active?.id ?? null);
182
- };
183
-
184
231
  const scrollToHash = (hash: string) => {
185
232
  const heading = Array.from(
186
233
  articleContent?.querySelectorAll<HTMLHeadingElement>("h1[id], h2[id], h3[id], h4[id], h5[id], h6[id]") ?? [],
187
234
  ).find((element) => element.id === hash);
188
235
  if (!heading) return false;
236
+ // A link to a heading in collapsed details opens them, and any details around them, first.
237
+ for (let details = heading.closest("details"); details; details = details.parentElement?.closest("details") ?? null)
238
+ details.open = true;
189
239
  setActiveSectionId(hash);
190
240
  heading.scrollIntoView({ block: "start", behavior: prefersReducedMotion() ? "auto" : "smooth" });
191
241
  return true;
192
242
  };
193
243
 
194
- const collectArticleSections = () => {
195
- const sections = Array.from(articleContent?.querySelectorAll<HTMLHeadingElement>("h2[id]") ?? []).map((element) => ({
196
- element,
197
- icon: element.dataset.helpIcon ?? "ti ti-point",
198
- id: element.id,
199
- title: element.textContent?.trim() || t().section,
200
- }));
201
- setArticleSections(sections);
202
- setActiveSectionId(sections[0]?.id ?? null);
244
+ /** Once the article body is in the document: open the requested section, or start at the top. */
245
+ const syncTocScrollRoot = () => {
246
+ const scroller = articleScroller();
247
+ // The table of contents follows the window unless the reader scrolls in its own container.
248
+ setTocScrollRoot(scroller instanceof HTMLElement && scroller !== document.scrollingElement ? scroller : undefined);
249
+ };
250
+
251
+ const settleArticle = () => {
252
+ syncTocScrollRoot();
203
253
  const openedSection = pendingHash ? scrollToHash(pendingHash) : false;
204
254
  pendingHash = "";
205
255
  pendingNavigation = false;
206
- if (!openedSection) syncActiveSection();
256
+ if (!openedSection) setActiveSectionId(articleSections()[0]?.id ?? null);
207
257
  };
208
258
 
209
259
  const openArticleSection = (section: HelpSection) => {
@@ -216,26 +266,15 @@ const HelpShell = (props: {
216
266
  }
217
267
  };
218
268
 
219
- const focusSearch = () => {
220
- showHub();
221
- requestAnimationFrame(() => searchInput?.focus({ preventScroll: true }));
222
- };
223
-
224
- const shortcutsTopic = createMemo<HelpTopic>(() => ({
225
- id: "shortcuts",
226
- title: t().shortcuts,
227
- icon: "ti ti-keyboard",
228
- description: t().shortcutsDescription,
229
- order: 0,
230
- kind: "content",
231
- children: <Shortcuts focusSearch={focusSearch} />,
232
- }));
233
- const topics = createMemo(() => (props.includeShortcuts === false ? externalTopics() : [shortcutsTopic(), ...externalTopics()]));
234
- const documentTopics = createMemo(() =>
235
- externalTopics().filter((topic): topic is HelpDocumentManifest & { kind: "document" } => topic.kind === "document"),
236
- );
269
+ const topics = externalTopics;
270
+ const start = createMemo(() => helpStart(topics(), props.surface === "modal" ? shell?.help : undefined));
237
271
  const activeTopic = createMemo(() => topics().find((topic) => topic.id === activeId()) ?? null);
238
- const adjacentTopics = createMemo(() => adjacentHelpDocuments(documentTopics(), activeId()));
272
+ // Article bodies load in the browser only; the server renders the head and the loading state.
273
+ const articleParts = createMemo(() => {
274
+ const html = payload()?.html;
275
+ return html ? helpArticleParts(html) : null;
276
+ });
277
+ const articleSections = (): HelpSection[] => (view() === "article" ? articleParts()?.sections : undefined) ?? [];
239
278
  const normalizedQuery = createMemo(() => query().trim().toLocaleLowerCase());
240
279
  const results = createMemo(() => {
241
280
  const value = normalizedQuery();
@@ -245,16 +284,12 @@ const HelpShell = (props: {
245
284
 
246
285
  createEffect(() => {
247
286
  const value = normalizedQuery();
248
- const urls = [
249
- ...new Set(
250
- externalTopics()
251
- .filter((topic): topic is HelpDocumentManifest & { kind: "document" } => topic.kind === "document")
252
- .map((topic) => topic.searchUrl),
253
- ),
254
- ];
287
+ searchAttempt();
288
+ const urls = [...new Set(topics().map((topic) => topic.searchUrl))];
255
289
  const version = ++searchVersion;
256
290
  setRemoteIds(null);
257
291
  setSearching(false);
292
+ setSearchFailed(false);
258
293
  if (!value || urls.length === 0) return;
259
294
 
260
295
  const controller = new AbortController();
@@ -278,7 +313,8 @@ const HelpShell = (props: {
278
313
  })
279
314
  .catch((error) => {
280
315
  if (error instanceof DOMException && error.name === "AbortError") return;
281
- // Metadata matches stay usable if full-text search is temporarily unavailable.
316
+ // Metadata matches stay usable if full-text search is temporarily unavailable; the results say so.
317
+ if (version === searchVersion) setSearchFailed(true);
282
318
  })
283
319
  .finally(() => {
284
320
  if (version === searchVersion) setSearching(false);
@@ -295,7 +331,6 @@ const HelpShell = (props: {
295
331
  createEffect(() => {
296
332
  const html = payload()?.html;
297
333
  if (!html || view() !== "article") {
298
- setArticleSections([]);
299
334
  setActiveSectionId(null);
300
335
  return;
301
336
  }
@@ -304,7 +339,7 @@ const HelpShell = (props: {
304
339
  const scroller = articleScroller();
305
340
  if (scroller) scroller.scrollTop = 0;
306
341
  }
307
- collectArticleSections();
342
+ settleArticle();
308
343
  });
309
344
  onCleanup(() => cancelAnimationFrame(frame));
310
345
  });
@@ -328,27 +363,26 @@ const HelpShell = (props: {
328
363
 
329
364
  onMount(() => {
330
365
  const dialog = root?.closest("dialog");
331
- if (dialog) {
332
- dialog.setAttribute("aria-labelledby", "layout-help-title");
333
- dialog.setAttribute("aria-describedby", "layout-help-subtitle");
334
- }
366
+ dialog?.setAttribute("aria-labelledby", "layout-help-title");
335
367
  const update = () => setExternalTopics(mergeTopics(sortedTopics(), props.documents));
336
368
  window.addEventListener(HELP_TOPICS_EVENT, update);
337
369
  update();
338
370
  onCleanup(() => window.removeEventListener(HELP_TOPICS_EVENT, update));
339
371
  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);
372
+ // The dialog reopens at the article position it had.
373
+ const onScroll = () => {
374
+ if (view() === "article" && scrollArea) props.session.articleScrollTop = scrollArea.scrollTop;
375
+ };
376
+ if (props.surface === "modal") scrollArea?.addEventListener("scroll", onScroll, { passive: true });
377
+ // Layout's breakpoints move page scrolling between its main column and the window; the contents follow.
378
+ let resizeFrame: number | undefined;
379
+ const onResize = () => {
380
+ resizeFrame ??= requestAnimationFrame(() => {
381
+ resizeFrame = undefined;
382
+ if (view() === "article") syncTocScrollRoot();
349
383
  });
350
384
  };
351
- document.addEventListener("scroll", onScroll, { capture: true, passive: true });
385
+ if (props.surface === "page") window.addEventListener("resize", onResize);
352
386
  const onPopState = () => {
353
387
  const address = pageAddress();
354
388
  if (address === shownAddress) return;
@@ -362,9 +396,10 @@ const HelpShell = (props: {
362
396
  };
363
397
  if (props.syncPageUrl) window.addEventListener("popstate", onPopState);
364
398
  onCleanup(() => {
365
- document.removeEventListener("scroll", onScroll, true);
399
+ scrollArea?.removeEventListener("scroll", onScroll);
400
+ window.removeEventListener("resize", onResize);
401
+ if (resizeFrame !== undefined) cancelAnimationFrame(resizeFrame);
366
402
  window.removeEventListener("popstate", onPopState);
367
- if (scrollFrame !== undefined) cancelAnimationFrame(scrollFrame);
368
403
  });
369
404
  });
370
405
  onCleanup(() => copyAllController?.abort());
@@ -393,12 +428,21 @@ const HelpShell = (props: {
393
428
  ) {
394
429
  throw new Error(t().invalidDocument);
395
430
  }
431
+ const kind = Reflect.get(value, "kind");
432
+ const needs = Reflect.get(value, "needs");
433
+ const related = Reflect.get(value, "related");
434
+ const next = articleLink(Reflect.get(value, "next"));
396
435
  const document: HelpDocumentPayload = {
397
436
  id: topic.id,
398
437
  title: value.title,
399
438
  html: value.html,
400
439
  markdown: value.markdown,
401
440
  locale: "locale" in value && typeof value.locale === "string" ? value.locale : (topic.locale ?? "en"),
441
+ // A Core older than the app's Help reader sends no article facts; the article then shows none.
442
+ kind: HELP_KINDS.find((candidate) => candidate === kind) ?? "task",
443
+ needs: HELP_ACCESS_LEVELS.find((level) => level === needs),
444
+ ...(next ? { next } : {}),
445
+ related: Array.isArray(related) ? related.flatMap((link) => articleLink(link) ?? []) : [],
402
446
  };
403
447
  props.session.articleCache.set(cacheKey, document);
404
448
  return document;
@@ -411,7 +455,7 @@ const HelpShell = (props: {
411
455
  setPayload(null);
412
456
  setLoadError(null);
413
457
  setLoading(false);
414
- if (!topic || topic.kind !== "document" || view() !== "article") return;
458
+ if (!topic || view() !== "article") return;
415
459
  const cached = props.session.articleCache.get(`${topic.locale ?? "en"}:${topic.url}`);
416
460
  if (cached) {
417
461
  setPayload(cached);
@@ -441,20 +485,36 @@ const HelpShell = (props: {
441
485
  });
442
486
  });
443
487
 
444
- const copyArticle = (topic: HelpTopic, document: HelpDocumentPayload | null) => {
445
- if (topic.kind !== "document" || !document) return;
446
- return articleClipboard.copy(formatHelpDocumentMarkdown({ ...topic, markdown: document.markdown }));
488
+ /**
489
+ * Copies the open article. The article's own buttons show "Copied" in place; the menu closes on choice, so it confirms
490
+ * with a toast. A failed write always ends in an error toast with Try again.
491
+ */
492
+ const copyArticle = async (topic: HelpTopic, document: HelpDocumentPayload | null, confirm = false) => {
493
+ if (!document) return;
494
+ if (await articleClipboard.copy(formatHelpDocumentMarkdown({ ...topic, markdown: document.markdown }))) {
495
+ if (confirm) toast.success(t().articleCopied);
496
+ return;
497
+ }
498
+ toast.error(t().copyFailed({ message: articleClipboard.error()?.message ?? t().copyFallback }), {
499
+ action: { label: t().tryAgain, onClick: () => void copyArticle(topic, document, confirm) },
500
+ });
501
+ };
502
+
503
+ const copyFromMenu = async () => {
504
+ const topic = activeTopic();
505
+ if (view() === "article" && topic) await copyArticle(topic, payload(), true);
506
+ else await copyAllHelp();
447
507
  };
448
508
 
449
509
  const copyAllHelp = async () => {
450
- const manifests = documentTopics();
510
+ const manifests = topics();
451
511
  if (manifests.length === 0 || copyingAll()) return;
452
512
 
453
513
  copyAllController?.abort();
454
514
  const controller = new AbortController();
455
515
  copyAllController = controller;
456
516
  setCopyingAll(true);
457
- setCopyAllError(null);
517
+ const progress = toast(t().preparing, { progress: "indeterminate", duration: 0, iconClass: "ti ti-markdown" });
458
518
  try {
459
519
  const documents = await Promise.all(
460
520
  manifests.map(async (topic) => {
@@ -462,10 +522,16 @@ const HelpShell = (props: {
462
522
  return { ...topic, markdown: document.markdown };
463
523
  }),
464
524
  );
465
- await allHelpClipboard.copy(formatHelpBundleMarkdown(documents));
525
+ if (!(await allHelpClipboard.copy(formatHelpBundleMarkdown(documents))))
526
+ throw allHelpClipboard.error() ?? new Error(t().copyFallback);
527
+ progress.dismiss();
528
+ toast.success(t().helpCopied);
466
529
  } catch (error) {
530
+ progress.dismiss();
467
531
  if (!(error instanceof DOMException && error.name === "AbortError")) {
468
- setCopyAllError(error instanceof Error ? error.message : t().copyFallback);
532
+ toast.error(t().copyAllFailed({ message: error instanceof Error ? error.message : t().copyFallback }), {
533
+ action: { label: t().tryAgain, onClick: () => void copyAllHelp() },
534
+ });
469
535
  }
470
536
  } finally {
471
537
  if (copyAllController === controller) {
@@ -482,6 +548,7 @@ const HelpShell = (props: {
482
548
  }
483
549
  };
484
550
  const openTopic = (id: string, hash = "", push = true) => {
551
+ searchReturnId = null;
485
552
  if (push) pushHelpUrl(id, hash);
486
553
  try {
487
554
  localStorage.setItem(LAST_TOPIC_KEY, id);
@@ -501,6 +568,7 @@ const HelpShell = (props: {
501
568
  setView("article");
502
569
  };
503
570
  const showHub = (push = true) => {
571
+ searchReturnId = null;
504
572
  setQuery("");
505
573
  setResultsMinHeight(undefined);
506
574
  setRemoteIds(null);
@@ -508,15 +576,38 @@ const HelpShell = (props: {
508
576
  if (push) pushHelpUrl(null);
509
577
  };
510
578
  const goBack = () => {
579
+ searchReturnId = null;
511
580
  setView("hub");
512
581
  pushHelpUrl(null);
513
582
  };
583
+ /** The search stays in the head in every view. Typing in an article shows results; clearing the search returns to the article. */
584
+ const changeQuery = (value: string) => {
585
+ const searching = !!value.trim();
586
+ if (searching && view() === "article") {
587
+ searchReturnId = activeId();
588
+ setResultsMinHeight(undefined);
589
+ setView("hub");
590
+ } else if (searching && !query().trim()) {
591
+ // Results replace the start below the field; the area keeps its height so nothing moves.
592
+ setResultsMinHeight(`${resultsRegion?.offsetHeight ?? 0}px`);
593
+ }
594
+ setQuery(value);
595
+ if (searching) return;
596
+ setResultsMinHeight(undefined);
597
+ const returnId = searchReturnId;
598
+ searchReturnId = null;
599
+ if (returnId && topics().some((topic) => topic.id === returnId)) {
600
+ setActiveId(returnId);
601
+ setView("article");
602
+ }
603
+ };
514
604
 
605
+ /** Links in the article, its table of contents, and its end open Help targets of this app in place. */
515
606
  const followHelpLink = (event: MouseEvent) => {
516
607
  if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
517
- if (!(event.target instanceof Element)) return;
608
+ if (!(event.target instanceof Element) || !(event.currentTarget instanceof Element)) return;
518
609
  const anchor = event.target.closest("a");
519
- if (!anchor || !articleContent?.contains(anchor) || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download"))
610
+ if (!anchor || !event.currentTarget.contains(anchor) || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download"))
520
611
  return;
521
612
  const target = helpLinkTarget(anchor.getAttribute("href") ?? "", location.href, { appId, pageBase: props.pageBase });
522
613
  if (!target) return;
@@ -538,78 +629,96 @@ const HelpShell = (props: {
538
629
  }
539
630
  };
540
631
 
541
- const modalTitle = createMemo(() => {
542
- if (view() === "article") return activeTopic()?.title ?? t().help;
543
- return t().help;
544
- });
545
- const modalDescription = createMemo(() => {
546
- if (view() === "article") return activeTopic()?.description ?? t().description;
547
- return t().description;
548
- });
549
- const modalIcon = createMemo(() => {
550
- if (view() === "article") return iconClass(activeTopic()?.icon);
551
- return "ti ti-help";
552
- });
553
-
554
- const TopicList = (listProps: { items: HelpTopic[] }) => (
555
- <div class="flex flex-col gap-1 rounded-[var(--ui-radius-surface)] bg-[var(--ui-surface-subtle)] p-1.5">
632
+ /** Rows: icon tile, title, and description; the start's groups, its further tasks, and search results use them. */
633
+ const TopicList = (listProps: { items: HelpTopic[]; marker?: string }) => (
634
+ <ul class="help-start-rows" {...(listProps.marker ? { [listProps.marker]: "" } : {})}>
556
635
  <For each={listProps.items}>
557
636
  {(topic) => (
558
- <button
559
- type="button"
560
- class="group flex w-full items-center gap-3 rounded-[var(--ui-radius-control)] px-3 py-2.5 text-left hover:bg-[var(--ui-surface-raised)] focus-ui"
561
- onClick={() => openTopic(topic.id)}
562
- >
563
- <span class="help-topic-icon flex h-8 w-8 shrink-0 items-center justify-center rounded-[var(--ui-radius-control)] app-accent-text">
564
- <i class={`${iconClass(topic.icon)} text-base`} />
565
- </span>
566
- <span class="min-w-0 flex-1">
567
- <span class="block truncate text-sm font-medium text-primary group-hover:app-accent-text">{topic.title}</span>
568
- <Show when={topic.description}>
569
- {(description) => <span class="mt-0.5 block truncate text-xs text-dimmed">{description()}</span>}
570
- </Show>
571
- </span>
572
- <i class="ti ti-chevron-right shrink-0 text-xs text-dimmed" />
573
- </button>
637
+ <li>
638
+ <button type="button" class="help-start-row focus-ui" data-help-topic={topic.id} onClick={() => openTopic(topic.id)}>
639
+ <span class="help-topic-icon help-start-row-icon app-accent-text" aria-hidden="true">
640
+ <i class={iconClass(topic.icon)} />
641
+ </span>
642
+ <span class="help-article-link-copy">
643
+ <span class="help-article-link-title">{topic.title}</span>
644
+ <Show when={topic.description}>{(description) => <span class="help-article-link-description">{description()}</span>}</Show>
645
+ </span>
646
+ <i class="ti ti-chevron-right help-start-row-arrow" aria-hidden="true" />
647
+ </button>
648
+ </li>
574
649
  )}
575
650
  </For>
576
- </div>
651
+ </ul>
577
652
  );
578
653
 
579
- const TopicNavigation = () => (
580
- <Show when={adjacentTopics().previous || adjacentTopics().next}>
581
- <nav class="mt-12 grid gap-3 sm:grid-cols-2" aria-label={t().topicNavigation}>
582
- <Show when={adjacentTopics().previous}>
583
- {(previous) => (
584
- <button
585
- type="button"
586
- class="group flex min-w-0 flex-col items-start gap-1 rounded-[var(--ui-radius-surface)] bg-[var(--ui-surface-subtle)] p-4 text-left transition-colors hover:bg-[var(--ui-surface-muted)] focus-ui"
587
- onClick={() => openTopic(previous().id)}
588
- >
589
- <span class="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-dimmed">
590
- <i class="ti ti-arrow-left" aria-hidden="true" />
591
- {t().previousTopic}
654
+ /** At most eight tasks as tiles; the app colour shows only in the icon. */
655
+ const TaskTiles = (tilesProps: { items: HelpTopic[] }) => (
656
+ <ul class="help-start-tiles" data-help-tiles="">
657
+ <For each={tilesProps.items}>
658
+ {(topic) => (
659
+ <li>
660
+ <button type="button" class="help-start-tile focus-ui" data-help-topic={topic.id} onClick={() => openTopic(topic.id)}>
661
+ <span class="help-topic-icon help-start-tile-icon app-accent-text" aria-hidden="true">
662
+ <i class={iconClass(topic.icon)} />
592
663
  </span>
593
- <span class="text-sm font-medium text-primary group-hover:app-accent-text">{previous().title}</span>
664
+ <span class="help-start-tile-title">{topic.title}</span>
665
+ <Show when={topic.description}>{(description) => <span class="help-start-tile-description">{description()}</span>}</Show>
594
666
  </button>
595
- )}
596
- </Show>
597
- <Show when={adjacentTopics().next}>
667
+ </li>
668
+ )}
669
+ </For>
670
+ </ul>
671
+ );
672
+
673
+ const groupTitle = (kind: HelpStartGroupKind) =>
674
+ ({ start: t().groupStart, reference: t().groupReference, troubleshoot: t().groupTroubleshoot })[kind];
675
+
676
+ const ArticleLinkTitle = (link: { link: HelpArticleLink }) => (
677
+ <span class="help-article-link-copy">
678
+ <span class="help-article-link-title">{link.link.title}</span>
679
+ <Show when={link.link.description}>{(description) => <span class="help-article-link-description">{description()}</span>}</Show>
680
+ </span>
681
+ );
682
+
683
+ /** "Next" leads on; related articles keep their own app's colour, and another app says whose article it is. */
684
+ const ArticleEnd = (end: { document: HelpDocumentPayload }) => (
685
+ <Show when={end.document.next || end.document.related.length > 0}>
686
+ <footer class="help-article-end">
687
+ <Show when={end.document.next}>
598
688
  {(next) => (
599
- <button
600
- type="button"
601
- class="group flex min-w-0 flex-col items-end gap-1 rounded-[var(--ui-radius-surface)] bg-[var(--ui-surface-subtle)] p-4 text-right transition-colors hover:bg-[var(--ui-surface-muted)] focus-ui sm:col-start-2"
602
- onClick={() => openTopic(next().id)}
603
- >
604
- <span class="flex items-center gap-1.5 text-[11px] font-semibold uppercase tracking-wide text-dimmed">
605
- {t().nextTopic}
606
- <i class="ti ti-arrow-right" aria-hidden="true" />
689
+ <a class="help-article-next app-accent-scope focus-ui" href={next().url} style={appAccentStyle(next().accent)}>
690
+ <span class="help-article-next-label">{t().next}</span>
691
+ <span class="help-article-next-row">
692
+ <i class={`${iconClass(next().icon)} help-article-next-icon`} aria-hidden="true" />
693
+ <ArticleLinkTitle link={next()} />
694
+ <i class="ti ti-arrow-right help-article-next-arrow" aria-hidden="true" />
607
695
  </span>
608
- <span class="text-sm font-medium text-primary group-hover:app-accent-text">{next().title}</span>
609
- </button>
696
+ </a>
610
697
  )}
611
698
  </Show>
612
- </nav>
699
+ <Show when={end.document.related.length > 0}>
700
+ <section class="help-article-related" aria-labelledby="help-article-related-title">
701
+ <h4 id="help-article-related-title">{t().related}</h4>
702
+ <ul>
703
+ <For each={end.document.related}>
704
+ {(link) => (
705
+ <li>
706
+ <a class="help-article-related-link app-accent-scope focus-ui" href={link.url} style={appAccentStyle(link.accent)}>
707
+ <span class="help-topic-icon help-article-related-icon app-accent-text" aria-hidden="true">
708
+ <i class={iconClass(link.icon)} />
709
+ </span>
710
+ <ArticleLinkTitle link={link} />
711
+ <Show when={link.appId !== appId}>
712
+ <span class="help-article-related-app app-accent-text">{link.appName}</span>
713
+ </Show>
714
+ </a>
715
+ </li>
716
+ )}
717
+ </For>
718
+ </ul>
719
+ </section>
720
+ </Show>
721
+ </footer>
613
722
  </Show>
614
723
  );
615
724
 
@@ -622,278 +731,328 @@ const HelpShell = (props: {
622
731
  </ScrollArea>
623
732
  );
624
733
 
734
+ const pageHref = () => (props.pageBase ? layoutHelpTopicHref(props.pageBase, view() === "article" ? activeId() : null) : undefined);
735
+ const menuItems = () => [
736
+ {
737
+ label: t().copyAsMarkdown,
738
+ icon: "ti ti-markdown",
739
+ ...(copyingAll() || (view() === "article" ? !payload() : topics().length === 0)
740
+ ? { disabled: true as const }
741
+ : { action: () => void copyFromMenu() }),
742
+ },
743
+ ...(props.surface === "modal" && pageHref() ? [{ label: t().openAsPage, icon: "ti ti-app-window", href: pageHref()! }] : []),
744
+ ];
745
+
625
746
  return (
626
747
  <div
627
748
  ref={root}
628
- class={`app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
749
+ class={`help-shell app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
629
750
  props.surface === "modal"
630
751
  ? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
631
752
  : props.surface === "page"
632
753
  ? ""
633
754
  : "h-full"
634
755
  }`}
756
+ data-surface={props.surface}
635
757
  style={appAccentStyle(props.accent)}
636
758
  >
637
- <Show when={props.surface === "modal"}>
638
- <header class="flex min-h-16 shrink-0 items-center gap-2 px-5 py-3">
639
- <Show when={view() !== "hub"}>
640
- <IconButton class="shrink-0" label={t().backToTopics} title={t().back} onClick={goBack}>
641
- <i class="ti ti-arrow-left" />
642
- </IconButton>
643
- </Show>
644
- <span class="help-topic-icon flex h-9 w-9 shrink-0 items-center justify-center rounded-[var(--ui-radius-control)] app-accent-text">
645
- <i class={`${modalIcon()} text-lg`} />
646
- </span>
647
- <div class="min-w-0 flex-1">
648
- <h2 ref={articleHeading} id="layout-help-title" tabindex="-1" class="truncate rounded-sm font-semibold text-primary focus-ui">
649
- {modalTitle()}
650
- </h2>
651
- <p id="layout-help-subtitle" class="truncate text-xs text-dimmed" title={modalDescription()}>
652
- {modalDescription()}
653
- </p>
654
- </div>
655
- <div class="flex shrink-0 items-center gap-1" role="group" aria-label={t().actions}>
656
- <Show when={view() === "hub" && documentTopics().length > 0}>
657
- <IconButton
658
- disabled={copyingAll()}
659
- label={allHelpClipboard.wasCopied() ? t().helpCopied : t().copyAllHelp}
660
- title={allHelpClipboard.wasCopied() ? t().copied : t().copyAllHelp}
661
- onClick={() => void copyAllHelp()}
662
- >
663
- <i class={copyingAll() ? "ti ti-loader-2 animate-spin" : allHelpClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
664
- </IconButton>
665
- </Show>
666
- <Show when={view() === "article" && activeTopic()?.kind === "document"}>
667
- <IconButton
668
- disabled={!payload()}
669
- label={articleClipboard.wasCopied() ? t().articleCopied : t().copyArticle}
670
- title={articleClipboard.wasCopied() ? t().copied : t().copyArticle}
671
- onClick={() => {
672
- const topic = activeTopic();
673
- if (topic) void copyArticle(topic, payload());
674
- }}
675
- >
676
- <i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
677
- </IconButton>
678
- </Show>
679
- <Show when={props.pageBase}>
680
- {(pageBase) => (
681
- <IconButtonLink
682
- href={layoutHelpTopicHref(pageBase(), view() === "article" ? activeId() : null)}
683
- target="_blank"
684
- rel="noopener noreferrer"
685
- label={t().openWindow}
686
- title={t().openFullPage}
687
- onClick={(event) => {
688
- if (event.button !== 0 || event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return;
689
-
690
- const popup = window.open(
691
- event.currentTarget.href,
692
- "cloud-layout-help",
693
- "popup,width=1120,height=820,resizable=yes,scrollbars=yes",
694
- );
695
- if (!popup) return;
696
-
697
- event.preventDefault();
698
- popup.opener = null;
699
- popup.focus();
700
- }}
759
+ {/* One head in every view: the search never moves between the start, an article, and results. An app's embedded
760
+ reader sits in the app's own chrome, which names it and picks its article, so it keeps only the search of its start. */}
761
+ <Show when={props.surface !== "embedded" || view() === "hub"}>
762
+ <header class="help-head" data-help-head="">
763
+ <div class="help-head-inner help-measure">
764
+ <Show when={props.surface !== "embedded"}>
765
+ <div class="help-head-row">
766
+ {/* The back button takes the app tile's place in an article, so nothing beside it moves. */}
767
+ <Show
768
+ when={view() === "article"}
769
+ fallback={
770
+ <span class="help-head-tile" aria-hidden="true">
771
+ <i class={app?.iconClass ?? "ti ti-help"} />
772
+ </span>
773
+ }
701
774
  >
702
- <i class="ti ti-app-window" />
703
- </IconButtonLink>
704
- )}
705
- </Show>
706
- <IconButton label={t().closeHelp} title={t().close} onClick={props.close}>
707
- <i class="ti ti-x" />
708
- </IconButton>
709
- </div>
710
- </header>
711
- </Show>
712
-
713
- <ReaderContent>
714
- <Show when={view() === "hub"}>
715
- <div class="mx-auto flex max-w-3xl flex-col gap-6">
716
- <div class="flex flex-col items-start justify-between gap-3 sm:flex-row">
717
- <div>
718
- <h3 class="text-xl font-semibold text-primary">{t().heading}</h3>
719
- <p class="mt-1 text-sm text-dimmed">{t().headingDescription}</p>
775
+ <IconButton class="help-head-back" label={t().backToTopics} title={t().back} onClick={goBack}>
776
+ <i class="ti ti-arrow-left" />
777
+ </IconButton>
778
+ </Show>
779
+ <h2 id="layout-help-title" class="help-head-title">
780
+ {appName ? t().helpFor({ app: appName }) : t().help}
781
+ </h2>
782
+ <div class="help-head-actions" role="group" aria-label={t().actions}>
783
+ <Dropdown.Root label={t().moreActions} position="bottom-left" items={menuItems()}>
784
+ <Dropdown.Trigger iconOnly label={t().moreActions} variant="ghost" data-help-menu="">
785
+ <i class="ti ti-dots" aria-hidden="true" />
786
+ </Dropdown.Trigger>
787
+ </Dropdown.Root>
788
+ <Show when={props.surface === "modal"}>
789
+ <IconButton label={t().closeHelp} title={t().close} onClick={props.close}>
790
+ <i class="ti ti-x" />
791
+ </IconButton>
792
+ </Show>
793
+ </div>
720
794
  </div>
721
- <Show when={props.surface !== "modal" && documentTopics().length > 0}>
722
- <Button size="sm" variant="secondary" class="shrink-0" disabled={copyingAll()} onClick={() => void copyAllHelp()}>
723
- <i class={copyingAll() ? "ti ti-loader-2 animate-spin" : allHelpClipboard.wasCopied() ? "ti ti-check" : "ti ti-copy"} />
724
- {copyingAll() ? t().preparing : allHelpClipboard.wasCopied() ? t().copied : t().copyAllShort}
725
- </Button>
726
- </Show>
727
- </div>
728
- <Show when={copyAllError()}>
729
- {(message) => (
730
- <p class="text-xs text-danger" role="alert">
731
- {t().copyAllFailed({ message: message() })}
732
- </p>
733
- )}
734
795
  </Show>
735
796
  <TextInput
736
797
  ref={searchInput}
737
798
  type="search"
799
+ class="help-head-search"
738
800
  icon="ti ti-search"
739
801
  aria-label={t().searchHelp}
740
802
  data-help-search=""
741
803
  value={query()}
742
804
  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
- }}
805
+ onValueChange={changeQuery}
749
806
  />
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)} />
807
+ </div>
808
+ </header>
809
+ </Show>
810
+
811
+ <ReaderContent>
812
+ <Show when={view() === "hub"}>
813
+ <div ref={resultsRegion} data-help-results="" class="help-start help-measure" style={{ "min-height": resultsMinHeight() }}>
814
+ <Show
815
+ when={normalizedQuery()}
816
+ fallback={
817
+ <>
818
+ <Show when={start().view.length > 0}>
819
+ <section class="help-start-section" data-help-view="" aria-labelledby="help-start-view-title">
820
+ <h3 id="help-start-view-title" class="help-start-heading">
821
+ <i class="ti ti-focus-2" aria-hidden="true" />
822
+ {t().forThisView}
823
+ </h3>
824
+ <TopicList items={start().view} />
760
825
  </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} />
826
+ </Show>
827
+ <Show when={start().tiles.length > 0}>
828
+ <section class="help-start-section" data-help-tasks="" aria-labelledby="help-start-tasks-title">
829
+ <h3 id="help-start-tasks-title" class="help-start-heading">
830
+ <i class="ti ti-checklist" aria-hidden="true" />
831
+ {t().whatToDo}
832
+ </h3>
833
+ <TaskTiles items={start().tiles} />
834
+ <Show when={start().moreTasks.length > 0}>
835
+ <TopicList items={start().moreTasks} marker="data-help-more-tasks" />
785
836
  </Show>
786
- }
787
- >
788
- <TopicList items={results()} />
837
+ </section>
838
+ </Show>
839
+ <Show when={start().groups.length > 0}>
840
+ <div class="help-start-groups">
841
+ <For each={start().groups}>
842
+ {(group) => (
843
+ <section
844
+ class="help-start-section"
845
+ data-help-group={group.kind}
846
+ aria-labelledby={`help-start-${group.kind}-title`}
847
+ >
848
+ <h3 id={`help-start-${group.kind}-title`} class="help-start-heading">
849
+ <i class={GROUP_ICONS[group.kind]} aria-hidden="true" />
850
+ {groupTitle(group.kind)}
851
+ </h3>
852
+ <TopicList items={group.topics} />
853
+ </section>
854
+ )}
855
+ </For>
856
+ </div>
857
+ </Show>
858
+ {/* An app without Help says so plainly; only a failed read of its Help offers a way to try again. */}
859
+ <Show when={topics().length === 0}>
860
+ <Show
861
+ when={shell?.helpFailed}
862
+ fallback={<Placeholder icon="ti ti-book-off" title={t().noArticles} description={t().noArticlesDetail} />}
863
+ >
864
+ <NoticeCard tone="danger" title={t().helpUnavailable} detail={t().helpUnavailableDetail} data-help-unavailable="">
865
+ <Button size="sm" variant="secondary" onClick={() => location.reload()}>
866
+ {t().reload}
867
+ </Button>
868
+ </NoticeCard>
869
+ </Show>
870
+ </Show>
871
+ <Show when={props.includeShortcuts !== false}>
872
+ <StartShortcuts appName={appName} />
873
+ </Show>
874
+ </>
875
+ }
876
+ >
877
+ {/* The count takes the place of the first heading, so the first row stays where it was. */}
878
+ <section class="help-start-section" data-help-search-results="">
879
+ {/* A failed full-text search says so in the same line, with a way to try again; the results do not move. */}
880
+ <div class="help-start-status">
881
+ <span aria-live="polite">{t().results({ count: results().length })}</span>
882
+ <Show when={searching()}>
883
+ <i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
884
+ </Show>
885
+ <Show when={searchFailed()}>
886
+ <span class="help-start-status-warning" data-help-search-failed="" title={t().searchTitlesOnly} role="status">
887
+ <i class="ti ti-alert-triangle" aria-hidden="true" />
888
+ <span aria-hidden="true">{t().titlesOnly}</span>
889
+ <span class="sr-only">{t().searchTitlesOnly}</span>
890
+ </span>
891
+ <Button
892
+ size="xs"
893
+ variant="ghost"
894
+ class="help-start-status-retry"
895
+ onClick={() => setSearchAttempt((value) => value + 1)}
896
+ >
897
+ {t().tryAgain}
898
+ </Button>
789
899
  </Show>
790
- </section>
791
- </Show>
792
- </div>
900
+ </div>
901
+ <Show
902
+ when={results().length > 0}
903
+ fallback={
904
+ <Show when={!searching()}>
905
+ <Placeholder icon="ti ti-search-off" title={t().noResults} />
906
+ </Show>
907
+ }
908
+ >
909
+ <TopicList items={results()} />
910
+ </Show>
911
+ </section>
912
+ </Show>
793
913
  </div>
794
914
  </Show>
795
915
 
796
916
  <Show when={view() === "article" && activeTopic()}>
797
917
  {(topic) => (
798
- <article class="help-article mx-auto flex min-h-full w-full max-w-7xl flex-col">
799
- <Show when={props.surface !== "modal"}>
800
- <button
801
- type="button"
802
- class="mb-6 inline-flex items-center gap-1.5 text-xs font-medium text-dimmed hover:app-accent-text focus-ui"
803
- onClick={goBack}
804
- >
805
- <i class="ti ti-arrow-left" /> {t().back}
806
- </button>
807
- <header class="mb-8 grid grid-cols-[auto_minmax(0,1fr)] items-start gap-x-3 gap-y-3 sm:grid-cols-[auto_minmax(0,1fr)_auto]">
808
- <span class="help-topic-icon flex h-10 w-10 shrink-0 items-center justify-center rounded-[var(--ui-radius-control)] app-accent-text">
809
- <i class={`${iconClass(topic().icon)} text-lg`} />
810
- </span>
811
- <div class="min-w-0 flex-1">
812
- <h3 ref={articleHeading} tabindex="-1" class="rounded-sm text-2xl font-semibold tracking-tight text-primary focus-ui">
918
+ <article
919
+ class="help-article"
920
+ data-surface={props.surface}
921
+ ref={(element) => {
922
+ // Delegated from the article: its body, table of contents, and end hold the links.
923
+ element.addEventListener("click", followHelpLink);
924
+ onCleanup(() => element.removeEventListener("click", followHelpLink));
925
+ }}
926
+ >
927
+ <div class="help-article-layout">
928
+ <div class="help-article-main">
929
+ <header class="help-article-head">
930
+ <p class="help-article-path">
931
+ <Show when={appName}>
932
+ {(name) => (
933
+ <>
934
+ <span class="app-accent-text">{name()}</span>
935
+ <i class="ti ti-chevron-right" aria-hidden="true" />
936
+ </>
937
+ )}
938
+ </Show>
939
+ <button type="button" class="help-article-path-link focus-ui" onClick={goBack}>
940
+ {t().help}
941
+ </button>
942
+ </p>
943
+ <h3 ref={articleHeading} tabindex="-1" class="help-article-title focus-ui">
813
944
  {topic().title}
814
945
  </h3>
815
- <Show when={topic().description}>
816
- {(description) => <p class="mt-1.5 text-sm leading-relaxed text-dimmed">{description()}</p>}
817
- </Show>
818
- </div>
819
- <Show when={topic().kind === "document" && payload()}>
820
- <Button
821
- size="sm"
822
- variant="secondary"
823
- class="col-start-2 shrink-0 justify-self-start sm:col-start-3 sm:row-start-1 sm:justify-self-auto"
824
- onClick={() => void copyArticle(topic(), payload())}
946
+ <Show
947
+ when={articleParts()?.lead}
948
+ fallback={
949
+ // An article without a leading paragraph, or a built-in topic, shows its description; a loading article shows neither yet.
950
+ <Show when={articleParts() && topic().description}>
951
+ {(description) => <p class="help-article-lead">{description()}</p>}
952
+ </Show>
953
+ }
825
954
  >
826
- <i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
827
- {articleClipboard.wasCopied() ? t().copied : t().copyMarkdown}
828
- </Button>
829
- </Show>
830
- </header>
831
- </Show>
832
- <Show when={topic().kind === "content"}>{legacyTopicContent(topic())}</Show>
833
- <Show when={topic().kind === "document"}>
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} />
955
+ {(lead) => <MarkdownView trustedHtml={lead()} class="help-document help-article-lead" />}
956
+ </Show>
957
+ <Show when={payload()?.needs || articleParts()?.steps}>
958
+ {/* One row of facts; the access level appears here and nowhere else in the reader's chrome. */}
959
+ <ul class="help-article-facts" aria-label={t().articleFacts}>
960
+ <Show when={payload()?.needs}>
961
+ {(level) => (
962
+ <li class="help-article-fact" data-fact="needs">
963
+ <i class="ti ti-key" aria-hidden="true" />
964
+ <span>
965
+ {t().youNeed} <strong>{accessLabel(level())}</strong>
854
966
  </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} />
866
- </div>
967
+ </li>
968
+ )}
969
+ </Show>
970
+ <Show when={articleParts()?.steps}>
971
+ {(count) => (
972
+ <li class="help-article-fact" data-fact="steps">
973
+ <i class="ti ti-list-numbers" aria-hidden="true" />
974
+ <span>{t().steps({ count: count() })}</span>
975
+ </li>
976
+ )}
977
+ </Show>
978
+ </ul>
867
979
  </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
- )}
980
+ <Show when={articleSections().length > 0 || props.surface !== "modal"}>
981
+ <div class="help-article-tools">
982
+ <Show when={articleSections().length > 0}>
983
+ <Dropdown.Root
984
+ label={t().onThisPage}
985
+ position="bottom-left"
986
+ items={articleSections().map((section) => ({
987
+ label: section.label,
988
+ choice: "radio" as const,
989
+ checked: () => activeSectionId() === section.id,
990
+ action: () => openArticleSection(section),
991
+ }))}
992
+ >
993
+ <Dropdown.Trigger label={t().onThisPage} size="sm" tooltip={false} variant="secondary">
994
+ <i class="ti ti-list" aria-hidden="true" />
995
+ <span>{t().onThisPage}</span>
996
+ <i class="ti ti-chevron-down" aria-hidden="true" />
997
+ </Dropdown.Trigger>
998
+ </Dropdown.Root>
999
+ </Show>
1000
+ <Show when={props.surface !== "modal"}>
1001
+ <IconButton
1002
+ class="help-article-tools-copy"
1003
+ disabled={!payload()}
1004
+ label={articleClipboard.wasCopied() ? t().articleCopied : t().copyArticle}
1005
+ title={articleClipboard.wasCopied() ? t().copied : t().copyArticle}
1006
+ onClick={() => void copyArticle(topic(), payload())}
1007
+ >
1008
+ <i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
1009
+ </IconButton>
1010
+ </Show>
1011
+ </div>
876
1012
  </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>
1013
+ </header>
1014
+ <Show when={loading()}>
1015
+ <div class="min-h-[24rem]">
1016
+ <Placeholder state="loading" variant="panel" title={t().loading} />
885
1017
  </div>
886
- <Show when={payload()}>
887
- <TopicNavigation />
888
- </Show>
1018
+ </Show>
1019
+ <Show when={loadError()}>
1020
+ {(message) => (
1021
+ <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
1022
+ <Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
1023
+ {t().tryAgain}
1024
+ </Button>
1025
+ </NoticeCard>
1026
+ )}
1027
+ </Show>
1028
+ <div ref={articleContent}>
1029
+ <Show when={articleParts()}>{(parts) => <MarkdownView trustedHtml={parts().body} class="help-document" />}</Show>
889
1030
  </div>
1031
+ <Show when={payload()}>{(document) => <ArticleEnd document={document()} />}</Show>
890
1032
  </div>
891
- </Show>
1033
+ <aside class="help-article-aside">
1034
+ <div class="help-article-aside-sticky">
1035
+ <TableOfContents
1036
+ items={articleSections()}
1037
+ title={t().onThisPage}
1038
+ scrollRoot={tocScrollRoot}
1039
+ contentRoot={() => articleContent}
1040
+ onActiveChange={(id) => setActiveSectionId(id ?? null)}
1041
+ />
1042
+ <Show when={props.surface !== "modal"}>
1043
+ <Button size="sm" variant="ghost" disabled={!payload()} onClick={() => void copyArticle(topic(), payload())}>
1044
+ <i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
1045
+ {articleClipboard.wasCopied() ? t().copied : t().copyMarkdown}
1046
+ </Button>
1047
+ </Show>
1048
+ </div>
1049
+ </aside>
1050
+ </div>
892
1051
  </article>
893
1052
  )}
894
1053
  </Show>
895
1054
  <span class="sr-only" aria-live="polite">
896
- {articleClipboard.wasCopied() ? t().articleCopiedAnnouncement : allHelpClipboard.wasCopied() ? t().allCopiedAnnouncement : ""}
1055
+ {articleClipboard.wasCopied() ? t().articleCopiedAnnouncement : ""}
897
1056
  </span>
898
1057
  </ReaderContent>
899
1058
  </div>