@k2b/cloud 0.39.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 (237) hide show
  1. package/README.md +8 -0
  2. package/package.json +11 -4
  3. package/src/_internal/app-favicon.ts +4 -2
  4. package/src/_internal/capabilities.ts +72 -48
  5. package/src/_internal/capability-reviews.ts +81 -0
  6. package/src/_internal/capability-schemas.ts +29 -0
  7. package/src/_internal/capability-sentences.ts +217 -1
  8. package/src/_internal/capability-streams.ts +7 -0
  9. package/src/_internal/define-app.ts +49 -18
  10. package/src/_internal/font-preloads.ts +2 -1
  11. package/src/_internal/heartbeat.ts +203 -14
  12. package/src/_internal/registry.ts +28 -1
  13. package/src/_internal/runtime-watcher.ts +6 -0
  14. package/src/_internal/setup-lock.ts +103 -0
  15. package/src/_internal/static-assets.ts +52 -3
  16. package/src/_internal/status-preserving-ssr.ts +2 -0
  17. package/src/access/PermissionEditor.tsx +2 -2
  18. package/src/account/EntitySearch.tsx +1 -1
  19. package/src/ai/action-review.ts +101 -0
  20. package/src/ai/background-review.ts +72 -0
  21. package/src/ai/capabilities.ts +29 -11
  22. package/src/ai/capability-execution.ts +76 -43
  23. package/src/ai/capability-result.ts +95 -0
  24. package/src/ai/chat/action-card.ts +464 -0
  25. package/src/ai/chat/action-messages.ts +129 -0
  26. package/src/ai/chat/action-question.browser-harness.tsx +245 -0
  27. package/src/ai/chat/action-question.fixtures.ts +733 -0
  28. package/src/ai/chat/action-question.tsx +680 -0
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/blocks.tsx +19 -25
  31. package/src/ai/chat/message-actions.tsx +6 -0
  32. package/src/ai/chat/tool-action-words.ts +31 -0
  33. package/src/ai/chat/tool-action.tsx +456 -0
  34. package/src/ai/chat/turn-view.tsx +10 -160
  35. package/src/ai/chat/view-block.tsx +15 -5
  36. package/src/ai/chat-task-contracts.ts +4 -1
  37. package/src/ai/chat-task-reasons-migrate.ts +7 -0
  38. package/src/ai/chat-task-reasons.ts +1 -1
  39. package/src/ai/chat-tasks.ts +55 -25
  40. package/src/ai/client/controller.ts +59 -0
  41. package/src/ai/default-tools.ts +9 -1
  42. package/src/ai/executor.ts +198 -39
  43. package/src/ai/firecrawl-tools.ts +15 -2
  44. package/src/ai/index.ts +3 -0
  45. package/src/ai/migrate.ts +26 -2
  46. package/src/ai/open-questions.ts +172 -0
  47. package/src/ai/projects-routes.ts +3 -0
  48. package/src/ai/protocol.ts +14 -2
  49. package/src/ai/question-text.ts +8 -0
  50. package/src/ai/recent-resources.ts +240 -0
  51. package/src/ai/recent-sent-refs.ts +38 -0
  52. package/src/ai/routes.ts +195 -5
  53. package/src/ai/runtime.ts +1 -0
  54. package/src/ai/skills-routes.ts +3 -0
  55. package/src/ai/skills.ts +10 -1
  56. package/src/ai/store.ts +245 -24
  57. package/src/ai/testing.ts +36 -0
  58. package/src/ai/tool-audit.ts +8 -0
  59. package/src/ai/tool-sentences.ts +152 -34
  60. package/src/ai/tool-wording.ts +36 -8
  61. package/src/ai/tools.ts +8 -3
  62. package/src/ai/types.ts +43 -4
  63. package/src/api/accounts-entities.ts +2 -1
  64. package/src/api/admin-ai-projects.ts +2 -0
  65. package/src/api/admin-ai-skills.ts +2 -0
  66. package/src/api/admin-ai-usage.ts +2 -0
  67. package/src/api/admin-geoip.ts +80 -0
  68. package/src/api/admin-identity.ts +3 -0
  69. package/src/api/admin-lifecycle.ts +3 -0
  70. package/src/api/admin-outgoing-mail.ts +2 -0
  71. package/src/api/app-approval-actions.ts +31 -7
  72. package/src/api/app-approval.ts +46 -5
  73. package/src/api/approvals-loader.ts +23 -1
  74. package/src/api/approvals.ts +2 -0
  75. package/src/api/auth/schemas.ts +3 -0
  76. package/src/api/auth.ts +67 -12
  77. package/src/api/capabilities.ts +44 -11
  78. package/src/api/capability-streams.ts +12 -0
  79. package/src/api/help.ts +4 -28
  80. package/src/api/index.ts +6 -1
  81. package/src/api/lasting-access-messages.ts +17 -0
  82. package/src/api/mcp.ts +30 -3
  83. package/src/api/me-panel-consent.ts +71 -0
  84. package/src/api/me.ts +159 -36
  85. package/src/api/search.ts +3 -1
  86. package/src/api/sync-ops.ts +2 -0
  87. package/src/api/widgets.ts +3 -0
  88. package/src/approvals/Approvals.tsx +166 -262
  89. package/src/approvals/approval-card.ts +68 -0
  90. package/src/approvals/messages.ts +12 -24
  91. package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
  92. package/src/browser/app-approval.ts +54 -5
  93. package/src/browser/commands.ts +22 -6
  94. package/src/browser/composer-catalog.ts +45 -3
  95. package/src/browser/composer-insert.ts +10 -0
  96. package/src/browser/composer-open-messages.ts +17 -0
  97. package/src/browser/composer-open.ts +110 -0
  98. package/src/browser/discoverable-command.ts +6 -0
  99. package/src/browser/hints.ts +6 -0
  100. package/src/browser/live-connection.ts +26 -2
  101. package/src/browser/navigation-apps.ts +7 -0
  102. package/src/browser/panel-context-bridge.ts +30 -0
  103. package/src/browser/panel-context-receiver.ts +53 -0
  104. package/src/browser/panel-menu.tsx +56 -0
  105. package/src/browser/panel-messages.ts +11 -0
  106. package/src/browser/panel-shortcuts.ts +31 -0
  107. package/src/browser/panel-slot.ts +412 -10
  108. package/src/browser/panel.ts +37 -0
  109. package/src/browser/search-commands.ts +6 -3
  110. package/src/capabilities/client.ts +8 -2
  111. package/src/capabilities/reviews.ts +205 -0
  112. package/src/capabilities/server.ts +10 -3
  113. package/src/capabilities/testing.ts +47 -0
  114. package/src/capabilities/types.ts +2 -2
  115. package/src/cli/account.ts +13 -3
  116. package/src/cli/admin/geoip.ts +115 -0
  117. package/src/cli/admin/index.ts +3 -0
  118. package/src/contracts/app-approval.ts +123 -16
  119. package/src/contracts/approvals.ts +2 -2
  120. package/src/contracts/capabilities.ts +410 -18
  121. package/src/contracts/geoip.ts +42 -0
  122. package/src/contracts/index.ts +3 -0
  123. package/src/contracts/panel-context-plain.ts +99 -0
  124. package/src/contracts/panel-context.ts +93 -0
  125. package/src/contracts/seen-hints.ts +35 -0
  126. package/src/contracts/shared.ts +3 -0
  127. package/src/server/client-ip.ts +5 -0
  128. package/src/server/help-payload.ts +38 -0
  129. package/src/server/middleware/auth.ts +13 -1
  130. package/src/server/middleware/middleware.ts +9 -0
  131. package/src/server/middleware/no-transform.ts +19 -0
  132. package/src/server/middleware/rate-limit.ts +4 -6
  133. package/src/server/request-context/address.ts +45 -0
  134. package/src/server/request-context/geoip.ts +380 -0
  135. package/src/server/request-context/index.ts +33 -0
  136. package/src/server/request-context/user-agent.ts +95 -0
  137. package/src/services/accounts/app.ts +23 -3
  138. package/src/services/accounts/users.ts +12 -1
  139. package/src/services/app-approval.ts +382 -59
  140. package/src/services/approvals/device.ts +49 -31
  141. package/src/services/approvals/index.ts +7 -5
  142. package/src/services/approvals/list.ts +27 -6
  143. package/src/services/approvals/question.ts +68 -0
  144. package/src/services/audit/index.ts +1 -0
  145. package/src/services/auth-flows/ipa.ts +39 -2
  146. package/src/services/auth-flows/notification-sender.ts +3 -0
  147. package/src/services/auth-flows/password-reset.ts +32 -6
  148. package/src/services/geoip/index.ts +164 -0
  149. package/src/services/geoip/store.ts +30 -0
  150. package/src/services/geoip/upload.ts +150 -0
  151. package/src/services/identity/routes.ts +5 -1
  152. package/src/services/index.ts +13 -0
  153. package/src/services/known-browsers.ts +312 -0
  154. package/src/services/login-address-store.ts +51 -0
  155. package/src/services/login-context.ts +118 -0
  156. package/src/services/mandates/index.ts +3 -0
  157. package/src/services/mandates/policy.ts +15 -3
  158. package/src/services/notifications/email-frame.ts +1 -1
  159. package/src/services/oauth-tokens.ts +16 -2
  160. package/src/services/panel-context-consent.ts +58 -0
  161. package/src/services/pdf/markdown.ts +160 -2
  162. package/src/services/service-account-credentials.ts +21 -0
  163. package/src/services/session/index.ts +296 -41
  164. package/src/services/session/recent.ts +13 -0
  165. package/src/services/webauthn.ts +12 -0
  166. package/src/shared/email-html.ts +2 -0
  167. package/src/shared/help.ts +3 -0
  168. package/src/shared-text/cursor.ts +22 -0
  169. package/src/shared-text/documents.ts +142 -0
  170. package/src/shared-text/events.ts +43 -0
  171. package/src/shared-text/index.ts +12 -0
  172. package/src/shared-text/log.ts +48 -0
  173. package/src/shared-text/text-diff.ts +23 -0
  174. package/src/shared-text/types.ts +41 -0
  175. package/src/ssr/AdminLayout.tsx +2 -0
  176. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  177. package/src/ssr/CloudShell.island.tsx +10 -2
  178. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  179. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  180. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  181. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  182. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  183. package/src/ssr/HelpPanelView.island.tsx +27 -0
  184. package/src/ssr/Layout.tsx +25 -2
  185. package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
  186. package/src/ssr/LayoutHelp.tsx +222 -419
  187. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +135 -0
  188. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  189. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  190. package/src/ssr/LayoutHelpTrigger.tsx +27 -2
  191. package/src/ssr/MobileNavigation.tsx +2 -2
  192. package/src/ssr/PanelLayout.tsx +9 -1
  193. package/src/ssr/PanelRuntime.island.tsx +7 -0
  194. package/src/ssr/PanelSlot.tsx +53 -3
  195. package/src/ssr/ShellRuntime.island.tsx +11 -2
  196. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  197. package/src/ssr/app-color.ts +14 -9
  198. package/src/ssr/app-navigation.ts +16 -3
  199. package/src/ssr/compact-shell.browser-harness.ts +30 -22
  200. package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
  201. package/src/ssr/help-messages.ts +4 -0
  202. package/src/ssr/help.ts +21 -0
  203. package/src/ssr/hints.ts +25 -0
  204. package/src/ssr/index.ts +3 -0
  205. package/src/ssr/layout-context.ts +2 -0
  206. package/src/ssr/layout-help-article.server.ts +50 -0
  207. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  208. package/src/ssr/layout-help-panel-document.ts +67 -0
  209. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  210. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  211. package/src/ssr/layout-help-panel-modules.ts +7 -0
  212. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  213. package/src/ssr/layout-help-panel-search.tsx +69 -0
  214. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  215. package/src/ssr/layout-help-panel-state.ts +141 -0
  216. package/src/ssr/layout-help-panel-types.ts +19 -0
  217. package/src/ssr/layout-help-start.ts +33 -7
  218. package/src/ssr/layout-help-views.tsx +484 -0
  219. package/src/ssr/panel-consent.ts +29 -0
  220. package/src/ssr/panel-context.ts +10 -0
  221. package/src/ssr/panel-idle.ts +9 -0
  222. package/src/ssr/panel-slot.browser-harness.tsx +53 -4
  223. package/src/ssr/platform-messages.ts +18 -0
  224. package/src/ssr/shell-data.tsx +2 -1
  225. package/src/styles/approvals.css +6 -0
  226. package/src/styles/effects.css +213 -9
  227. package/src/styles/file-chooser.css +2 -1
  228. package/src/styles/global.css +49 -12
  229. package/src/styles/panel.css +47 -4
  230. package/src/styles/resource-search.css +6 -2
  231. package/src/styles/tokens.css +17 -11
  232. package/src/styles/utilities-buttons.css +3 -2
  233. package/src/styles/utilities-data.css +2 -1
  234. package/src/styles/utilities-feedback.css +2 -2
  235. package/src/styles/utilities-layout.css +3 -13
  236. package/src/styles/utilities-markdown-editor.css +6 -6
  237. package/src/styles/utilities-navigation.css +17 -22
@@ -1,6 +1,7 @@
1
1
  import { isOwnAppPath } from "../_internal/app-path";
2
2
  import { hasAnyAppRole } from "../_internal/app-roles";
3
3
  import type { RuntimeAppMeta } from "../contracts/app";
4
+ import { panelProviderNeedsConsent } from "../contracts/panel-context-plain";
4
5
  import { isPwaPartId } from "../contracts/pwa-paths";
5
6
  import type { User } from "../contracts/shared";
6
7
  import { resolveAppPresentation } from "../shared/app-presentation";
@@ -54,7 +55,7 @@ export const visiblePwaParts = (apps: readonly RuntimeAppMeta[], user: User | un
54
55
  .sort((a, b) => a.name.localeCompare(b.name, locale) || a.id.localeCompare(b.id));
55
56
  };
56
57
 
57
- export type PanelProvider = { id: string; label: string; icon: string; href: string };
58
+ export type PanelProvider = { id: string; label: string; icon: string; href: string; appName: string; consent: boolean };
58
59
 
59
60
  /** Authorized presentation only: the panel route still authorizes every request. */
60
61
  export const visiblePanelProviders = (apps: readonly RuntimeAppMeta[], user: User | undefined, locale: string): PanelProvider[] => {
@@ -62,8 +63,20 @@ export const visiblePanelProviders = (apps: readonly RuntimeAppMeta[], user: Use
62
63
  return apps
63
64
  .flatMap((app) => {
64
65
  if (!app.panel || !isOwnAppPath(app.panel.href, app.routes) || !hasAnyAppRole(user, app.panel.requiresRoles)) return [];
65
- const panel = resolveAppPresentation(app, locale).panel;
66
- return panel ? [{ id: app.id, label: panel.label, icon: panel.icon, href: panel.href }] : [];
66
+ const presentation = resolveAppPresentation(app, locale);
67
+ const panel = presentation.panel;
68
+ return panel
69
+ ? [
70
+ {
71
+ id: app.id,
72
+ label: panel.label,
73
+ icon: panel.icon,
74
+ href: panel.href,
75
+ appName: presentation.name,
76
+ consent: panelProviderNeedsConsent(app.id),
77
+ },
78
+ ]
79
+ : [];
67
80
  })
68
81
  .sort((a, b) => a.label.localeCompare(b.label, locale) || a.id.localeCompare(b.id));
69
82
  };
@@ -116,30 +116,38 @@ const workspace = (selected: string) =>
116
116
  * A task-list page: a long list in Main and an open detail that the server renders with its own close
117
117
  * control, as Contacts and Capabilities do. Invented content.
118
118
  */
119
- const listWorkspace = () =>
119
+ const listWorkspace = (head = false) =>
120
120
  createComponent(AppWorkspace, {
121
121
  class: "min-h-0 flex-1",
122
122
  get children() {
123
- return createComponent(AppWorkspace.Content, {
124
- get children() {
125
- return [
126
- createComponent(AppWorkspace.Main, {
127
- get children() {
128
- return Array.from({ length: 80 }, (_, index) =>
129
- createComponent(Button, { class: "list-row", align: "start", children: `Task ${index + 1}` }),
130
- );
131
- },
132
- }),
133
- createComponent(AppWorkspace.Detail, {
134
- id: "task-detail",
135
- open: true,
136
- get children() {
137
- return createComponent(Button, { "data-workspace-detail-close": "", children: "Close task" });
138
- },
139
- }),
140
- ];
141
- },
142
- });
123
+ return [
124
+ head ? createComponent(AppWorkspace.Head, { primary: { label: "New task", icon: "ti ti-plus", href: "?new=task" } }) : null,
125
+ createComponent(AppWorkspace.Content, {
126
+ get children() {
127
+ return [
128
+ createComponent(AppWorkspace.Main, {
129
+ get children() {
130
+ return Array.from({ length: 80 }, (_, index) =>
131
+ createComponent(Button, {
132
+ variant: head ? "ghost" : undefined,
133
+ class: "list-row",
134
+ align: "start",
135
+ children: `Task ${index + 1}`,
136
+ }),
137
+ );
138
+ },
139
+ }),
140
+ createComponent(AppWorkspace.Detail, {
141
+ id: "task-detail",
142
+ open: true,
143
+ get children() {
144
+ return createComponent(Button, { "data-workspace-detail-close": "", children: "Close task" });
145
+ },
146
+ }),
147
+ ];
148
+ },
149
+ }),
150
+ ];
143
151
  },
144
152
  });
145
153
 
@@ -270,7 +278,7 @@ const server = new Hono()
270
278
  fullPage: list === "page",
271
279
  title: [{ title: "Home", href: "/" }, { title: "Tasks" }],
272
280
  get children() {
273
- return listWorkspace();
281
+ return listWorkspace(c.req.query("head") === "1");
274
282
  },
275
283
  });
276
284
  return () =>
@@ -0,0 +1,43 @@
1
+ import { DataTable, prompts, SettingsModal } from "@k2b/ui";
2
+ import { render } from "solid-js/web";
3
+
4
+ const host = document.createElement("div");
5
+ document.querySelector(".cloud-app-canvas")!.append(host);
6
+
7
+ const SelectedTable = () => (
8
+ <DataTable
9
+ rows={[{ id: "selected", name: "Selected file" }]}
10
+ columns={[{ id: "name", header: "Name", value: "name" }]}
11
+ getRowId={(row) => row.id}
12
+ selectedRowId="selected"
13
+ surface="plain"
14
+ />
15
+ );
16
+
17
+ render(
18
+ () => (
19
+ <button
20
+ type="button"
21
+ onClick={() =>
22
+ void prompts.dialog<void>(
23
+ (close) => (
24
+ <div class="dialog-fixed-frame flex min-h-0 flex-col overflow-hidden">
25
+ <SettingsModal title="App settings" onClose={() => close()}>
26
+ <SettingsModal.Tab id="general" title="General">
27
+ <SelectedTable />
28
+ </SettingsModal.Tab>
29
+ <SettingsModal.Tab id="files" title="Files">
30
+ <SelectedTable />
31
+ </SettingsModal.Tab>
32
+ </SettingsModal>
33
+ </div>
34
+ ),
35
+ { surface: "bare", header: false, size: "large" },
36
+ )
37
+ }
38
+ >
39
+ Open app settings
40
+ </button>
41
+ ),
42
+ host,
43
+ );
@@ -20,6 +20,8 @@ export const helpMessages = i18n.define({
20
20
  shortcutsInApp: ({ app }: { app: string }) => `In ${app}`,
21
21
  shortcutsInThisApp: "In this app",
22
22
  shortcutsWithSelection: "With a selection",
23
+ helpNotAvailable: "This Help is not available",
24
+ helpNotAvailableDetail: "You may not have access to it, or it no longer exists.",
23
25
  noArticles: "No Help articles here yet",
24
26
  reload: "Reload",
25
27
  noArticlesDetail: "This app has not published Help yet.",
@@ -79,6 +81,8 @@ export const helpMessages = i18n.define({
79
81
  shortcutsInApp: ({ app }) => `In ${app}`,
80
82
  shortcutsInThisApp: "In dieser App",
81
83
  shortcutsWithSelection: "Mit Auswahl",
84
+ helpNotAvailable: "Diese Hilfe ist nicht verfügbar",
85
+ helpNotAvailableDetail: "Vielleicht hast du keinen Zugriff, oder es gibt sie nicht mehr.",
82
86
  noArticles: "Hier gibt es noch keine Hilfeartikel",
83
87
  reload: "Neu laden",
84
88
  noArticlesDetail: "Diese App hat noch keine Hilfe veröffentlicht.",
package/src/ssr/help.ts CHANGED
@@ -1,9 +1,13 @@
1
1
  import type { Context } from "hono";
2
+ import { buildHelpDocumentPayload } from "../server/help-payload";
2
3
  import { getLocale } from "../server/locale";
3
4
  import { createHelpReader } from "../services/help";
4
5
  import type { HelpManifestResult } from "../services/help/types";
5
6
  import { logger } from "../services/logging";
7
+ import type { HelpDocumentPayload } from "../shared/help";
6
8
  import { resolveCurrentApp } from "./app-appearance";
9
+ import type { HelpArticleParts } from "./layout-help-article";
10
+ import { helpArticlePartsServer } from "./layout-help-article.server";
7
11
  import { getRuntimeContext } from "./runtime";
8
12
 
9
13
  const manifests = new WeakMap<object, HelpManifestResult | null>();
@@ -30,3 +34,20 @@ export const preloadLayoutHelp = async (c: Context, appId?: string): Promise<Hel
30
34
  return null;
31
35
  }
32
36
  };
37
+
38
+ /** Article HTML crosses the island boundary once, as parts. */
39
+ export type LayoutHelpArticle = { payload: Omit<HelpDocumentPayload, "html">; parts: HelpArticleParts };
40
+
41
+ /** An authorized initial article; failures leave the browser's on-demand reader available. */
42
+ export const preloadLayoutHelpArticle = async (c: Context, appId: string, documentId: string): Promise<LayoutHelpArticle | null> => {
43
+ if (!c.get("user")) return null;
44
+ try {
45
+ const article = await createHelpReader(getLocale(c), c.get("actor")).read({ appId, documentId });
46
+ if (!article) return null;
47
+ const { html, ...payload } = buildHelpDocumentPayload(article, c.req.header("User-Agent") ?? "");
48
+ return { payload, parts: helpArticlePartsServer(html) };
49
+ } catch (error) {
50
+ log.warn("Help article unavailable", { appId, documentId, error: error instanceof Error ? error.message : String(error) });
51
+ return null;
52
+ }
53
+ };
@@ -0,0 +1,25 @@
1
+ import type { Context } from "hono";
2
+ import { isHintId, mergeSeenHints, parseSeenHintsCookieHeader, seenHintsSetCookie } from "../contracts/seen-hints";
3
+ import { userFromActor } from "../server/actor";
4
+
5
+ /** Ids marked seen earlier in the same request, so a second mark or a later check sees them too. */
6
+ const marked = new WeakMap<Context, string[]>();
7
+
8
+ const seenIds = (c: Context): string[] => marked.get(c) ?? parseSeenHintsCookieHeader(c.req.header("Cookie"));
9
+
10
+ /** The caller checks access to the new path; anonymous visitors never see a hint. */
11
+ export const hintOpen = (c: Context, id: string, options: { allowed: boolean }): boolean => {
12
+ if (!isHintId(id)) throw new Error(`Invalid hint id: ${id}`);
13
+ return options.allowed && !!userFromActor(c.get("actor")) && !seenIds(c).includes(id);
14
+ };
15
+
16
+ /**
17
+ * Call on the new path too: using it counts as having seen the hint. Each call writes the whole list seen so far in
18
+ * this request, so with several calls the browser keeps the last cookie and with it every id.
19
+ */
20
+ export const markHintSeen = (c: Context, id: string): void => {
21
+ const ids = mergeSeenHints(seenIds(c), id);
22
+ marked.set(c, ids);
23
+ const secure = new URL(c.req.url).protocol === "https:" || c.req.header("x-forwarded-proto")?.split(",")[0]?.trim() === "https";
24
+ c.header("Set-Cookie", seenHintsSetCookie(ids, secure), { append: true });
25
+ };
package/src/ssr/index.ts CHANGED
@@ -11,7 +11,9 @@ export {
11
11
  visiblePanelProviders,
12
12
  visiblePwaParts,
13
13
  } from "./app-navigation";
14
+ export { hintOpen, markHintSeen } from "./hints";
14
15
  export { default as Layout } from "./Layout";
16
+ export { LayoutHelpPanel, type LayoutHelpPanelProps } from "./LayoutHelpPanel.server";
15
17
  export {
16
18
  default as MinimalLayout,
17
19
  type MinimalLayoutPreferencePosition,
@@ -19,6 +21,7 @@ export {
19
21
  } from "./MinimalLayout";
20
22
  export { default as PanelLayout, type PanelLayoutProps } from "./PanelLayout";
21
23
  export { default as PwaLayout, type PwaLayoutProps } from "./PwaLayout";
24
+ export { readPanelContext } from "./panel-context";
22
25
  export { requestPath } from "./request-path";
23
26
  export { getLocalizedRuntimeContext, getRuntimeContext, type RuntimeContext } from "./runtime";
24
27
  export type { UrlFilterField } from "./url-filter";
@@ -1,3 +1,4 @@
1
+ import type { PanelConsentSnapshot } from "../contracts/panel-context";
1
2
  import type { RailSnapshot } from "../contracts/rail-preferences";
2
3
  import type { User } from "../contracts/shared";
3
4
  import type { LayoutAnnouncementsState } from "../server/middleware/settings";
@@ -5,6 +6,7 @@ import type { RuntimeContext } from "./runtime";
5
6
 
6
7
  export type LayoutContext = {
7
8
  get(key: "user"): User | undefined;
9
+ get(key: "panelConsent"): PanelConsentSnapshot | undefined;
8
10
  get(key: "railPreferences"): RailSnapshot | undefined;
9
11
  get(key: "page"): { theme?: "light" | "dark"; title?: string; pwa?: { name: string } };
10
12
  get(key: "runtime"): RuntimeContext;
@@ -0,0 +1,50 @@
1
+ import type * as Htmlparser2 from "htmlparser2";
2
+ import type { HelpArticleParts } from "./layout-help-article";
3
+
4
+ // Keep the same synchronous import path as sanitize-html (see ai/readable-html.ts).
5
+ const { parseDocument, DomUtils, ElementType }: typeof Htmlparser2 = require("htmlparser2");
6
+ type HtmlNode = ReturnType<typeof parseDocument>["children"][number];
7
+
8
+ const insideDetails = (node: HtmlNode): boolean => {
9
+ for (let parent = node.parent; parent; parent = parent.parent) if ("name" in parent && parent.name === "details") return true;
10
+ return false;
11
+ };
12
+ const spokenText = (node: HtmlNode): string => {
13
+ if (node.type === ElementType.Text) return node.data;
14
+ if (!("children" in node)) return "";
15
+ return node.children
16
+ .filter((child) => !("attribs" in child) || child.attribs["aria-hidden"] !== "true")
17
+ .map(spokenText)
18
+ .join("");
19
+ };
20
+
21
+ /** Server-only equivalent of the browser's inert-template splitter. Never import into an island. */
22
+ export const helpArticlePartsServer = (html: string): HelpArticleParts => {
23
+ const body = parseDocument(html);
24
+ const first = body.children.find((node) => "attribs" in node);
25
+ const lead = first?.name === "p" && !first.attribs.class ? DomUtils.getOuterHTML(first) : undefined;
26
+ if (lead && first) DomUtils.removeElement(first);
27
+ const elements = DomUtils.findAll(() => true, body.children);
28
+ return {
29
+ lead,
30
+ body: DomUtils.getInnerHTML(body),
31
+ sections: elements
32
+ .filter((element) => element.name === "h2" && "id" in element.attribs && !insideDetails(element))
33
+ .map((element) => ({ id: element.attribs.id ?? "", label: spokenText(element).replace(/\s+/g, " ").trim() }))
34
+ .filter((section) => section.label),
35
+ steps: elements.filter((element) => {
36
+ const list = element.parent;
37
+ const wrapper = list?.parent;
38
+ return (
39
+ element.name === "li" &&
40
+ list &&
41
+ "name" in list &&
42
+ list.name === "ol" &&
43
+ wrapper &&
44
+ "attribs" in wrapper &&
45
+ (wrapper.attribs.class ?? "").split(/\s+/).includes("help-steps") &&
46
+ !insideDetails(element)
47
+ );
48
+ }).length,
49
+ };
50
+ };
@@ -0,0 +1,43 @@
1
+ import { Button, IconButton } from "@k2b/ui";
2
+ import type { JSX } from "solid-js";
3
+
4
+ /**
5
+ * The same button and tooltip as Dropdown.Trigger, without loading the menu implementation. It names no aria-controls:
6
+ * the menu it would point to exists only once the real Dropdown replaces it on activation.
7
+ */
8
+ export function HelpPanelMenuTrigger(props: {
9
+ label: string;
10
+ onClick?: JSX.EventHandler<HTMLButtonElement, MouseEvent>;
11
+ onKeyDown?: JSX.EventHandler<HTMLButtonElement, KeyboardEvent>;
12
+ onPointerEnter?: JSX.EventHandler<HTMLButtonElement, PointerEvent>;
13
+ onPointerDown?: JSX.EventHandler<HTMLButtonElement, PointerEvent>;
14
+ onFocus?: JSX.EventHandler<HTMLButtonElement, FocusEvent>;
15
+ }) {
16
+ return (
17
+ <span class="k2b-dropdown ">
18
+ <IconButton {...props} class="k2b-dropdown__trigger " aria-expanded="false" aria-haspopup="menu" data-help-menu="">
19
+ <i class="ti ti-dots" aria-hidden="true" />
20
+ </IconButton>
21
+ </span>
22
+ );
23
+ }
24
+ export function HelpPanelSectionTrigger(props: { label: string }) {
25
+ return (
26
+ <span class="k2b-dropdown ">
27
+ <Button
28
+ aria-expanded="false"
29
+ aria-haspopup="menu"
30
+ aria-label={props.label}
31
+ class=" k2b-dropdown__trigger "
32
+ size="sm"
33
+ tooltip={false}
34
+ variant="secondary"
35
+ data-help-sections=""
36
+ >
37
+ <i class="ti ti-list" aria-hidden="true" />
38
+ <span>{props.label}</span>
39
+ <i class="ti ti-chevron-down" aria-hidden="true" />
40
+ </Button>
41
+ </span>
42
+ );
43
+ }
@@ -0,0 +1,67 @@
1
+ import type { HelpArticleLink, HelpDocumentManifest, HelpDocumentPayload } from "../shared/help";
2
+ import { HELP_ACCESS_LEVELS, HELP_KINDS } from "../shared/help";
3
+ import type { HelpPanelState } from "./layout-help-panel-state";
4
+
5
+ const optionalString = (value: object, key: string): string | undefined => {
6
+ const field = Reflect.get(value, key);
7
+ return typeof field === "string" ? field : undefined;
8
+ };
9
+ /** A linked article from the Help API, or nothing when the server sent something else. */
10
+ const articleLink = (value: unknown): HelpArticleLink | undefined => {
11
+ if (!value || typeof value !== "object") return undefined;
12
+ const [appId, appName, documentId, title, url] = ["appId", "appName", "documentId", "title", "url"].map((key) =>
13
+ optionalString(value, key),
14
+ );
15
+ if (!appId || !appName || !documentId || !title || !url?.startsWith("/")) return undefined;
16
+ const [accent, description, icon] = ["accent", "description", "icon"].map((key) => optionalString(value, key));
17
+ return { appId, appName, accent, documentId, title, description, icon, url };
18
+ };
19
+
20
+ export const loadPanelDocument = async (
21
+ state: HelpPanelState,
22
+ topic: HelpDocumentManifest,
23
+ signal: AbortSignal,
24
+ t: { requestFailed: string; invalidDocument: string },
25
+ ) => {
26
+ const cacheKey = `${topic.locale ?? "en"}:${topic.url}`;
27
+ const cached = state.cache.get(cacheKey);
28
+ if (cached) return cached;
29
+
30
+ const response = await fetch(topic.url, { signal, headers: { Accept: "application/json" } });
31
+ if (!response.ok) throw new Error(t.requestFailed);
32
+ const value: unknown = await response.json();
33
+ if (
34
+ !value ||
35
+ typeof value !== "object" ||
36
+ !("id" in value) ||
37
+ value.id !== topic.id ||
38
+ !("html" in value) ||
39
+ !("markdown" in value) ||
40
+ !("title" in value) ||
41
+ ("locale" in value && value.locale !== undefined && typeof value.locale !== "string") ||
42
+ (topic.locale !== undefined && "locale" in value && value.locale !== undefined && value.locale !== topic.locale) ||
43
+ typeof value.html !== "string" ||
44
+ typeof value.markdown !== "string" ||
45
+ typeof value.title !== "string"
46
+ ) {
47
+ throw new Error(t.invalidDocument);
48
+ }
49
+ const kind = Reflect.get(value, "kind");
50
+ const needs = Reflect.get(value, "needs");
51
+ const related = Reflect.get(value, "related");
52
+ const next = articleLink(Reflect.get(value, "next"));
53
+ const document: HelpDocumentPayload = {
54
+ id: topic.id,
55
+ title: value.title,
56
+ html: value.html,
57
+ markdown: value.markdown,
58
+ locale: "locale" in value && typeof value.locale === "string" ? value.locale : (topic.locale ?? "en"),
59
+ // A Core older than the app's Help reader sends no article facts; the article then shows none.
60
+ kind: HELP_KINDS.find((candidate) => candidate === kind) ?? "task",
61
+ needs: HELP_ACCESS_LEVELS.find((level) => level === needs),
62
+ ...(next ? { next } : {}),
63
+ related: Array.isArray(related) ? related.flatMap((link) => articleLink(link) ?? []) : [],
64
+ };
65
+ state.storeDocument(topic, document);
66
+ return document;
67
+ };
@@ -0,0 +1,32 @@
1
+ const focused = new WeakMap<Element, string>();
2
+ /** Module evaluation precedes SSR's destructive island mount. */
3
+ export function capturePanelFocus() {
4
+ if (typeof document === "undefined") return;
5
+ const element = document.activeElement;
6
+ const wrapper = element?.closest("solid-island");
7
+ if (!wrapper || !(element instanceof HTMLElement)) return;
8
+ const topic = element.dataset.helpTopic;
9
+ const selector =
10
+ topic !== undefined
11
+ ? `[data-help-topic="${CSS.escape(topic)}"]`
12
+ : element.matches("[data-help-menu]")
13
+ ? "[data-help-menu]"
14
+ : element.matches(".help-head-back")
15
+ ? ".help-head-back"
16
+ : undefined;
17
+ if (selector) focused.set(wrapper, selector);
18
+ }
19
+ export function restorePanelFocus(element: Element) {
20
+ const wrapper = element.closest("solid-island");
21
+ const selector = wrapper && focused.get(wrapper);
22
+ if (!wrapper || !selector) return;
23
+ focused.delete(wrapper);
24
+ wrapper.querySelector<HTMLElement>(selector)?.focus({ preventScroll: true });
25
+ }
26
+
27
+ /** One provider reader per document; production island wrappers intentionally omit data-file. */
28
+ export function panelElement(): HTMLElement {
29
+ const element = document.querySelector<HTMLElement>('.help-shell[data-surface="panel"]');
30
+ if (!element) throw new Error("Missing Help panel");
31
+ return element;
32
+ }
@@ -0,0 +1,113 @@
1
+ import { clipboard } from "@k2b/stdlib/solid";
2
+ import { Dropdown, toast, useLocale } from "@k2b/ui";
3
+ import { createEffect, createSignal, onCleanup, onMount } from "solid-js";
4
+ import { helpMessages } from "./help-messages";
5
+ import { formatHelpBundleMarkdown, formatHelpDocumentMarkdown } from "./layout-help-markdown";
6
+ import { loadPanelDocument } from "./layout-help-panel-document";
7
+ import type { HelpPanelState } from "./layout-help-panel-state";
8
+ import { layoutHelpTopicHref } from "./layout-help-url";
9
+
10
+ export type MenuActivation = { key: string; focus: boolean; pointer?: boolean };
11
+ export function HelpPanelMenu(props: { state: HelpPanelState; activation: MenuActivation }) {
12
+ const state = props.state;
13
+ const locale = useLocale();
14
+ const t = () => helpMessages.resolve([locale()]).t;
15
+ const write = (text: string) => navigator.clipboard.writeText(text);
16
+ const writer = clipboard.createWriter({ write, copiedFor: 1800 });
17
+ const allWriter = clipboard.createWriter({ write, copiedFor: 1800 });
18
+ createEffect(() => state.setAnnouncement(writer.wasCopied() ? t().articleCopiedAnnouncement : ""));
19
+ onCleanup(() => state.setAnnouncement(""));
20
+ const [copying, setCopying] = createSignal(false);
21
+ let controller: AbortController | undefined;
22
+ let trigger: HTMLButtonElement | undefined;
23
+ const topic = () => state.data.documents.find((topic) => topic.id === state.activeId());
24
+ const payload = () => {
25
+ const active = topic();
26
+ return active ? state.cachedDocument(active) : undefined;
27
+ };
28
+ const copyArticle = async () => {
29
+ const active = topic();
30
+ const document = payload();
31
+ if (!active || !document) return;
32
+ // Invoke the clipboard writer before the first await: Safari needs the menu item's user activation.
33
+ if (await writer.copy(formatHelpDocumentMarkdown({ ...active, markdown: document.markdown }))) {
34
+ toast.success(t().articleCopied);
35
+ } else
36
+ toast.error(t().copyFailed({ message: writer.error()?.message ?? t().copyFallback }), {
37
+ action: { label: t().tryAgain, onClick: () => void copyArticle() },
38
+ });
39
+ };
40
+ const copyAll = async () => {
41
+ if (!state.data.documents.length || copying()) return;
42
+ controller?.abort();
43
+ const current = new AbortController();
44
+ controller = current;
45
+ setCopying(true);
46
+ const progress = toast(t().preparing, { progress: "indeterminate", duration: 0, iconClass: "ti ti-markdown" });
47
+ try {
48
+ const documents = await Promise.all(
49
+ state.data.documents.map(async (topic) => ({
50
+ ...topic,
51
+ markdown: (await loadPanelDocument(state, topic, current.signal, t())).markdown,
52
+ })),
53
+ );
54
+ if (!(await allWriter.copy(formatHelpBundleMarkdown(documents)))) throw allWriter.error() ?? new Error(t().copyFallback);
55
+ progress.dismiss();
56
+ toast.success(t().helpCopied);
57
+ } catch (error) {
58
+ progress.dismiss();
59
+ if (!(error instanceof DOMException && error.name === "AbortError"))
60
+ toast.error(t().copyAllFailed({ message: error instanceof Error ? error.message : t().copyFallback }), {
61
+ action: { label: t().tryAgain, onClick: () => void copyAll() },
62
+ });
63
+ } finally {
64
+ if (controller === current) {
65
+ controller = undefined;
66
+ setCopying(false);
67
+ }
68
+ }
69
+ };
70
+ const [quiet, setQuiet] = createSignal(!!props.activation.pointer);
71
+ onCleanup(() => controller?.abort());
72
+ onMount(() => {
73
+ if (props.activation.focus) trigger?.focus({ preventScroll: true });
74
+ trigger?.dispatchEvent(new KeyboardEvent("keydown", { key: props.activation.key, bubbles: true, cancelable: true }));
75
+ });
76
+ const items = () => [
77
+ {
78
+ label: t().copyAsMarkdown,
79
+ icon: "ti ti-markdown",
80
+ ...(copying() || (state.view() === "article" ? !payload() : !state.data.documents.length)
81
+ ? { disabled: true as const }
82
+ : { action: () => void (state.view() === "article" ? copyArticle() : copyAll()) }),
83
+ },
84
+ ...(state.data.pageBase
85
+ ? [
86
+ {
87
+ label: t().openAsPage,
88
+ icon: "ti ti-app-window",
89
+ href: layoutHelpTopicHref(state.data.pageBase, state.view() === "article" ? state.activeId() : null),
90
+ },
91
+ ]
92
+ : []),
93
+ ];
94
+ return (
95
+ <Dropdown.Root label={t().moreActions} position="bottom-left" items={items()}>
96
+ <Dropdown.Trigger
97
+ ref={(element) => {
98
+ trigger = element;
99
+ }}
100
+ iconOnly
101
+ label={t().moreActions}
102
+ variant="ghost"
103
+ data-help-menu=""
104
+ // A press on the light trigger counts as a press here: like Dropdown.Trigger's own, its hint stays closed until
105
+ // the pointer leaves.
106
+ tooltip={quiet() ? false : undefined}
107
+ onPointerLeave={() => setQuiet(false)}
108
+ >
109
+ <i class="ti ti-dots" aria-hidden="true" />
110
+ </Dropdown.Trigger>
111
+ </Dropdown.Root>
112
+ );
113
+ }
@@ -0,0 +1,7 @@
1
+ // Keep importOnDemand() from ../browser/reload out: its eager toast import would break the panel bundle budget.
2
+ export const helpPanelModules = {
3
+ menu: () => import("./layout-help-panel-menu"),
4
+ search: () => import("./layout-help-panel-search"),
5
+ reader: () => import("./layout-help-panel-reader"),
6
+ document: () => import("./layout-help-panel-document"),
7
+ };