@k2b/cloud 0.40.0 → 0.41.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 (75) hide show
  1. package/package.json +6 -3
  2. package/src/_internal/capability-sentences.ts +2 -1
  3. package/src/_internal/define-app.ts +13 -3
  4. package/src/_internal/heartbeat.ts +203 -14
  5. package/src/_internal/registry.ts +28 -1
  6. package/src/_internal/setup-lock.ts +103 -0
  7. package/src/ai/chat/action-card.ts +69 -3
  8. package/src/ai/chat/action-question.tsx +12 -1
  9. package/src/ai/chat/action-text.ts +288 -0
  10. package/src/ai/chat/tool-action-words.ts +31 -0
  11. package/src/ai/chat/tool-action.tsx +1 -26
  12. package/src/ai/executor.ts +10 -2
  13. package/src/ai/firecrawl-tools.ts +15 -2
  14. package/src/ai/index.ts +1 -0
  15. package/src/ai/migrate.ts +15 -0
  16. package/src/ai/open-questions.ts +172 -0
  17. package/src/ai/question-text.ts +8 -0
  18. package/src/ai/routes.ts +51 -5
  19. package/src/ai/store.ts +18 -0
  20. package/src/ai/testing.ts +36 -0
  21. package/src/ai/tool-sentences.ts +152 -34
  22. package/src/ai/tool-wording.ts +12 -6
  23. package/src/ai/tools.ts +8 -3
  24. package/src/ai/types.ts +16 -2
  25. package/src/api/app-approval-actions.ts +31 -7
  26. package/src/api/auth/schemas.ts +3 -0
  27. package/src/api/auth.ts +54 -18
  28. package/src/api/mcp.ts +27 -3
  29. package/src/api/me.ts +79 -3
  30. package/src/approvals/Approvals.tsx +25 -63
  31. package/src/approvals/approval-card.ts +68 -0
  32. package/src/contracts/app-approval.ts +3 -3
  33. package/src/contracts/capabilities.ts +1 -1
  34. package/src/contracts/shared.ts +3 -0
  35. package/src/server/middleware/auth.ts +4 -2
  36. package/src/services/accounts/app.ts +23 -3
  37. package/src/services/accounts/users.ts +12 -1
  38. package/src/services/approvals/device.ts +49 -33
  39. package/src/services/approvals/index.ts +1 -0
  40. package/src/services/approvals/list.ts +1 -1
  41. package/src/services/approvals/question.ts +68 -0
  42. package/src/services/audit/index.ts +1 -0
  43. package/src/services/auth-flows/ipa.ts +39 -2
  44. package/src/services/auth-flows/password-reset.ts +32 -6
  45. package/src/services/index.ts +2 -0
  46. package/src/services/pdf/markdown.ts +160 -2
  47. package/src/services/session/index.ts +203 -29
  48. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  49. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  50. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  51. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  52. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  53. package/src/ssr/HelpPanelView.island.tsx +27 -0
  54. package/src/ssr/LayoutHelp.tsx +182 -540
  55. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  56. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  57. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  58. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  59. package/src/ssr/help.ts +4 -3
  60. package/src/ssr/index.ts +1 -0
  61. package/src/ssr/islands/index.ts +3 -4
  62. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  63. package/src/ssr/layout-help-panel-document.ts +67 -0
  64. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  65. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  66. package/src/ssr/layout-help-panel-modules.ts +7 -0
  67. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  68. package/src/ssr/layout-help-panel-search.tsx +69 -0
  69. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  70. package/src/ssr/layout-help-panel-state.ts +141 -0
  71. package/src/ssr/layout-help-panel-types.ts +19 -0
  72. package/src/ssr/layout-help-views.tsx +484 -0
  73. package/src/ssr/panel-idle.ts +9 -0
  74. package/src/styles/approvals.css +6 -0
  75. package/src/styles/effects.css +15 -0
@@ -1,4 +1,4 @@
1
- import type { HelpAccessLevel, HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
1
+ import type { HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "@k2b/cloud/shared";
2
2
  import { clipboard } from "@k2b/stdlib/solid";
3
3
  import {
4
4
  BottomSheet,
@@ -7,8 +7,6 @@ import {
7
7
  Dropdown,
8
8
  dialogCore,
9
9
  IconButton,
10
- Kbd,
11
- type KeyboardPlatform,
12
10
  MarkdownView,
13
11
  NoticeCard,
14
12
  Placeholder,
@@ -16,14 +14,12 @@ import {
16
14
  prompts,
17
15
  ScrollArea,
18
16
  TableOfContents,
19
- TextInput,
20
17
  toast,
21
18
  useLocale,
22
19
  } from "@k2b/ui";
23
- import { createEffect, createMemo, createSignal, For, type JSX, onCleanup, onMount, Show, untrack } from "solid-js";
20
+ import { createEffect, createMemo, createSignal, type JSX, onCleanup, onMount, Show, untrack } from "solid-js";
24
21
  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";
22
+ import { openPanel } from "../browser/panel";
27
23
  import { HELP_ACCESS_LEVELS, HELP_KINDS } from "../shared/help";
28
24
  import { appAccentStyle } from "./app-appearance";
29
25
  import type { LayoutHelpArticle } from "./help";
@@ -33,14 +29,9 @@ import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-h
33
29
  import { focusHelpArticleHeading, resetHelpArticleScroll } from "./layout-help-navigation";
34
30
  import { HELP_TOPICS_EVENT, type HelpTopic, registry } from "./layout-help-registry";
35
31
  import { searchHelpTopics } from "./layout-help-search";
36
- import {
37
- type HelpShortcutCommand,
38
- type HelpShortcutScope,
39
- type HelpStartGroupKind,
40
- helpShortcutGroups,
41
- helpStart,
42
- } from "./layout-help-start";
32
+ import { type HelpShortcutCommand, helpShortcutGroups, helpStart } from "./layout-help-start";
43
33
  import { helpLinkTarget, layoutHelpTopicHref } from "./layout-help-url";
34
+ import { HelpArticleView, HelpHead, HelpResultsView, HelpShortcutsView, HelpStartView, type HelpSurface } from "./layout-help-views";
44
35
  import { platformMessages } from "./platform-messages";
45
36
  import { readShellData, useShellData } from "./shell-data";
46
37
  import { shellShortcuts } from "./shell-shortcuts";
@@ -58,22 +49,6 @@ export type LayoutHelpPageProps = {
58
49
  pageBase?: string;
59
50
  };
60
51
 
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
-
77
52
  type HelpView = "hub" | "article";
78
53
  type HelpSession = {
79
54
  view: HelpView;
@@ -101,8 +76,6 @@ const articleLink = (value: unknown): HelpArticleLink | undefined => {
101
76
  return { appId, appName, accent, documentId, title, description, icon, url };
102
77
  };
103
78
 
104
- /** An article without its own icon gets a neutral page tile. */
105
- const iconClass = (icon?: string) => (icon?.startsWith("ti ") ? icon : `ti ${icon ?? "ti-file-text"}`);
106
79
  const byOrder = (a: HelpTopic, b: HelpTopic) => (a.order ?? 100) - (b.order ?? 100) || a.title.localeCompare(b.title);
107
80
  const sortedTopics = () => [...(registry()?.values() ?? [])].sort(byOrder);
108
81
  const mergeTopics = (registered: readonly HelpTopic[], documents: readonly HelpDocumentManifest[] = []): HelpTopic[] => {
@@ -110,11 +83,6 @@ const mergeTopics = (registered: readonly HelpTopic[], documents: readonly HelpD
110
83
  for (const topic of documents) topics.set(topic.id, topic);
111
84
  return [...topics.values()].sort(byOrder);
112
85
  };
113
- const GROUP_ICONS: Record<HelpStartGroupKind, string> = {
114
- start: "ti ti-rocket",
115
- reference: "ti ti-book-2",
116
- troubleshoot: "ti ti-lifebuoy",
117
- };
118
86
 
119
87
  /**
120
88
  * Keyboard shortcuts come last in the start. The browser knows the page's commands. On a Layout page (it has shell
@@ -122,68 +90,22 @@ const GROUP_ICONS: Record<HelpStartGroupKind, string> = {
122
90
  * first client render on, also before the shell has registered them. The groups reserve their height and place in CSS,
123
91
  * so the ones the browser adds fill space that is already there.
124
92
  */
125
- const StartShortcuts = (props: { appName?: string; panel?: boolean; platform?: KeyboardPlatform }) => {
93
+ const StartShortcuts = (props: { appName?: string }) => {
126
94
  const locale = useLocale();
127
- const t = () => helpMessages.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 }) : [];
95
+ const shell = useShellData();
96
+ const seed: HelpShortcutCommand[] = shell ? shellShortcuts(platformMessages.resolve([locale()]).t, { panel: !!shell.panel }) : [];
131
97
  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;
134
98
  const commands = contextCommandsWithShortcuts();
135
99
  return [...seed.filter((command) => !commands.some((other) => other.id === command.id)), ...commands];
136
100
  };
137
- const [commands, setCommands] = createSignal<HelpShortcutCommand[]>(props.panel ? seed : read());
101
+ const [commands, setCommands] = createSignal<HelpShortcutCommand[]>(read());
138
102
  onMount(() => {
139
103
  const update = () => setCommands(read());
140
- if (props.panel) {
141
- onCleanup(onPanelShortcutsChange(update));
142
- onCleanup(onContextChange(update));
143
- }
144
104
  update();
145
- if (!props.panel) {
146
- window.addEventListener(COMMANDS_CHANGED, update);
147
- onCleanup(() => window.removeEventListener(COMMANDS_CHANGED, update));
148
- }
105
+ window.addEventListener(COMMANDS_CHANGED, update);
106
+ onCleanup(() => window.removeEventListener(COMMANDS_CHANGED, update));
149
107
  });
150
- const label = (scope: HelpShortcutScope) =>
151
- scope === "everywhere"
152
- ? t().shortcutsEverywhere
153
- : scope === "selection"
154
- ? t().shortcutsWithSelection
155
- : props.appName
156
- ? t().shortcutsInApp({ app: props.appName })
157
- : t().shortcutsInThisApp;
158
- return (
159
- <section class="help-start-section help-start-shortcuts" data-help-shortcuts="" aria-labelledby="help-start-shortcuts-title">
160
- <h3 id="help-start-shortcuts-title" class="help-start-heading">
161
- <i class="ti ti-keyboard" aria-hidden="true" />
162
- {t().keyboardShortcuts}
163
- </h3>
164
- <div class="help-shortcut-groups">
165
- <For each={helpShortcutGroups(commands())}>
166
- {(group) => (
167
- <section class="help-shortcut-group" data-help-shortcut-group={group.scope} aria-label={label(group.scope)}>
168
- <h4 class="help-shortcut-group-title" aria-hidden="true">
169
- {label(group.scope)}
170
- </h4>
171
- <ul>
172
- <For each={group.shortcuts}>
173
- {(command) => (
174
- <li class="help-shortcut" title={command.description}>
175
- <span class="help-shortcut-title">{command.title}</span>
176
- <Kbd keys={command.shortcut} platform={props.platform ?? shell?.platform} />
177
- </li>
178
- )}
179
- </For>
180
- </ul>
181
- </section>
182
- )}
183
- </For>
184
- </div>
185
- </section>
186
- );
108
+ return <HelpShortcutsView groups={helpShortcutGroups(commands())} appName={props.appName} platform={shell?.platform} />;
187
109
  };
188
110
 
189
111
  const HelpShell = (props: {
@@ -196,29 +118,29 @@ const HelpShell = (props: {
196
118
  accent?: string;
197
119
  /** The app's canvas colours for a reader outside the app canvas. */
198
120
  appearance?: string;
199
- surface?: "modal" | "page" | "embedded" | "panel";
200
- panel?: LayoutHelpPanelProps;
121
+ surface: HelpSurface;
201
122
  syncPageUrl?: boolean;
202
123
  }) => {
203
124
  const locale = useLocale();
204
125
  const t = () => helpMessages.resolve([locale()]).t;
205
126
  // Links to this app's Help, by its own route or Core's `/help/apps/<id>`, open in place.
206
- const shell = props.surface === "panel" ? undefined : useShellData();
207
- const appId = props.panel?.app?.id ?? shell?.appId;
127
+ const shell = useShellData();
128
+ const appId = shell?.appId;
208
129
  const app = shell?.apps.find((candidate) => candidate.id === appId);
209
- const appName = props.panel?.app?.name ?? app?.label;
210
- const [viewIds, setViewIds] = createSignal(props.panel?.view ?? []);
211
- const registeredTopics = () => (props.surface === "panel" ? [] : sortedTopics());
212
- const accessLabel = (level: HelpAccessLevel) => ({ view: t().accessView, edit: t().accessEdit, manage: t().accessManage })[level];
130
+ const appName = app?.label;
131
+ const registeredTopics = () => sortedTopics();
213
132
  const [externalTopics, setExternalTopics] = createSignal(mergeTopics(registeredTopics(), props.documents));
214
133
  const [view, setView] = createSignal<HelpView>(props.session.view);
215
134
  const [query, setQuery] = createSignal(props.session.query);
216
135
  const [activeId, setActiveId] = createSignal<string | null>(props.session.activeId);
217
136
  const initialTopic = props.documents?.find((topic) => topic.id === props.session.activeId);
218
137
  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);
138
+ // HTML travels once as parts; rebuild it for the cache and the article settle effect.
139
+ const seeded = initialArticle
140
+ ? { ...initialArticle.payload, html: (initialArticle.parts.lead ?? "") + initialArticle.parts.body }
141
+ : undefined;
142
+ if (seeded && initialTopic) props.session.articleCache.set(`${initialTopic.locale ?? "en"}:${initialTopic.url}`, seeded);
143
+ const [payload, setPayload] = createSignal<HelpDocumentPayload | null>(seeded ?? null);
222
144
  const [loading, setLoading] = createSignal(props.session.view === "article" && !initialArticle);
223
145
  const [loadError, setLoadError] = createSignal<string | null>(null);
224
146
  const [loadAttempt, setLoadAttempt] = createSignal(0);
@@ -266,7 +188,7 @@ const HelpShell = (props: {
266
188
  };
267
189
 
268
190
  const restoreArticleScroll = () => {
269
- if ((props.surface !== "modal" && props.surface !== "panel") || pendingNavigation || pendingHash) return;
191
+ if (props.surface !== "modal" || pendingNavigation || pendingHash) return;
270
192
  requestAnimationFrame(() => {
271
193
  if (scrollArea && view() === "article") scrollArea.scrollTop = props.session.articleScrollTop;
272
194
  });
@@ -311,14 +233,12 @@ const HelpShell = (props: {
311
233
  };
312
234
 
313
235
  const topics = externalTopics;
314
- const start = createMemo(() =>
315
- helpStart(topics(), props.surface === "panel" ? viewIds() : props.surface === "modal" ? shell?.help : undefined),
316
- );
236
+ const start = createMemo(() => helpStart(topics(), props.surface === "modal" ? shell?.help : undefined));
317
237
  const activeTopic = createMemo(() => topics().find((topic) => topic.id === activeId()) ?? null);
318
238
  // Reuse the serialized parts for the initial object on both sides of the island boundary.
319
239
  const articleParts = createMemo(() => {
320
240
  const article = payload();
321
- if (initialArticle && article === initialArticle.payload) return initialArticle.parts;
241
+ if (initialArticle && article === seeded) return initialArticle.parts;
322
242
  return article?.html ? helpArticleParts(article.html) : null;
323
243
  });
324
244
  const articleSections = (): HelpSection[] => (view() === "article" ? articleParts()?.sections : undefined) ?? [];
@@ -409,10 +329,7 @@ const HelpShell = (props: {
409
329
  createEffect(syncSession);
410
330
 
411
331
  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") {
332
+ if (props.surface === "modal") {
416
333
  const key = (event: KeyboardEvent) => {
417
334
  if (event.defaultPrevented || event.isComposing) return;
418
335
  const target = event.target;
@@ -445,7 +362,7 @@ const HelpShell = (props: {
445
362
  const onScroll = () => {
446
363
  if (view() === "article" && scrollArea) props.session.articleScrollTop = scrollArea.scrollTop;
447
364
  };
448
- if (props.surface === "modal" || props.surface === "panel") scrollArea?.addEventListener("scroll", onScroll, { passive: true });
365
+ if (props.surface === "modal") scrollArea?.addEventListener("scroll", onScroll, { passive: true });
449
366
  // Layout's breakpoints move page scrolling between its main column and the window; the contents follow.
450
367
  let resizeFrame: number | undefined;
451
368
  const onResize = () => {
@@ -704,99 +621,6 @@ const HelpShell = (props: {
704
621
  }
705
622
  };
706
623
 
707
- /** Rows: icon tile, title, and description; the start's groups, its further tasks, and search results use them. */
708
- const TopicList = (listProps: { items: HelpTopic[]; marker?: string }) => (
709
- <ul class="help-start-rows" {...(listProps.marker ? { [listProps.marker]: "" } : {})}>
710
- <For each={listProps.items}>
711
- {(topic) => (
712
- <li>
713
- <button type="button" class="help-start-row focus-ui" data-help-topic={topic.id} onClick={() => openTopic(topic.id)}>
714
- <span class="help-topic-icon k2b-icon-tile help-start-row-icon app-accent-text" aria-hidden="true">
715
- <i class={iconClass(topic.icon)} />
716
- </span>
717
- <span class="help-article-link-copy">
718
- <span class="help-article-link-title">{topic.title}</span>
719
- <Show when={topic.description}>{(description) => <span class="help-article-link-description">{description()}</span>}</Show>
720
- </span>
721
- <i class="ti ti-chevron-right help-start-row-arrow" aria-hidden="true" />
722
- </button>
723
- </li>
724
- )}
725
- </For>
726
- </ul>
727
- );
728
-
729
- /** At most eight tasks as tiles; the app colour shows only in the icon. */
730
- const TaskTiles = (tilesProps: { items: HelpTopic[] }) => (
731
- <ul class="help-start-tiles" data-help-tiles="">
732
- <For each={tilesProps.items}>
733
- {(topic) => (
734
- <li>
735
- <button type="button" class="help-start-tile focus-ui" data-help-topic={topic.id} onClick={() => openTopic(topic.id)}>
736
- <span class="help-topic-icon k2b-icon-tile help-start-tile-icon app-accent-text" aria-hidden="true">
737
- <i class={iconClass(topic.icon)} />
738
- </span>
739
- <span class="help-start-tile-title">{topic.title}</span>
740
- <Show when={topic.description}>{(description) => <span class="help-start-tile-description">{description()}</span>}</Show>
741
- </button>
742
- </li>
743
- )}
744
- </For>
745
- </ul>
746
- );
747
-
748
- const groupTitle = (kind: HelpStartGroupKind) =>
749
- ({ start: t().groupStart, reference: t().groupReference, troubleshoot: t().groupTroubleshoot })[kind];
750
-
751
- const ArticleLinkTitle = (link: { link: HelpArticleLink }) => (
752
- <span class="help-article-link-copy">
753
- <span class="help-article-link-title">{link.link.title}</span>
754
- <Show when={link.link.description}>{(description) => <span class="help-article-link-description">{description()}</span>}</Show>
755
- </span>
756
- );
757
-
758
- /** "Next" leads on; related articles keep their own app's colour, and another app says whose article it is. */
759
- const ArticleEnd = (end: { document: HelpDocumentPayload }) => (
760
- <Show when={end.document.next || end.document.related.length > 0}>
761
- <footer class="help-article-end">
762
- <Show when={end.document.next}>
763
- {(next) => (
764
- <a class="help-article-next app-accent-scope focus-ui" href={next().url} style={appAccentStyle(next().accent)}>
765
- <span class="help-article-next-label">{t().next}</span>
766
- <span class="help-article-next-row">
767
- <i class={`${iconClass(next().icon)} help-article-next-icon`} aria-hidden="true" />
768
- <ArticleLinkTitle link={next()} />
769
- <i class="ti ti-arrow-right help-article-next-arrow" aria-hidden="true" />
770
- </span>
771
- </a>
772
- )}
773
- </Show>
774
- <Show when={end.document.related.length > 0}>
775
- <section class="help-article-related" aria-labelledby="help-article-related-title">
776
- <h4 id="help-article-related-title">{t().related}</h4>
777
- <ul>
778
- <For each={end.document.related}>
779
- {(link) => (
780
- <li>
781
- <a class="help-article-related-link app-accent-scope focus-ui" href={link.url} style={appAccentStyle(link.accent)}>
782
- <span class="help-topic-icon k2b-icon-tile help-article-related-icon app-accent-text" aria-hidden="true">
783
- <i class={iconClass(link.icon)} />
784
- </span>
785
- <ArticleLinkTitle link={link} />
786
- <Show when={link.appId !== appId}>
787
- <span class="help-article-related-app app-accent-text">{link.appName}</span>
788
- </Show>
789
- </a>
790
- </li>
791
- )}
792
- </For>
793
- </ul>
794
- </section>
795
- </Show>
796
- </footer>
797
- </Show>
798
- );
799
-
800
624
  const ReaderContent = (content: { children: JSX.Element }) =>
801
625
  props.surface === "page" ? (
802
626
  <div class="p-4 sm:p-5">{content.children}</div>
@@ -806,8 +630,8 @@ const HelpShell = (props: {
806
630
  </ScrollArea>
807
631
  );
808
632
 
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";
633
+ // The dialog copies from its ⋯ menu; only the page repeats the copy action beside the article.
634
+ const copyInMenu = props.surface === "modal";
811
635
  const pageHref = () => (props.pageBase ? layoutHelpTopicHref(props.pageBase, view() === "article" ? activeId() : null) : undefined);
812
636
  const menuItems = () => [
813
637
  {
@@ -823,335 +647,169 @@ const HelpShell = (props: {
823
647
  return (
824
648
  <div
825
649
  ref={root}
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
- }
650
+ class={`help-shell app-accent-scope flex min-h-0 flex-col ${props.surface === "page" ? "" : "bg-[var(--ui-surface-raised)]"} ${
651
+ props.surface === "modal"
652
+ ? "h-[min(48rem,var(--ui-dialog-available-height))] overflow-hidden panel-dialog-shell [box-shadow:var(--ui-shadow-float)]"
653
+ : props.surface === "page"
654
+ ? ""
655
+ : "h-full"
656
+ }`}
837
657
  data-surface={props.surface}
838
- style={
839
- props.surface === "panel"
840
- ? props.panel?.appearance
841
- : [appAccentStyle(props.accent), props.appearance].filter(Boolean).join(";") || undefined
842
- }
658
+ style={[appAccentStyle(props.accent), props.appearance].filter(Boolean).join(";") || undefined}
843
659
  >
844
- {/* One head in every view: the search never moves between the start, an article, and results. An app's embedded
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. */}
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"}>
848
- <header class="help-head" data-help-head="">
849
- <div class="help-head-inner help-measure">
850
- <Show when={props.surface !== "embedded"}>
851
- <div class="help-head-row">
852
- {/* The back button takes the app tile's place in an article, so nothing beside it moves. */}
853
- <Show
854
- when={view() === "article"}
855
- fallback={
856
- <span class="help-head-tile" aria-hidden="true">
857
- <i class={props.panel?.app?.icon ?? app?.iconClass ?? "ti ti-help-circle"} />
858
- </span>
859
- }
860
- >
861
- <IconButton class="help-head-back" label={t().backToTopics} title={t().back} onClick={goBack}>
862
- <i class="ti ti-arrow-left" />
863
- </IconButton>
864
- </Show>
865
- <h2 id="layout-help-title" class="help-head-title">
866
- {appName ? (props.surface === "panel" ? appName : t().helpFor({ app: appName })) : t().help}
867
- </h2>
868
- <div class="help-head-actions" role="group" aria-label={t().actions}>
869
- <Dropdown.Root label={t().moreActions} position="bottom-left" items={menuItems()}>
870
- <Dropdown.Trigger iconOnly label={t().moreActions} variant="ghost" data-help-menu="">
871
- <i class="ti ti-dots" aria-hidden="true" />
872
- </Dropdown.Trigger>
873
- </Dropdown.Root>
874
- <Show when={props.surface === "modal"}>
875
- <IconButton label={t().closeHelp} title={t().close} onClick={props.close}>
876
- <i class="ti ti-x" />
877
- </IconButton>
878
- </Show>
879
- </div>
880
- </div>
881
- </Show>
882
- <TextInput
883
- ref={searchInput}
884
- type="search"
885
- class="help-head-search"
886
- icon="ti ti-search"
887
- aria-label={t().searchHelp}
888
- data-help-search=""
889
- value={query()}
890
- placeholder={t().searchPlaceholder}
891
- onValueChange={changeQuery}
892
- />
893
- </div>
894
- </header>
660
+ <Show when={props.surface !== "embedded" || view() === "hub"}>
661
+ <HelpHead
662
+ row={props.surface !== "embedded"}
663
+ back={view() === "article"}
664
+ tile={app?.iconClass ?? "ti ti-help-circle"}
665
+ title={appName ? t().helpFor({ app: appName }) : t().help}
666
+ menu={
667
+ <Dropdown.Root label={t().moreActions} position="bottom-left" items={menuItems()}>
668
+ <Dropdown.Trigger iconOnly label={t().moreActions} variant="ghost" data-help-menu="">
669
+ <i class="ti ti-dots" aria-hidden="true" />
670
+ </Dropdown.Trigger>
671
+ </Dropdown.Root>
672
+ }
673
+ closable={props.surface === "modal"}
674
+ onBack={goBack}
675
+ onClose={props.close}
676
+ query={query()}
677
+ onQuery={changeQuery}
678
+ searchRef={(element) => {
679
+ searchInput = element;
680
+ }}
681
+ />
895
682
  </Show>
896
683
 
897
684
  <ReaderContent>
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>
916
- </Show>
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" />
926
- </Show>
927
- </section>
928
- </Show>
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>
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>
685
+ <Show when={view() === "hub"}>
686
+ <div ref={resultsRegion} data-help-results="" class="help-start help-measure" style={{ "min-height": resultsMinHeight() }}>
687
+ <Show
688
+ when={normalizedQuery()}
689
+ fallback={
690
+ <HelpStartView
691
+ start={start()}
692
+ empty={topics().length === 0}
693
+ failed={!!shell?.helpFailed}
694
+ onOpen={openTopic}
695
+ onReload={() => location.reload()}
696
+ shortcuts={
961
697
  <Show when={props.includeShortcuts !== false}>
962
- <StartShortcuts appName={appName} panel={props.surface === "panel"} platform={props.panel?.platform} />
698
+ <StartShortcuts appName={appName} />
963
699
  </Show>
964
- </>
700
+ }
701
+ />
702
+ }
703
+ >
704
+ <HelpResultsView
705
+ results={results()}
706
+ searching={searching()}
707
+ failed={searchFailed()}
708
+ onRetry={() => setSearchAttempt((value) => value + 1)}
709
+ onOpen={openTopic}
710
+ />
711
+ </Show>
712
+ </div>
713
+ </Show>
714
+
715
+ <Show when={view() === "article" && activeTopic()}>
716
+ {(topic) => (
717
+ <article
718
+ class="help-article"
719
+ data-surface={props.surface}
720
+ ref={(element) => {
721
+ // Delegated from the article: its body, table of contents, and end hold the links.
722
+ element.addEventListener("click", followHelpLink);
723
+ onCleanup(() => element.removeEventListener("click", followHelpLink));
724
+ }}
725
+ >
726
+ <HelpArticleView
727
+ surface={props.surface}
728
+ topic={topic()}
729
+ appName={appName}
730
+ appId={appId}
731
+ parts={articleParts()}
732
+ document={payload()}
733
+ Markdown={MarkdownView}
734
+ showTools={articleSections().length > 0 || !copyInMenu}
735
+ sectionMenu={
736
+ <Show when={articleSections().length > 0}>
737
+ <Dropdown.Root
738
+ label={t().onThisPage}
739
+ position="bottom-left"
740
+ items={articleSections().map((section) => ({
741
+ label: section.label,
742
+ choice: "radio" as const,
743
+ checked: () => activeSectionId() === section.id,
744
+ action: () => openArticleSection(section),
745
+ }))}
746
+ >
747
+ <Dropdown.Trigger label={t().onThisPage} size="sm" tooltip={false} variant="secondary">
748
+ <i class="ti ti-list" aria-hidden="true" />
749
+ <span>{t().onThisPage}</span>
750
+ <i class="ti ti-chevron-down" aria-hidden="true" />
751
+ </Dropdown.Trigger>
752
+ </Dropdown.Root>
753
+ </Show>
965
754
  }
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()} />
755
+ copyButton={
756
+ <Show when={!copyInMenu}>
757
+ <IconButton
758
+ class="help-article-tools-copy"
759
+ disabled={!payload()}
760
+ label={articleClipboard.wasCopied() ? t().articleCopied : t().copyArticle}
761
+ title={articleClipboard.wasCopied() ? t().copied : t().copyArticle}
762
+ onClick={() => void copyArticle(topic(), payload())}
763
+ >
764
+ <i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
765
+ </IconButton>
1000
766
  </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>
1036
- </Show>
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" />}
1050
- </Show>
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>
1073
- </Show>
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>
1106
- </Show>
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
- )}
1128
- </Show>
1129
- <div ref={articleContent}>
1130
- <Show when={articleParts()}>{(parts) => <MarkdownView trustedHtml={parts().body} class="help-document" />}</Show>
767
+ }
768
+ loading={
769
+ <Show when={loading()}>
770
+ <div class="min-h-[24rem]">
771
+ <Placeholder state="loading" variant="panel" title={t().loading} />
1131
772
  </div>
1132
- <Show when={payload()}>{(document) => <ArticleEnd document={document()} />}</Show>
1133
- </div>
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}
773
+ </Show>
774
+ }
775
+ failure={
776
+ <Show when={loadError()}>
777
+ {(message) => (
778
+ <NoticeCard tone="danger" title={t().loadFailed} detail={message()}>
779
+ <Button size="sm" variant="secondary" onClick={() => setLoadAttempt((value) => value + 1)}>
780
+ {t().tryAgain}
1147
781
  </Button>
1148
- </Show>
1149
- </div>
1150
- </aside>
1151
- </div>
1152
- </article>
1153
- )}
1154
- </Show>
782
+ </NoticeCard>
783
+ )}
784
+ </Show>
785
+ }
786
+ toc={
787
+ <TableOfContents
788
+ items={articleSections()}
789
+ title={t().onThisPage}
790
+ scrollRoot={tocScrollRoot}
791
+ contentRoot={() => articleContent}
792
+ onActiveChange={(id) => setActiveSectionId(id ?? null)}
793
+ />
794
+ }
795
+ copyAction={
796
+ <Show when={!copyInMenu}>
797
+ <Button size="sm" variant="ghost" disabled={!payload()} onClick={() => void copyArticle(topic(), payload())}>
798
+ <i class={articleClipboard.wasCopied() ? "ti ti-check" : "ti ti-markdown"} />
799
+ {articleClipboard.wasCopied() ? t().copied : t().copyMarkdown}
800
+ </Button>
801
+ </Show>
802
+ }
803
+ headingRef={(element) => {
804
+ articleHeading = element;
805
+ }}
806
+ contentRef={(element) => {
807
+ articleContent = element;
808
+ }}
809
+ onBack={goBack}
810
+ />
811
+ </article>
812
+ )}
1155
813
  </Show>
1156
814
  <span class="sr-only" aria-live="polite">
1157
815
  {articleClipboard.wasCopied() ? t().articleCopiedAnnouncement : ""}
@@ -1201,22 +859,6 @@ export function LayoutHelpPage(props: LayoutHelpPageProps) {
1201
859
  );
1202
860
  }
1203
861
 
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
862
  /** The dialog renders outside the app canvas; it takes the app's canvas colours along for the article head. */
1221
863
  const canvasAppearance = () => {
1222
864
  const canvas = document.querySelector(".cloud-app-canvas");