@k2b/cloud 0.38.0 → 0.40.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 (244) hide show
  1. package/README.md +8 -0
  2. package/package.json +12 -5
  3. package/scripts/build-canvas-workers.ts +109 -29
  4. package/scripts/build.ts +3 -5
  5. package/src/_internal/app-favicon.ts +4 -2
  6. package/src/_internal/app-path.ts +25 -0
  7. package/src/_internal/app-presentation.ts +4 -0
  8. package/src/_internal/canvas-worker.ts +5 -3
  9. package/src/_internal/capabilities.ts +72 -48
  10. package/src/_internal/capability-reviews.ts +81 -0
  11. package/src/_internal/capability-schemas.ts +29 -0
  12. package/src/_internal/capability-sentences.ts +216 -1
  13. package/src/_internal/capability-streams.ts +7 -0
  14. package/src/_internal/define-app.ts +63 -16
  15. package/src/_internal/font-preloads.ts +2 -1
  16. package/src/_internal/panel-cookie.ts +39 -0
  17. package/src/_internal/registry-validation.ts +21 -26
  18. package/src/_internal/runtime-context.ts +1 -0
  19. package/src/_internal/runtime-watcher.ts +6 -0
  20. package/src/_internal/static-assets.ts +52 -3
  21. package/src/_internal/status-preserving-ssr.ts +2 -0
  22. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  23. package/src/access/PermissionEditor.tsx +32 -7
  24. package/src/access/ResourceApiKeys.tsx +5 -1
  25. package/src/account/EntitySearch.tsx +1 -1
  26. package/src/ai/action-review.ts +101 -0
  27. package/src/ai/approval-routes.ts +5 -2
  28. package/src/ai/approvals.ts +139 -28
  29. package/src/ai/background-review.ts +72 -0
  30. package/src/ai/browser.ts +10 -0
  31. package/src/ai/capabilities.ts +29 -11
  32. package/src/ai/capability-execution.ts +76 -43
  33. package/src/ai/capability-result.ts +95 -0
  34. package/src/ai/chat/action-card.ts +398 -0
  35. package/src/ai/chat/action-messages.ts +129 -0
  36. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  37. package/src/ai/chat/action-question.fixtures.ts +733 -0
  38. package/src/ai/chat/action-question.tsx +669 -0
  39. package/src/ai/chat/action-sentences.ts +1 -44
  40. package/src/ai/chat/blocks.tsx +19 -25
  41. package/src/ai/chat/message-actions.tsx +6 -0
  42. package/src/ai/chat/tool-action.tsx +481 -0
  43. package/src/ai/chat/turn-view.tsx +45 -163
  44. package/src/ai/chat/view-block.tsx +22 -7
  45. package/src/ai/chat-task-contracts.ts +31 -3
  46. package/src/ai/chat-task-reasons-migrate.ts +75 -0
  47. package/src/ai/chat-task-reasons.ts +92 -0
  48. package/src/ai/chat-tasks.ts +438 -78
  49. package/src/ai/client/controller.ts +59 -0
  50. package/src/ai/default-tools.ts +9 -1
  51. package/src/ai/executor.ts +190 -39
  52. package/src/ai/index.ts +14 -0
  53. package/src/ai/migrate.ts +39 -4
  54. package/src/ai/projects-routes.ts +3 -0
  55. package/src/ai/protocol.ts +14 -2
  56. package/src/ai/recent-resources.ts +240 -0
  57. package/src/ai/recent-sent-refs.ts +38 -0
  58. package/src/ai/request-context.ts +7 -1
  59. package/src/ai/routes.ts +172 -2
  60. package/src/ai/runtime.ts +13 -27
  61. package/src/ai/skills-routes.ts +3 -0
  62. package/src/ai/skills.ts +10 -1
  63. package/src/ai/store.ts +232 -27
  64. package/src/ai/timeline.ts +9 -1
  65. package/src/ai/tool-audit.ts +8 -0
  66. package/src/ai/tool-wording.ts +67 -0
  67. package/src/ai/turn-abort.ts +33 -0
  68. package/src/ai/types.ts +40 -4
  69. package/src/api/accounts-entities.ts +2 -1
  70. package/src/api/admin-ai-projects.ts +2 -0
  71. package/src/api/admin-ai-skills.ts +2 -0
  72. package/src/api/admin-ai-usage.ts +2 -0
  73. package/src/api/admin-core-settings.ts +190 -139
  74. package/src/api/admin-geoip.ts +80 -0
  75. package/src/api/admin-identity.ts +3 -0
  76. package/src/api/admin-lifecycle.ts +3 -0
  77. package/src/api/admin-outgoing-mail.ts +6 -2
  78. package/src/api/app-approval.ts +54 -6
  79. package/src/api/approvals-loader.ts +23 -1
  80. package/src/api/approvals.ts +3 -1
  81. package/src/api/auth.ts +25 -6
  82. package/src/api/capabilities.ts +44 -11
  83. package/src/api/capability-streams.ts +12 -0
  84. package/src/api/help.ts +4 -28
  85. package/src/api/index.ts +7 -1
  86. package/src/api/lasting-access-messages.ts +17 -0
  87. package/src/api/mcp.ts +3 -0
  88. package/src/api/me-panel-consent.ts +71 -0
  89. package/src/api/me.ts +80 -33
  90. package/src/api/search.ts +3 -1
  91. package/src/api/sync-ops.ts +2 -0
  92. package/src/api/widgets.ts +3 -0
  93. package/src/approvals/Approvals.tsx +180 -238
  94. package/src/approvals/messages.ts +12 -24
  95. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  96. package/src/browser/CloudResourceSearch.tsx +10 -6
  97. package/src/browser/app-approval.ts +60 -6
  98. package/src/browser/commands.ts +22 -6
  99. package/src/browser/composer-catalog.ts +145 -0
  100. package/src/browser/composer-insert-messages.ts +31 -0
  101. package/src/browser/composer-insert.ts +160 -0
  102. package/src/browser/composer-open-messages.ts +17 -0
  103. package/src/browser/composer-open.ts +110 -0
  104. package/src/browser/discoverable-command.ts +6 -0
  105. package/src/browser/hints.ts +6 -0
  106. package/src/browser/live-connection.ts +26 -2
  107. package/src/browser/navigation-apps.ts +7 -0
  108. package/src/browser/panel-context-bridge.ts +30 -0
  109. package/src/browser/panel-context-receiver.ts +53 -0
  110. package/src/browser/panel-menu.tsx +56 -0
  111. package/src/browser/panel-messages.ts +35 -0
  112. package/src/browser/panel-shortcuts.ts +31 -0
  113. package/src/browser/panel-slot.ts +756 -0
  114. package/src/browser/panel.ts +65 -0
  115. package/src/browser/resource-picker.tsx +12 -1
  116. package/src/browser/search-commands.ts +6 -3
  117. package/src/capabilities/client.ts +8 -2
  118. package/src/capabilities/reviews.ts +205 -0
  119. package/src/capabilities/server.ts +10 -3
  120. package/src/capabilities/testing.ts +47 -0
  121. package/src/capabilities/types.ts +2 -2
  122. package/src/cli/account.ts +13 -3
  123. package/src/cli/admin/geoip.ts +115 -0
  124. package/src/cli/admin/index.ts +3 -0
  125. package/src/contracts/app-approval.ts +159 -12
  126. package/src/contracts/app.ts +14 -0
  127. package/src/contracts/approvals.ts +2 -2
  128. package/src/contracts/capabilities.ts +410 -18
  129. package/src/contracts/geoip.ts +42 -0
  130. package/src/contracts/index.ts +3 -0
  131. package/src/contracts/panel-context-plain.ts +99 -0
  132. package/src/contracts/panel-context.ts +93 -0
  133. package/src/contracts/registry.ts +3 -1
  134. package/src/contracts/seen-hints.ts +35 -0
  135. package/src/contracts/shared.ts +2 -0
  136. package/src/server/client-ip.ts +5 -0
  137. package/src/server/help-payload.ts +38 -0
  138. package/src/server/middleware/auth.ts +11 -1
  139. package/src/server/middleware/middleware.ts +9 -0
  140. package/src/server/middleware/no-transform.ts +19 -0
  141. package/src/server/middleware/rate-limit.ts +4 -6
  142. package/src/server/request-context/address.ts +45 -0
  143. package/src/server/request-context/geoip.ts +380 -0
  144. package/src/server/request-context/index.ts +33 -0
  145. package/src/server/request-context/user-agent.ts +95 -0
  146. package/src/server/services/access.ts +5 -1
  147. package/src/services/accounts/posix.ts +2 -1
  148. package/src/services/app-approval.ts +533 -90
  149. package/src/services/approvals/device.ts +9 -5
  150. package/src/services/approvals/index.ts +6 -5
  151. package/src/services/approvals/list.ts +26 -5
  152. package/src/services/auth-flows/notification-sender.ts +3 -0
  153. package/src/services/geoip/index.ts +164 -0
  154. package/src/services/geoip/store.ts +30 -0
  155. package/src/services/geoip/upload.ts +150 -0
  156. package/src/services/identity/routes.ts +5 -1
  157. package/src/services/index.ts +32 -3
  158. package/src/services/known-browsers.ts +312 -0
  159. package/src/services/login-address-store.ts +51 -0
  160. package/src/services/login-context.ts +118 -0
  161. package/src/services/mandates/index.ts +3 -0
  162. package/src/services/mandates/policy.ts +15 -3
  163. package/src/services/notifications/browser.ts +16 -5
  164. package/src/services/notifications/email-frame.ts +1 -1
  165. package/src/services/oauth-tokens.ts +16 -2
  166. package/src/services/panel-context-consent.ts +58 -0
  167. package/src/services/pdf/image-protocol.ts +135 -0
  168. package/src/services/pdf/image-variants.ts +124 -0
  169. package/src/services/pdf/image-worker.ts +31 -0
  170. package/src/services/pdf/image.ts +109 -0
  171. package/src/services/pdf/index.ts +9 -0
  172. package/src/services/service-account-credentials.ts +21 -0
  173. package/src/services/session/index.ts +97 -16
  174. package/src/services/session/recent.ts +13 -0
  175. package/src/services/settings/api.ts +11 -6
  176. package/src/services/settings/app.ts +5 -4
  177. package/src/services/settings/core-settings.ts +4 -3
  178. package/src/services/settings/index.ts +19 -17
  179. package/src/services/settings/namespace.ts +2 -1
  180. package/src/services/settings/store.ts +83 -11
  181. package/src/services/webauthn.ts +12 -0
  182. package/src/shared/ai-platform-prompt.ts +1 -1
  183. package/src/shared/app-presentation.ts +1 -0
  184. package/src/shared/email-html.ts +2 -0
  185. package/src/shared/help.ts +3 -0
  186. package/src/shared/redirect.ts +12 -0
  187. package/src/shared-text/cursor.ts +22 -0
  188. package/src/shared-text/documents.ts +142 -0
  189. package/src/shared-text/events.ts +43 -0
  190. package/src/shared-text/index.ts +12 -0
  191. package/src/shared-text/log.ts +48 -0
  192. package/src/shared-text/text-diff.ts +23 -0
  193. package/src/shared-text/types.ts +41 -0
  194. package/src/ssr/AdminLayout.tsx +2 -0
  195. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  196. package/src/ssr/CloudShell.island.tsx +95 -10
  197. package/src/ssr/Layout.tsx +66 -10
  198. package/src/ssr/LayoutHeader.tsx +15 -9
  199. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  200. package/src/ssr/LayoutHelp.tsx +435 -274
  201. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
  202. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  203. package/src/ssr/LayoutRail.tsx +2 -2
  204. package/src/ssr/LayoutTabBar.tsx +29 -0
  205. package/src/ssr/MobileNavigation.tsx +2 -2
  206. package/src/ssr/PanelLayout.tsx +35 -0
  207. package/src/ssr/PanelRuntime.island.tsx +94 -0
  208. package/src/ssr/PanelSlot.tsx +158 -0
  209. package/src/ssr/PanelTrigger.tsx +49 -0
  210. package/src/ssr/ShellRuntime.island.tsx +69 -5
  211. package/src/ssr/app-appearance.ts +3 -2
  212. package/src/ssr/app-color.ts +105 -0
  213. package/src/ssr/app-launchpad.tsx +18 -1
  214. package/src/ssr/app-navigation.ts +28 -0
  215. package/src/ssr/compact-shell.baseline.json +5270 -0
  216. package/src/ssr/compact-shell.browser-harness.ts +439 -0
  217. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  218. package/src/ssr/help-messages.ts +4 -0
  219. package/src/ssr/help.ts +20 -0
  220. package/src/ssr/hints.ts +25 -0
  221. package/src/ssr/index.ts +5 -0
  222. package/src/ssr/islands/index.ts +4 -3
  223. package/src/ssr/layout-context.ts +2 -0
  224. package/src/ssr/layout-help-article.server.ts +50 -0
  225. package/src/ssr/layout-help-start.ts +33 -7
  226. package/src/ssr/panel-consent.ts +29 -0
  227. package/src/ssr/panel-context.ts +10 -0
  228. package/src/ssr/panel-slot.browser-harness.tsx +159 -0
  229. package/src/ssr/platform-messages.ts +48 -0
  230. package/src/ssr/rail-navigation.ts +44 -0
  231. package/src/ssr/shell-data.tsx +5 -0
  232. package/src/ssr/shell-shortcuts.ts +13 -2
  233. package/src/styles/effects.css +201 -11
  234. package/src/styles/file-chooser.css +2 -1
  235. package/src/styles/global.css +314 -63
  236. package/src/styles/panel.css +416 -0
  237. package/src/styles/resource-search.css +6 -2
  238. package/src/styles/tokens.css +36 -13
  239. package/src/styles/utilities-buttons.css +3 -2
  240. package/src/styles/utilities-data.css +2 -1
  241. package/src/styles/utilities-feedback.css +2 -2
  242. package/src/styles/utilities-layout.css +3 -13
  243. package/src/styles/utilities-markdown-editor.css +6 -6
  244. package/src/styles/utilities-navigation.css +22 -43
@@ -1,10 +1,14 @@
1
1
  import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
2
2
  import { clipboard } from "@k2b/stdlib/solid";
3
3
  import {
4
+ BottomSheet,
4
5
  Button,
6
+ bottomSheetOptions,
5
7
  Dropdown,
8
+ dialogCore,
6
9
  IconButton,
7
10
  Kbd,
11
+ type KeyboardPlatform,
8
12
  MarkdownView,
9
13
  NoticeCard,
10
14
  Placeholder,
@@ -16,10 +20,13 @@ import {
16
20
  toast,
17
21
  useLocale,
18
22
  } from "@k2b/ui";
19
- import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show } from "solid-js";
23
+ import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show, untrack } from "solid-js";
20
24
  import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "../browser/command-shortcuts";
25
+ import { getContext, onContextChange, openPanel } from "../browser/panel";
26
+ import { onPanelShortcutsChange, panelShortcutsReceived, readPanelShortcuts } from "../browser/panel-shortcuts";
21
27
  import { HELP_ACCESS_LEVELS, HELP_KINDS } from "../shared/help";
22
28
  import { appAccentStyle } from "./app-appearance";
29
+ import type { LayoutHelpArticle } from "./help";
23
30
  import { helpMessages } from "./help-messages";
24
31
  import { helpArticleParts } from "./layout-help-article";
25
32
  import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-help-markdown";
@@ -41,6 +48,8 @@ import { shellShortcuts } from "./shell-shortcuts";
41
48
  export type LayoutHelpPageProps = {
42
49
  documents: readonly HelpDocumentManifest[];
43
50
  initialTopic?: string;
51
+ /** Authorized article and server-parsed parts for an identical first frame. */
52
+ initialArticle?: LayoutHelpArticle;
44
53
  includeShortcuts?: boolean;
45
54
  accent?: string;
46
55
  /** Render inside an app-owned pane instead of occupying a full page. */
@@ -49,6 +58,22 @@ export type LayoutHelpPageProps = {
49
58
  pageBase?: string;
50
59
  };
51
60
 
61
+ export type LayoutHelpPanelProps = {
62
+ documents: readonly HelpDocumentManifest[];
63
+ /** Absent when the context selected Help the reader may not see. */
64
+ app?: { id: string; name: string; icon?: string };
65
+ /** appAppearanceStyle() of the app, from the server. */
66
+ appearance?: string;
67
+ /** Server-filtered For this view ids. */
68
+ view?: readonly string[];
69
+ initialTopic?: string;
70
+ /** Authorized article and server-parsed parts for an identical first frame. */
71
+ initialArticle?: LayoutHelpArticle;
72
+ pageBase?: string;
73
+ platform: KeyboardPlatform;
74
+ state?: "failed" | "unavailable";
75
+ };
76
+
52
77
  type HelpView = "hub" | "article";
53
78
  type HelpSession = {
54
79
  view: HelpView;
@@ -97,21 +122,30 @@ const GROUP_ICONS: Record<HelpStartGroupKind, string> = {
97
122
  * first client render on, also before the shell has registered them. The groups reserve their height and place in CSS,
98
123
  * so the ones the browser adds fill space that is already there.
99
124
  */
100
- const StartShortcuts = (props: { appName?: string }) => {
125
+ const StartShortcuts = (props: { appName?: string; panel?: boolean; platform?: KeyboardPlatform }) => {
101
126
  const locale = useLocale();
102
127
  const t = () => helpMessages.resolve([locale()]).t;
103
- const shell = useShellData();
104
- const seed: HelpShortcutCommand[] = shell ? shellShortcuts(platformMessages.resolve([locale()]).t) : [];
128
+ const shell = props.panel ? undefined : useShellData();
129
+ const seed: HelpShortcutCommand[] =
130
+ props.panel || shell ? shellShortcuts(platformMessages.resolve([locale()]).t, { panel: props.panel || !!shell?.panel }) : [];
105
131
  const read = (): HelpShortcutCommand[] => {
132
+ // Once received, the host list is authoritative: omitted shortcuts must stay omitted.
133
+ if (props.panel) return panelShortcutsReceived() && getContext() !== null ? readPanelShortcuts() : seed;
106
134
  const commands = contextCommandsWithShortcuts();
107
135
  return [...seed.filter((command) => !commands.some((other) => other.id === command.id)), ...commands];
108
136
  };
109
- const [commands, setCommands] = createSignal<HelpShortcutCommand[]>(read());
137
+ const [commands, setCommands] = createSignal<HelpShortcutCommand[]>(props.panel ? seed : read());
110
138
  onMount(() => {
111
139
  const update = () => setCommands(read());
140
+ if (props.panel) {
141
+ onCleanup(onPanelShortcutsChange(update));
142
+ onCleanup(onContextChange(update));
143
+ }
112
144
  update();
113
- window.addEventListener(COMMANDS_CHANGED, update);
114
- onCleanup(() => window.removeEventListener(COMMANDS_CHANGED, update));
145
+ if (!props.panel) {
146
+ window.addEventListener(COMMANDS_CHANGED, update);
147
+ onCleanup(() => window.removeEventListener(COMMANDS_CHANGED, update));
148
+ }
115
149
  });
116
150
  const label = (scope: HelpShortcutScope) =>
117
151
  scope === "everywhere"
@@ -139,7 +173,7 @@ const StartShortcuts = (props: { appName?: string }) => {
139
173
  {(command) => (
140
174
  <li class="help-shortcut" title={command.description}>
141
175
  <span class="help-shortcut-title">{command.title}</span>
142
- <Kbd keys={command.shortcut} platform={shell?.platform} />
176
+ <Kbd keys={command.shortcut} platform={props.platform ?? shell?.platform} />
143
177
  </li>
144
178
  )}
145
179
  </For>
@@ -154,28 +188,38 @@ const StartShortcuts = (props: { appName?: string }) => {
154
188
 
155
189
  const HelpShell = (props: {
156
190
  session: HelpSession;
191
+ initialArticle?: LayoutHelpArticle;
157
192
  close: () => void;
158
193
  pageBase?: string;
159
194
  documents?: readonly HelpDocumentManifest[];
160
195
  includeShortcuts?: boolean;
161
196
  accent?: string;
162
- surface?: "modal" | "page" | "embedded";
197
+ /** The app's canvas colours for a reader outside the app canvas. */
198
+ appearance?: string;
199
+ surface?: "modal" | "page" | "embedded" | "panel";
200
+ panel?: LayoutHelpPanelProps;
163
201
  syncPageUrl?: boolean;
164
202
  }) => {
165
203
  const locale = useLocale();
166
204
  const t = () => helpMessages.resolve([locale()]).t;
167
205
  // Links to this app's Help, by its own route or Core's `/help/apps/<id>`, open in place.
168
- const shell = useShellData();
169
- const appId = shell?.appId;
206
+ const shell = props.surface === "panel" ? undefined : useShellData();
207
+ const appId = props.panel?.app?.id ?? shell?.appId;
170
208
  const app = shell?.apps.find((candidate) => candidate.id === appId);
171
- const appName = app?.label;
209
+ const appName = props.panel?.app?.name ?? app?.label;
210
+ const [viewIds, setViewIds] = createSignal(props.panel?.view ?? []);
211
+ const registeredTopics = () => (props.surface === "panel" ? [] : sortedTopics());
172
212
  const accessLabel = (level: HelpAccessLevel) => ({ view: t().accessView, edit: t().accessEdit, manage: t().accessManage })[level];
173
- const [externalTopics, setExternalTopics] = createSignal(mergeTopics(sortedTopics(), props.documents));
213
+ const [externalTopics, setExternalTopics] = createSignal(mergeTopics(registeredTopics(), props.documents));
174
214
  const [view, setView] = createSignal<HelpView>(props.session.view);
175
215
  const [query, setQuery] = createSignal(props.session.query);
176
216
  const [activeId, setActiveId] = createSignal<string | null>(props.session.activeId);
177
- const [payload, setPayload] = createSignal<HelpDocumentPayload | null>(null);
178
- const [loading, setLoading] = createSignal(props.session.view === "article");
217
+ const initialTopic = props.documents?.find((topic) => topic.id === props.session.activeId);
218
+ const initialArticle = initialTopic && props.initialArticle?.payload.id === initialTopic.id ? props.initialArticle : undefined;
219
+ if (initialArticle && initialTopic)
220
+ props.session.articleCache.set(`${initialTopic.locale ?? "en"}:${initialTopic.url}`, initialArticle.payload);
221
+ const [payload, setPayload] = createSignal<HelpDocumentPayload | null>(initialArticle?.payload ?? null);
222
+ const [loading, setLoading] = createSignal(props.session.view === "article" && !initialArticle);
179
223
  const [loadError, setLoadError] = createSignal<string | null>(null);
180
224
  const [loadAttempt, setLoadAttempt] = createSignal(0);
181
225
  const [remoteIds, setRemoteIds] = createSignal<readonly string[] | null>(null);
@@ -222,7 +266,7 @@ const HelpShell = (props: {
222
266
  };
223
267
 
224
268
  const restoreArticleScroll = () => {
225
- if (props.surface !== "modal" || pendingNavigation || pendingHash) return;
269
+ if ((props.surface !== "modal" && props.surface !== "panel") || pendingNavigation || pendingHash) return;
226
270
  requestAnimationFrame(() => {
227
271
  if (scrollArea && view() === "article") scrollArea.scrollTop = props.session.articleScrollTop;
228
272
  });
@@ -267,12 +311,15 @@ const HelpShell = (props: {
267
311
  };
268
312
 
269
313
  const topics = externalTopics;
270
- const start = createMemo(() => helpStart(topics(), props.surface === "modal" ? shell?.help : undefined));
314
+ const start = createMemo(() =>
315
+ helpStart(topics(), props.surface === "panel" ? viewIds() : props.surface === "modal" ? shell?.help : undefined),
316
+ );
271
317
  const activeTopic = createMemo(() => topics().find((topic) => topic.id === activeId()) ?? null);
272
- // Article bodies load in the browser only; the server renders the head and the loading state.
318
+ // Reuse the serialized parts for the initial object on both sides of the island boundary.
273
319
  const articleParts = createMemo(() => {
274
- const html = payload()?.html;
275
- return html ? helpArticleParts(html) : null;
320
+ const article = payload();
321
+ if (initialArticle && article === initialArticle.payload) return initialArticle.parts;
322
+ return article?.html ? helpArticleParts(article.html) : null;
276
323
  });
277
324
  const articleSections = (): HelpSection[] => (view() === "article" ? articleParts()?.sections : undefined) ?? [];
278
325
  const normalizedQuery = createMemo(() => query().trim().toLocaleLowerCase());
@@ -362,9 +409,34 @@ const HelpShell = (props: {
362
409
  createEffect(syncSession);
363
410
 
364
411
  onMount(() => {
412
+ if (props.surface === "panel") {
413
+ onCleanup(onContextChange((context) => setViewIds(context && context.app === appId ? (context.help ?? []) : [])));
414
+ }
415
+ if (props.surface === "panel" || props.surface === "modal") {
416
+ const key = (event: KeyboardEvent) => {
417
+ if (event.defaultPrevented || event.isComposing) return;
418
+ const target = event.target;
419
+ if (event.key === "Escape" && target === searchInput && query()) {
420
+ event.preventDefault();
421
+ changeQuery("");
422
+ return;
423
+ }
424
+ // Some layouts type "/" with Shift (German: Shift+7), so Shift does not disqualify it.
425
+ if (event.ctrlKey || event.metaKey || event.altKey) return;
426
+ if (
427
+ event.key === "/" &&
428
+ !(target instanceof Element && target.closest('input, textarea, select, [contenteditable]:not([contenteditable="false"])'))
429
+ ) {
430
+ event.preventDefault();
431
+ searchInput?.focus();
432
+ }
433
+ };
434
+ document.addEventListener("keydown", key);
435
+ onCleanup(() => document.removeEventListener("keydown", key));
436
+ }
365
437
  const dialog = root?.closest("dialog");
366
438
  dialog?.setAttribute("aria-labelledby", "layout-help-title");
367
- const update = () => setExternalTopics(mergeTopics(sortedTopics(), props.documents));
439
+ const update = () => setExternalTopics(mergeTopics(registeredTopics(), props.documents));
368
440
  window.addEventListener(HELP_TOPICS_EVENT, update);
369
441
  update();
370
442
  onCleanup(() => window.removeEventListener(HELP_TOPICS_EVENT, update));
@@ -373,7 +445,7 @@ const HelpShell = (props: {
373
445
  const onScroll = () => {
374
446
  if (view() === "article" && scrollArea) props.session.articleScrollTop = scrollArea.scrollTop;
375
447
  };
376
- if (props.surface === "modal") scrollArea?.addEventListener("scroll", onScroll, { passive: true });
448
+ if (props.surface === "modal" || props.surface === "panel") scrollArea?.addEventListener("scroll", onScroll, { passive: true });
377
449
  // Layout's breakpoints move page scrolling between its main column and the window; the contents follow.
378
450
  let resizeFrame: number | undefined;
379
451
  const onResize = () => {
@@ -452,16 +524,19 @@ const HelpShell = (props: {
452
524
  loadAttempt();
453
525
  const topic = activeTopic();
454
526
  const version = ++requestVersion;
455
- setPayload(null);
527
+ const cached = topic && view() === "article" ? props.session.articleCache.get(`${topic.locale ?? "en"}:${topic.url}`) : undefined;
456
528
  setLoadError(null);
457
529
  setLoading(false);
458
- if (!topic || view() !== "article") return;
459
- const cached = props.session.articleCache.get(`${topic.locale ?? "en"}:${topic.url}`);
530
+ // Keep a seeded payload in place on mount: no empty frame or duplicate scroll restoration.
460
531
  if (cached) {
461
- setPayload(cached);
462
- restoreArticleScroll();
532
+ if (untrack(payload) !== cached) {
533
+ setPayload(cached);
534
+ restoreArticleScroll();
535
+ }
463
536
  return;
464
537
  }
538
+ setPayload(null);
539
+ if (!topic || view() !== "article") return;
465
540
 
466
541
  const controller = new AbortController();
467
542
  setLoading(true);
@@ -636,7 +711,7 @@ const HelpShell = (props: {
636
711
  {(topic) => (
637
712
  <li>
638
713
  <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">
714
+ <span class="help-topic-icon k2b-icon-tile help-start-row-icon app-accent-text" aria-hidden="true">
640
715
  <i class={iconClass(topic.icon)} />
641
716
  </span>
642
717
  <span class="help-article-link-copy">
@@ -658,7 +733,7 @@ const HelpShell = (props: {
658
733
  {(topic) => (
659
734
  <li>
660
735
  <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">
736
+ <span class="help-topic-icon k2b-icon-tile help-start-tile-icon app-accent-text" aria-hidden="true">
662
737
  <i class={iconClass(topic.icon)} />
663
738
  </span>
664
739
  <span class="help-start-tile-title">{topic.title}</span>
@@ -704,7 +779,7 @@ const HelpShell = (props: {
704
779
  {(link) => (
705
780
  <li>
706
781
  <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">
782
+ <span class="help-topic-icon k2b-icon-tile help-article-related-icon app-accent-text" aria-hidden="true">
708
783
  <i class={iconClass(link.icon)} />
709
784
  </span>
710
785
  <ArticleLinkTitle link={link} />
@@ -731,6 +806,8 @@ const HelpShell = (props: {
731
806
  </ScrollArea>
732
807
  );
733
808
 
809
+ // The dialog and the panel copy from their ⋯ menu; only the page repeats the copy action beside the article.
810
+ const copyInMenu = props.surface === "modal" || props.surface === "panel";
734
811
  const pageHref = () => (props.pageBase ? layoutHelpTopicHref(props.pageBase, view() === "article" ? activeId() : null) : undefined);
735
812
  const menuItems = () => [
736
813
  {
@@ -740,25 +817,34 @@ const HelpShell = (props: {
740
817
  ? { disabled: true as const }
741
818
  : { action: () => void copyFromMenu() }),
742
819
  },
743
- ...(props.surface === "modal" && pageHref() ? [{ label: t().openAsPage, icon: "ti ti-app-window", href: pageHref()! }] : []),
820
+ ...(copyInMenu && pageHref() ? [{ label: t().openAsPage, icon: "ti ti-app-window", href: pageHref()! }] : []),
744
821
  ];
745
822
 
746
823
  return (
747
824
  <div
748
825
  ref={root}
749
- class={`help-shell app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
750
- props.surface === "modal"
751
- ? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
752
- : props.surface === "page"
753
- ? ""
754
- : "h-full"
755
- }`}
826
+ class={
827
+ props.surface === "panel"
828
+ ? "help-shell app-accent-scope flex h-full min-h-0 flex-col"
829
+ : `help-shell app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
830
+ props.surface === "modal"
831
+ ? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
832
+ : props.surface === "page"
833
+ ? ""
834
+ : "h-full"
835
+ }`
836
+ }
756
837
  data-surface={props.surface}
757
- style={appAccentStyle(props.accent)}
838
+ style={
839
+ props.surface === "panel"
840
+ ? props.panel?.appearance
841
+ : [appAccentStyle(props.accent), props.appearance].filter(Boolean).join(";") || undefined
842
+ }
758
843
  >
759
844
  {/* One head in every view: the search never moves between the start, an article, and results. An app's embedded
760
845
  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"}>
846
+ {/* Help the reader may not see gets no head either: the slot already names the panel, and there is nothing to search. */}
847
+ <Show when={(props.surface !== "embedded" || view() === "hub") && props.panel?.state !== "unavailable"}>
762
848
  <header class="help-head" data-help-head="">
763
849
  <div class="help-head-inner help-measure">
764
850
  <Show when={props.surface !== "embedded"}>
@@ -768,7 +854,7 @@ const HelpShell = (props: {
768
854
  when={view() === "article"}
769
855
  fallback={
770
856
  <span class="help-head-tile" aria-hidden="true">
771
- <i class={app?.iconClass ?? "ti ti-help"} />
857
+ <i class={props.panel?.app?.icon ?? app?.iconClass ?? "ti ti-help-circle"} />
772
858
  </span>
773
859
  }
774
860
  >
@@ -777,7 +863,7 @@ const HelpShell = (props: {
777
863
  </IconButton>
778
864
  </Show>
779
865
  <h2 id="layout-help-title" class="help-head-title">
780
- {appName ? t().helpFor({ app: appName }) : t().help}
866
+ {appName ? (props.surface === "panel" ? appName : t().helpFor({ app: appName })) : t().help}
781
867
  </h2>
782
868
  <div class="help-head-actions" role="group" aria-label={t().actions}>
783
869
  <Dropdown.Root label={t().moreActions} position="bottom-left" items={menuItems()}>
@@ -809,247 +895,263 @@ const HelpShell = (props: {
809
895
  </Show>
810
896
 
811
897
  <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} />
825
- </section>
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" />
836
- </Show>
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>
899
- </Show>
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} />
898
+ <Show
899
+ when={props.panel?.state !== "unavailable"}
900
+ fallback={<Placeholder icon="ti ti-book-off" title={t().helpNotAvailable} description={t().helpNotAvailableDetail} />}
901
+ >
902
+ <Show when={view() === "hub"}>
903
+ <div ref={resultsRegion} data-help-results="" class="help-start help-measure" style={{ "min-height": resultsMinHeight() }}>
904
+ <Show
905
+ when={normalizedQuery()}
906
+ fallback={
907
+ <>
908
+ <Show when={start().view.length > 0}>
909
+ <section class="help-start-section" data-help-view="" aria-labelledby="help-start-view-title">
910
+ <h3 id="help-start-view-title" class="help-start-heading">
911
+ <i class="ti ti-focus-2" aria-hidden="true" />
912
+ {t().forThisView}
913
+ </h3>
914
+ <TopicList items={start().view} />
915
+ </section>
906
916
  </Show>
907
- }
908
- >
909
- <TopicList items={results()} />
910
- </Show>
911
- </section>
912
- </Show>
913
- </div>
914
- </Show>
915
-
916
- <Show when={view() === "article" && activeTopic()}>
917
- {(topic) => (
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">
944
- {topic().title}
945
- </h3>
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>}
917
+ <Show when={start().tiles.length > 0}>
918
+ <section class="help-start-section" data-help-tasks="" aria-labelledby="help-start-tasks-title">
919
+ <h3 id="help-start-tasks-title" class="help-start-heading">
920
+ <i class="ti ti-checklist" aria-hidden="true" />
921
+ {t().whatToDo}
922
+ </h3>
923
+ <TaskTiles items={start().tiles} />
924
+ <Show when={start().moreTasks.length > 0}>
925
+ <TopicList items={start().moreTasks} marker="data-help-more-tasks" />
952
926
  </Show>
953
- }
954
- >
955
- {(lead) => <MarkdownView trustedHtml={lead()} class="help-document help-article-lead" />}
927
+ </section>
956
928
  </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>
966
- </span>
967
- </li>
929
+ <Show when={start().groups.length > 0}>
930
+ <div class="help-start-groups">
931
+ <For each={start().groups}>
932
+ {(group) => (
933
+ <section
934
+ class="help-start-section"
935
+ data-help-group={group.kind}
936
+ aria-labelledby={`help-start-${group.kind}-title`}
937
+ >
938
+ <h3 id={`help-start-${group.kind}-title`} class="help-start-heading">
939
+ <i class={GROUP_ICONS[group.kind]} aria-hidden="true" />
940
+ {groupTitle(group.kind)}
941
+ </h3>
942
+ <TopicList items={group.topics} />
943
+ </section>
968
944
  )}
945
+ </For>
946
+ </div>
947
+ </Show>
948
+ {/* An app without Help says so plainly; only a failed read of its Help offers a way to try again. */}
949
+ <Show when={topics().length === 0}>
950
+ <Show
951
+ when={props.panel?.state === "failed" || shell?.helpFailed}
952
+ fallback={<Placeholder icon="ti ti-book-off" title={t().noArticles} description={t().noArticlesDetail} />}
953
+ >
954
+ <NoticeCard tone="danger" title={t().helpUnavailable} detail={t().helpUnavailableDetail} data-help-unavailable="">
955
+ <Button size="sm" variant="secondary" onClick={() => location.reload()}>
956
+ {t().reload}
957
+ </Button>
958
+ </NoticeCard>
959
+ </Show>
960
+ </Show>
961
+ <Show when={props.includeShortcuts !== false}>
962
+ <StartShortcuts appName={appName} panel={props.surface === "panel"} platform={props.panel?.platform} />
963
+ </Show>
964
+ </>
965
+ }
966
+ >
967
+ {/* The count takes the place of the first heading, so the first row stays where it was. */}
968
+ <section class="help-start-section" data-help-search-results="">
969
+ {/* A failed full-text search says so in the same line, with a way to try again; the results do not move. */}
970
+ <div class="help-start-status">
971
+ <span aria-live="polite">{t().results({ count: results().length })}</span>
972
+ <Show when={searching()}>
973
+ <i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
974
+ </Show>
975
+ <Show when={searchFailed()}>
976
+ <span class="help-start-status-warning" data-help-search-failed="" title={t().searchTitlesOnly} role="status">
977
+ <i class="ti ti-alert-triangle" aria-hidden="true" />
978
+ <span aria-hidden="true">{t().titlesOnly}</span>
979
+ <span class="sr-only">{t().searchTitlesOnly}</span>
980
+ </span>
981
+ <Button
982
+ size="xs"
983
+ variant="ghost"
984
+ class="help-start-status-retry"
985
+ onClick={() => setSearchAttempt((value) => value + 1)}
986
+ >
987
+ {t().tryAgain}
988
+ </Button>
989
+ </Show>
990
+ </div>
991
+ <Show
992
+ when={results().length > 0}
993
+ fallback={
994
+ <Show when={!searching()}>
995
+ <Placeholder icon="ti ti-search-off" title={t().noResults} />
996
+ </Show>
997
+ }
998
+ >
999
+ <TopicList items={results()} />
1000
+ </Show>
1001
+ </section>
1002
+ </Show>
1003
+ </div>
1004
+ </Show>
1005
+
1006
+ <Show when={view() === "article" && activeTopic()}>
1007
+ {(topic) => (
1008
+ <article
1009
+ class="help-article"
1010
+ data-surface={props.surface}
1011
+ ref={(element) => {
1012
+ // Delegated from the article: its body, table of contents, and end hold the links.
1013
+ element.addEventListener("click", followHelpLink);
1014
+ onCleanup(() => element.removeEventListener("click", followHelpLink));
1015
+ }}
1016
+ >
1017
+ <div class="help-article-layout">
1018
+ <div class="help-article-main">
1019
+ <header class="help-article-head" data-help-article-head="">
1020
+ <div class="help-article-head-copy">
1021
+ {/* In the panel, its head already names the app and leads back to the start. */}
1022
+ <Show when={props.surface !== "panel"}>
1023
+ <p class="help-article-path">
1024
+ <Show when={appName}>
1025
+ {(name) => (
1026
+ <>
1027
+ <span class="app-accent-text">{name()}</span>
1028
+ <i class="ti ti-chevron-right" aria-hidden="true" />
1029
+ </>
1030
+ )}
1031
+ </Show>
1032
+ <button type="button" class="help-article-path-link focus-ui" onClick={goBack}>
1033
+ {t().help}
1034
+ </button>
1035
+ </p>
969
1036
  </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
- )}
1037
+ <h3 ref={articleHeading} tabindex="-1" class="help-article-title focus-ui">
1038
+ {topic().title}
1039
+ </h3>
1040
+ <Show
1041
+ when={articleParts()?.lead}
1042
+ fallback={
1043
+ // An article without a leading paragraph, or a built-in topic, shows its description; a loading article shows neither yet.
1044
+ <Show when={articleParts() && topic().description}>
1045
+ {(description) => <p class="help-article-lead">{description()}</p>}
1046
+ </Show>
1047
+ }
1048
+ >
1049
+ {(lead) => <MarkdownView trustedHtml={lead()} class="help-document help-article-lead" />}
977
1050
  </Show>
978
- </ul>
979
- </Show>
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>
1051
+ <Show when={payload()?.needs || articleParts()?.steps}>
1052
+ {/* One row of facts; the access level appears here and nowhere else in the reader's chrome. */}
1053
+ <ul class="help-article-facts" aria-label={t().articleFacts}>
1054
+ <Show when={payload()?.needs}>
1055
+ {(level) => (
1056
+ <li class="help-article-fact" data-fact="needs">
1057
+ <i class="ti ti-key" aria-hidden="true" />
1058
+ <span>
1059
+ {t().youNeed} <strong>{accessLabel(level())}</strong>
1060
+ </span>
1061
+ </li>
1062
+ )}
1063
+ </Show>
1064
+ <Show when={articleParts()?.steps}>
1065
+ {(count) => (
1066
+ <li class="help-article-fact" data-fact="steps">
1067
+ <i class="ti ti-list-numbers" aria-hidden="true" />
1068
+ <span>{t().steps({ count: count() })}</span>
1069
+ </li>
1070
+ )}
1071
+ </Show>
1072
+ </ul>
999
1073
  </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>
1074
+ <Show when={articleSections().length > 0 || !copyInMenu}>
1075
+ <div class="help-article-tools">
1076
+ <Show when={articleSections().length > 0}>
1077
+ <Dropdown.Root
1078
+ label={t().onThisPage}
1079
+ position="bottom-left"
1080
+ items={articleSections().map((section) => ({
1081
+ label: section.label,
1082
+ choice: "radio" as const,
1083
+ checked: () => activeSectionId() === section.id,
1084
+ action: () => openArticleSection(section),
1085
+ }))}
1086
+ >
1087
+ <Dropdown.Trigger label={t().onThisPage} size="sm" tooltip={false} variant="secondary">
1088
+ <i class="ti ti-list" aria-hidden="true" />
1089
+ <span>{t().onThisPage}</span>
1090
+ <i class="ti ti-chevron-down" aria-hidden="true" />
1091
+ </Dropdown.Trigger>
1092
+ </Dropdown.Root>
1093
+ </Show>
1094
+ <Show when={!copyInMenu}>
1095
+ <IconButton
1096
+ class="help-article-tools-copy"
1097
+ disabled={!payload()}
1098
+ label={articleClipboard.wasCopied() ? t().articleCopied : t().copyArticle}
1099
+ title={articleClipboard.wasCopied() ? t().copied : t().copyArticle}
1100
+ onClick={() => void copyArticle(topic(), payload())}
1101
+ >
1102
+ <i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
1103
+ </IconButton>
1104
+ </Show>
1105
+ </div>
1010
1106
  </Show>
1011
1107
  </div>
1108
+ {/* The embedded readers sit in the app's own chrome and keep the flat head. */}
1109
+ <Show when={props.surface !== "embedded"}>
1110
+ <span class="help-article-icon k2b-icon-tile app-accent-text" aria-hidden="true">
1111
+ <i class={iconClass(topic().icon)} />
1112
+ </span>
1113
+ </Show>
1114
+ </header>
1115
+ <Show when={loading()}>
1116
+ <div class="min-h-[24rem]">
1117
+ <Placeholder state="loading" variant="panel" title={t().loading} />
1118
+ </div>
1119
+ </Show>
1120
+ <Show when={loadError()}>
1121
+ {(message) => (
1122
+ <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
1123
+ <Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
1124
+ {t().tryAgain}
1125
+ </Button>
1126
+ </NoticeCard>
1127
+ )}
1012
1128
  </Show>
1013
- </header>
1014
- <Show when={loading()}>
1015
- <div class="min-h-[24rem]">
1016
- <Placeholder state="loading" variant="panel" title={t().loading} />
1129
+ <div ref={articleContent}>
1130
+ <Show when={articleParts()}>{(parts) => <MarkdownView trustedHtml={parts().body} class="help-document" />}</Show>
1017
1131
  </div>
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>
1132
+ <Show when={payload()}>{(document) => <ArticleEnd document={document()} />}</Show>
1030
1133
  </div>
1031
- <Show when={payload()}>{(document) => <ArticleEnd document={document()} />}</Show>
1134
+ <aside class="help-article-aside">
1135
+ <div class="help-article-aside-sticky">
1136
+ <TableOfContents
1137
+ items={articleSections()}
1138
+ title={t().onThisPage}
1139
+ scrollRoot={tocScrollRoot}
1140
+ contentRoot={() => articleContent}
1141
+ onActiveChange={(id) => setActiveSectionId(id ?? null)}
1142
+ />
1143
+ <Show when={!copyInMenu}>
1144
+ <Button size="sm" variant="ghost" disabled={!payload()} onClick={() => void copyArticle(topic(), payload())}>
1145
+ <i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
1146
+ {articleClipboard.wasCopied() ? t().copied : t().copyMarkdown}
1147
+ </Button>
1148
+ </Show>
1149
+ </div>
1150
+ </aside>
1032
1151
  </div>
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>
1051
- </article>
1052
- )}
1152
+ </article>
1153
+ )}
1154
+ </Show>
1053
1155
  </Show>
1054
1156
  <span class="sr-only" aria-live="polite">
1055
1157
  {articleClipboard.wasCopied() ? t().articleCopiedAnnouncement : ""}
@@ -1079,14 +1181,15 @@ const createSession = (initialTopic?: string): HelpSession => {
1079
1181
 
1080
1182
  /**
1081
1183
  * Render the shared Help experience as a full page. Apps pass the same
1082
- * manifest used by `Layout.HelpDocuments`; article bodies still load lazily
1083
- * from the central Help API.
1184
+ * manifest used by `Layout.HelpDocuments`; initialArticle supplies the first
1185
+ * article, while other articles load from the central Help API.
1084
1186
  */
1085
1187
  export function LayoutHelpPage(props: LayoutHelpPageProps) {
1086
1188
  const session = createSession(props.initialTopic);
1087
1189
  return (
1088
1190
  <HelpShell
1089
1191
  session={session}
1192
+ initialArticle={props.initialTopic ? props.initialArticle : undefined}
1090
1193
  close={() => {}}
1091
1194
  documents={props.documents}
1092
1195
  includeShortcuts={props.includeShortcuts}
@@ -1098,12 +1201,70 @@ export function LayoutHelpPage(props: LayoutHelpPageProps) {
1098
1201
  );
1099
1202
  }
1100
1203
 
1101
- /** Open the current app's Help over the page; the dialog sits outside the app canvas, so it takes the app's accent along. */
1204
+ /** Help inside a provider document; the server snapshot also owns the first browser render. */
1205
+ export function LayoutHelpPanel(props: LayoutHelpPanelProps) {
1206
+ const session = createSession(props.initialTopic);
1207
+ return (
1208
+ <HelpShell
1209
+ session={session}
1210
+ initialArticle={props.initialTopic ? props.initialArticle : undefined}
1211
+ close={() => {}}
1212
+ documents={props.documents}
1213
+ pageBase={props.pageBase}
1214
+ surface="panel"
1215
+ panel={props}
1216
+ />
1217
+ );
1218
+ }
1219
+
1220
+ /** The dialog renders outside the app canvas; it takes the app's canvas colours along for the article head. */
1221
+ const canvasAppearance = () => {
1222
+ const canvas = document.querySelector(".cloud-app-canvas");
1223
+ if (!canvas) return undefined;
1224
+ const style = getComputedStyle(canvas);
1225
+ return CANVAS_PROPERTIES.flatMap((name) => {
1226
+ const value = style.getPropertyValue(name).trim();
1227
+ return value ? [`${name}:${value}`] : [];
1228
+ }).join(";");
1229
+ };
1230
+ const CANVAS_PROPERTIES = [
1231
+ "--app-canvas-from",
1232
+ "--app-canvas-via",
1233
+ "--app-canvas-to",
1234
+ "--app-canvas-angle",
1235
+ "--app-canvas-strength",
1236
+ "--app-canvas-dark-strength",
1237
+ ];
1238
+
1239
+ /**
1240
+ * Open Core's Help provider in the panel slot, or fall back to the current app's dialog when no slot accepts it
1241
+ * (anonymous pages, `Layout panel={false}`, an app on an older @k2b/cloud). On a phone the fallback is the shared
1242
+ * bottom sheet at full height, like the panel's phone sheet.
1243
+ */
1102
1244
  export function openLayoutHelpDialog() {
1245
+ if (openPanel("core")) return;
1103
1246
  const session = createSession();
1104
1247
  const accent = readShellData()?.accent;
1248
+ const appearance = canvasAppearance();
1249
+ const pageBase = window.__cloudLayoutHelpPageBase;
1250
+ if (window.matchMedia("(max-width: 47.999rem)").matches) {
1251
+ void dialogCore.open<void>(
1252
+ (close, context) => (
1253
+ <BottomSheet onDismiss={context.requestDismiss}>
1254
+ <HelpShell session={session} close={() => close()} accent={accent} appearance={appearance} surface="modal" pageBase={pageBase} />
1255
+ </BottomSheet>
1256
+ ),
1257
+ {
1258
+ ...bottomSheetOptions,
1259
+ panelClassName: `${bottomSheetOptions.panelClassName} cloud-help-sheet`,
1260
+ ariaLabel: helpMessages.resolve([document.documentElement.lang || "en"]).t.help,
1261
+ history: true,
1262
+ },
1263
+ );
1264
+ return;
1265
+ }
1105
1266
  void prompts.dialog<void>(
1106
- (close) => <HelpShell session={session} close={close} accent={accent} surface="modal" pageBase={window.__cloudLayoutHelpPageBase} />,
1267
+ (close) => <HelpShell session={session} close={close} accent={accent} appearance={appearance} surface="modal" pageBase={pageBase} />,
1107
1268
  { surface: "bare", header: false, size: "wide" },
1108
1269
  );
1109
1270
  }