@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
@@ -0,0 +1,484 @@
1
+ import type { HelpAccessLevel, HelpArticleLink, HelpDocumentPayload } from "@k2b/cloud/shared";
2
+ import { Button, IconButton, Kbd, type KeyboardPlatform, NoticeCard, Placeholder, TextInput, useLocale } from "@k2b/ui";
3
+ import { For, type JSX, Show } from "solid-js";
4
+ import { appAccentStyle } from "./app-appearance";
5
+ import { helpMessages } from "./help-messages";
6
+ import type { HelpArticleParts } from "./layout-help-article";
7
+ import type { HelpTopic } from "./layout-help-registry";
8
+ import type { HelpShortcutScope, HelpStart, HelpStartGroupKind, helpShortcutGroups } from "./layout-help-start";
9
+
10
+ /**
11
+ * The Help reader's markup. Each view takes data and callbacks only, so the same view renders on the server and in the
12
+ * browser; state, effects, requests, the clipboard, and heavy controls stay with the reader that composes them.
13
+ */
14
+
15
+ export type HelpSurface = "modal" | "page" | "embedded";
16
+
17
+ const useHelpMessages = () => {
18
+ const locale = useLocale();
19
+ return () => helpMessages.resolve([locale()]).t;
20
+ };
21
+
22
+ /** An article without its own icon gets a neutral page tile. */
23
+ const helpIconClass = (icon?: string) => (icon?.startsWith("ti ") ? icon : `ti ${icon ?? "ti-file-text"}`);
24
+ const GROUP_ICONS: Record<HelpStartGroupKind, string> = {
25
+ start: "ti ti-rocket",
26
+ reference: "ti ti-book-2",
27
+ troubleshoot: "ti ti-lifebuoy",
28
+ };
29
+
30
+ /**
31
+ * One head in every view: the search never moves between the start, an article, and results. An app's embedded reader
32
+ * sits in the app's own chrome, which names it and picks its article, so it keeps only the search of its start.
33
+ */
34
+ export function HelpHead(props: {
35
+ /** The row with the tile, the title, and the actions; the embedded reader has none. */
36
+ row: boolean;
37
+ /** In an article, the back button takes the app tile's place. */
38
+ back: boolean;
39
+ /** A provider can mount only the interactive lead. */
40
+ lead?: JSX.Element;
41
+ tile: string;
42
+ title: string;
43
+ /** The ⋯ menu. */
44
+ menu: JSX.Element;
45
+ closable: boolean;
46
+ onBack: () => void;
47
+ onClose: () => void;
48
+ query: string;
49
+ onQuery: (value: string) => void;
50
+ searchRef: (element: HTMLInputElement) => void;
51
+ }) {
52
+ const t = useHelpMessages();
53
+ return (
54
+ <header class="help-head" data-help-head="">
55
+ <div class="help-head-inner help-measure">
56
+ <Show when={props.row}>
57
+ <div class="help-head-row">
58
+ {/* The back button takes the app tile's place in an article, so nothing beside it moves. */}
59
+ {props.lead ?? (
60
+ <Show
61
+ when={props.back}
62
+ fallback={
63
+ <span class="help-head-tile" aria-hidden="true">
64
+ <i class={props.tile} />
65
+ </span>
66
+ }
67
+ >
68
+ <IconButton class="help-head-back" label={t().backToTopics} title={t().back} onClick={props.onBack}>
69
+ <i class="ti ti-arrow-left" />
70
+ </IconButton>
71
+ </Show>
72
+ )}
73
+ <h2 id="layout-help-title" class="help-head-title">
74
+ {props.title}
75
+ </h2>
76
+ <div class="help-head-actions" role="group" aria-label={t().actions}>
77
+ {props.menu}
78
+ <Show when={props.closable}>
79
+ <IconButton label={t().closeHelp} title={t().close} onClick={props.onClose}>
80
+ <i class="ti ti-x" />
81
+ </IconButton>
82
+ </Show>
83
+ </div>
84
+ </div>
85
+ </Show>
86
+ <TextInput
87
+ ref={props.searchRef}
88
+ type="search"
89
+ class="help-head-search"
90
+ icon="ti ti-search"
91
+ aria-label={t().searchHelp}
92
+ data-help-search=""
93
+ value={props.query}
94
+ placeholder={t().searchPlaceholder}
95
+ onValueChange={props.onQuery}
96
+ />
97
+ </div>
98
+ </header>
99
+ );
100
+ }
101
+
102
+ /** Rows: icon tile, title, and description; the start's groups, its further tasks, and search results use them. */
103
+ function HelpTopicRows(props: { items: readonly HelpTopic[]; marker?: string; onOpen: (id: string) => void }) {
104
+ return (
105
+ <ul class="help-start-rows" {...(props.marker ? { [props.marker]: "" } : {})}>
106
+ <For each={props.items}>
107
+ {(topic) => (
108
+ <li>
109
+ <button type="button" class="help-start-row focus-ui" data-help-topic={topic.id} onClick={() => props.onOpen(topic.id)}>
110
+ <span class="help-topic-icon k2b-icon-tile help-start-row-icon app-accent-text" aria-hidden="true">
111
+ <i class={helpIconClass(topic.icon)} />
112
+ </span>
113
+ <span class="help-article-link-copy">
114
+ <span class="help-article-link-title">{topic.title}</span>
115
+ <Show when={topic.description}>{(description) => <span class="help-article-link-description">{description()}</span>}</Show>
116
+ </span>
117
+ <i class="ti ti-chevron-right help-start-row-arrow" aria-hidden="true" />
118
+ </button>
119
+ </li>
120
+ )}
121
+ </For>
122
+ </ul>
123
+ );
124
+ }
125
+
126
+ /** At most eight tasks as tiles; the app colour shows only in the icon. */
127
+ function HelpTaskTiles(props: { items: readonly HelpTopic[]; onOpen: (id: string) => void }) {
128
+ return (
129
+ <ul class="help-start-tiles" data-help-tiles="">
130
+ <For each={props.items}>
131
+ {(topic) => (
132
+ <li>
133
+ <button type="button" class="help-start-tile focus-ui" data-help-topic={topic.id} onClick={() => props.onOpen(topic.id)}>
134
+ <span class="help-topic-icon k2b-icon-tile help-start-tile-icon app-accent-text" aria-hidden="true">
135
+ <i class={helpIconClass(topic.icon)} />
136
+ </span>
137
+ <span class="help-start-tile-title">{topic.title}</span>
138
+ <Show when={topic.description}>{(description) => <span class="help-start-tile-description">{description()}</span>}</Show>
139
+ </button>
140
+ </li>
141
+ )}
142
+ </For>
143
+ </ul>
144
+ );
145
+ }
146
+
147
+ export function HelpViewSection(props: { items: readonly HelpTopic[]; onOpen: (id: string) => void }) {
148
+ const t = useHelpMessages();
149
+ return (
150
+ <Show when={props.items.length > 0}>
151
+ <section class="help-start-section" data-help-view="" aria-labelledby="help-start-view-title">
152
+ <h3 id="help-start-view-title" class="help-start-heading">
153
+ <i class="ti ti-focus-2" aria-hidden="true" />
154
+ {t().forThisView}
155
+ </h3>
156
+ <HelpTopicRows items={props.items} onOpen={props.onOpen} />
157
+ </section>
158
+ </Show>
159
+ );
160
+ }
161
+
162
+ /** The start: For this view, What to do, the kind groups, and the shortcuts last. */
163
+ export function HelpStartView(props: {
164
+ start: HelpStart<HelpTopic>;
165
+ /** The app has no Help articles. */
166
+ empty: boolean;
167
+ /** Reading the app's Help failed. */
168
+ failed: boolean;
169
+ onOpen: (id: string) => void;
170
+ onReload: () => void;
171
+ shortcuts?: JSX.Element;
172
+ }) {
173
+ const t = useHelpMessages();
174
+ const groupTitle = (kind: HelpStartGroupKind) =>
175
+ ({ start: t().groupStart, reference: t().groupReference, troubleshoot: t().groupTroubleshoot })[kind];
176
+ return (
177
+ <>
178
+ <HelpViewSection items={props.start.view} onOpen={props.onOpen} />
179
+ <Show when={props.start.tiles.length > 0}>
180
+ <section class="help-start-section" data-help-tasks="" aria-labelledby="help-start-tasks-title">
181
+ <h3 id="help-start-tasks-title" class="help-start-heading">
182
+ <i class="ti ti-checklist" aria-hidden="true" />
183
+ {t().whatToDo}
184
+ </h3>
185
+ <HelpTaskTiles items={props.start.tiles} onOpen={props.onOpen} />
186
+ <Show when={props.start.moreTasks.length > 0}>
187
+ <HelpTopicRows items={props.start.moreTasks} marker="data-help-more-tasks" onOpen={props.onOpen} />
188
+ </Show>
189
+ </section>
190
+ </Show>
191
+ <Show when={props.start.groups.length > 0}>
192
+ <div class="help-start-groups">
193
+ <For each={props.start.groups}>
194
+ {(group) => (
195
+ <section class="help-start-section" data-help-group={group.kind} aria-labelledby={`help-start-${group.kind}-title`}>
196
+ <h3 id={`help-start-${group.kind}-title`} class="help-start-heading">
197
+ <i class={GROUP_ICONS[group.kind]} aria-hidden="true" />
198
+ {groupTitle(group.kind)}
199
+ </h3>
200
+ <HelpTopicRows items={group.topics} onOpen={props.onOpen} />
201
+ </section>
202
+ )}
203
+ </For>
204
+ </div>
205
+ </Show>
206
+ {/* An app without Help says so plainly; only a failed read of its Help offers a way to try again. */}
207
+ <Show when={props.empty}>
208
+ <Show
209
+ when={props.failed}
210
+ fallback={<Placeholder icon="ti ti-book-off" title={t().noArticles} description={t().noArticlesDetail} />}
211
+ >
212
+ <NoticeCard tone="danger" title={t().helpUnavailable} detail={t().helpUnavailableDetail} data-help-unavailable="">
213
+ <Button size="sm" variant="secondary" onClick={() => props.onReload()}>
214
+ {t().reload}
215
+ </Button>
216
+ </NoticeCard>
217
+ </Show>
218
+ </Show>
219
+ {props.shortcuts}
220
+ </>
221
+ );
222
+ }
223
+
224
+ /**
225
+ * Keyboard shortcuts come last in the start. The groups reserve their height and place in CSS, so the ones the browser
226
+ * adds fill space that is already there.
227
+ */
228
+ export function HelpShortcutsView(props: { groups: ReturnType<typeof helpShortcutGroups>; appName?: string; platform?: KeyboardPlatform }) {
229
+ const t = useHelpMessages();
230
+ const label = (scope: HelpShortcutScope) =>
231
+ scope === "everywhere"
232
+ ? t().shortcutsEverywhere
233
+ : scope === "selection"
234
+ ? t().shortcutsWithSelection
235
+ : props.appName
236
+ ? t().shortcutsInApp({ app: props.appName })
237
+ : t().shortcutsInThisApp;
238
+ return (
239
+ <section class="help-start-section help-start-shortcuts" data-help-shortcuts="" aria-labelledby="help-start-shortcuts-title">
240
+ <h3 id="help-start-shortcuts-title" class="help-start-heading">
241
+ <i class="ti ti-keyboard" aria-hidden="true" />
242
+ {t().keyboardShortcuts}
243
+ </h3>
244
+ <div class="help-shortcut-groups">
245
+ <For each={props.groups}>
246
+ {(group) => (
247
+ <section class="help-shortcut-group" data-help-shortcut-group={group.scope} aria-label={label(group.scope)}>
248
+ <h4 class="help-shortcut-group-title" aria-hidden="true">
249
+ {label(group.scope)}
250
+ </h4>
251
+ <ul>
252
+ <For each={group.shortcuts}>
253
+ {(command) => (
254
+ <li class="help-shortcut" title={command.description}>
255
+ <span class="help-shortcut-title">{command.title}</span>
256
+ <Kbd keys={command.shortcut} platform={props.platform} />
257
+ </li>
258
+ )}
259
+ </For>
260
+ </ul>
261
+ </section>
262
+ )}
263
+ </For>
264
+ </div>
265
+ </section>
266
+ );
267
+ }
268
+
269
+ /** Search results replace the start below the field. The count takes the place of the first heading, so the first row stays where it was. */
270
+ export function HelpResultsView(props: {
271
+ results: readonly HelpTopic[];
272
+ searching: boolean;
273
+ /** Full-text search failed; the results match titles only. */
274
+ failed: boolean;
275
+ onRetry: () => void;
276
+ onOpen: (id: string) => void;
277
+ }) {
278
+ const t = useHelpMessages();
279
+ return (
280
+ <section class="help-start-section" data-help-search-results="">
281
+ {/* A failed full-text search says so in the same line, with a way to try again; the results do not move. */}
282
+ <div class="help-start-status">
283
+ <span aria-live="polite">{t().results({ count: props.results.length })}</span>
284
+ <Show when={props.searching}>
285
+ <i class="ti ti-loader-2 animate-spin" aria-hidden="true" />
286
+ </Show>
287
+ <Show when={props.failed}>
288
+ <span class="help-start-status-warning" data-help-search-failed="" title={t().searchTitlesOnly} role="status">
289
+ <i class="ti ti-alert-triangle" aria-hidden="true" />
290
+ <span aria-hidden="true">{t().titlesOnly}</span>
291
+ <span class="sr-only">{t().searchTitlesOnly}</span>
292
+ </span>
293
+ <Button size="xs" variant="ghost" class="help-start-status-retry" onClick={() => props.onRetry()}>
294
+ {t().tryAgain}
295
+ </Button>
296
+ </Show>
297
+ </div>
298
+ <Show
299
+ when={props.results.length > 0}
300
+ fallback={
301
+ <Show when={!props.searching}>
302
+ <Placeholder icon="ti ti-search-off" title={t().noResults} />
303
+ </Show>
304
+ }
305
+ >
306
+ <HelpTopicRows items={props.results} onOpen={props.onOpen} />
307
+ </Show>
308
+ </section>
309
+ );
310
+ }
311
+
312
+ /** Help the reader may not see. */
313
+ export function HelpNotAvailableView() {
314
+ const t = useHelpMessages();
315
+ return <Placeholder icon="ti ti-book-off" title={t().helpNotAvailable} description={t().helpNotAvailableDetail} />;
316
+ }
317
+
318
+ const ArticleLinkTitle = (props: { link: HelpArticleLink }) => (
319
+ <span class="help-article-link-copy">
320
+ <span class="help-article-link-title">{props.link.title}</span>
321
+ <Show when={props.link.description}>{(description) => <span class="help-article-link-description">{description()}</span>}</Show>
322
+ </span>
323
+ );
324
+
325
+ /** "Next" leads on; related articles keep their own app's colour, and another app says whose article it is. */
326
+ const ArticleEnd = (props: { document: HelpDocumentPayload; appId?: string }) => {
327
+ const t = useHelpMessages();
328
+ return (
329
+ <Show when={props.document.next || props.document.related.length > 0}>
330
+ <footer class="help-article-end">
331
+ <Show when={props.document.next}>
332
+ {(next) => (
333
+ <a class="help-article-next app-accent-scope focus-ui" href={next().url} style={appAccentStyle(next().accent)}>
334
+ <span class="help-article-next-label">{t().next}</span>
335
+ <span class="help-article-next-row">
336
+ <i class={`${helpIconClass(next().icon)} help-article-next-icon`} aria-hidden="true" />
337
+ <ArticleLinkTitle link={next()} />
338
+ <i class="ti ti-arrow-right help-article-next-arrow" aria-hidden="true" />
339
+ </span>
340
+ </a>
341
+ )}
342
+ </Show>
343
+ <Show when={props.document.related.length > 0}>
344
+ <section class="help-article-related" aria-labelledby="help-article-related-title">
345
+ <h4 id="help-article-related-title">{t().related}</h4>
346
+ <ul>
347
+ <For each={props.document.related}>
348
+ {(link) => (
349
+ <li>
350
+ <a class="help-article-related-link app-accent-scope focus-ui" href={link.url} style={appAccentStyle(link.accent)}>
351
+ <span class="help-topic-icon k2b-icon-tile help-article-related-icon app-accent-text" aria-hidden="true">
352
+ <i class={helpIconClass(link.icon)} />
353
+ </span>
354
+ <ArticleLinkTitle link={link} />
355
+ <Show when={link.appId !== props.appId}>
356
+ <span class="help-article-related-app app-accent-text">{link.appName}</span>
357
+ </Show>
358
+ </a>
359
+ </li>
360
+ )}
361
+ </For>
362
+ </ul>
363
+ </section>
364
+ </Show>
365
+ </footer>
366
+ </Show>
367
+ );
368
+ };
369
+
370
+ /** The inside of an article: its head, the status of its document, the body, its end, and the aside. */
371
+ export function HelpArticleView(props: {
372
+ surface: HelpSurface | "panel";
373
+ topic: HelpTopic;
374
+ appName?: string;
375
+ appId?: string;
376
+ /** The article's lead, body, sections, and step count; `null` while its document loads. */
377
+ parts: HelpArticleParts | null;
378
+ document: HelpDocumentPayload | null;
379
+ /** Renders trusted article HTML. */
380
+ Markdown: (props: { trustedHtml: string; class: string }) => JSX.Element;
381
+ showTools: boolean;
382
+ /** The article's tools under its facts: the "On this page" menu and the copy button. */
383
+ sectionMenu: JSX.Element;
384
+ copyButton: JSX.Element;
385
+ /** The article's document loads or failed to load; both sit between the article's head and its body. */
386
+ loading: JSX.Element;
387
+ failure: JSX.Element;
388
+ /** The sticky aside beside the article: its table of contents and the copy action. */
389
+ toc: JSX.Element;
390
+ copyAction: JSX.Element;
391
+ headingRef: (element: HTMLHeadingElement) => void;
392
+ contentRef: (element: HTMLDivElement) => void;
393
+ onBack: () => void;
394
+ }) {
395
+ const t = useHelpMessages();
396
+ const accessLabel = (level: HelpAccessLevel) => ({ view: t().accessView, edit: t().accessEdit, manage: t().accessManage })[level];
397
+ return (
398
+ <div class="help-article-layout">
399
+ <div class="help-article-main">
400
+ <header class="help-article-head" data-help-article-head="">
401
+ <div class="help-article-head-copy">
402
+ {/* In the panel, its head already names the app and leads back to the start. */}
403
+ <Show when={props.surface !== "panel"}>
404
+ <p class="help-article-path">
405
+ <Show when={props.appName}>
406
+ {(name) => (
407
+ <>
408
+ <span class="app-accent-text">{name()}</span>
409
+ <i class="ti ti-chevron-right" aria-hidden="true" />
410
+ </>
411
+ )}
412
+ </Show>
413
+ <button type="button" class="help-article-path-link focus-ui" onClick={props.onBack}>
414
+ {t().help}
415
+ </button>
416
+ </p>
417
+ </Show>
418
+ <h3 ref={props.headingRef} tabindex="-1" class="help-article-title focus-ui">
419
+ {props.topic.title}
420
+ </h3>
421
+ <Show
422
+ when={props.parts?.lead}
423
+ fallback={
424
+ // An article without a leading paragraph, or a built-in topic, shows its description; a loading article shows neither yet.
425
+ <Show when={props.parts && props.topic.description}>
426
+ {(description) => <p class="help-article-lead">{description()}</p>}
427
+ </Show>
428
+ }
429
+ >
430
+ {(lead) => <props.Markdown trustedHtml={lead()} class="help-document help-article-lead" />}
431
+ </Show>
432
+ <Show when={props.document?.needs || props.parts?.steps}>
433
+ {/* One row of facts; the access level appears here and nowhere else in the reader's chrome. */}
434
+ <ul class="help-article-facts" aria-label={t().articleFacts}>
435
+ <Show when={props.document?.needs}>
436
+ {(level) => (
437
+ <li class="help-article-fact" data-fact="needs">
438
+ <i class="ti ti-key" aria-hidden="true" />
439
+ <span>
440
+ {t().youNeed} <strong>{accessLabel(level())}</strong>
441
+ </span>
442
+ </li>
443
+ )}
444
+ </Show>
445
+ <Show when={props.parts?.steps}>
446
+ {(count) => (
447
+ <li class="help-article-fact" data-fact="steps">
448
+ <i class="ti ti-list-numbers" aria-hidden="true" />
449
+ <span>{t().steps({ count: count() })}</span>
450
+ </li>
451
+ )}
452
+ </Show>
453
+ </ul>
454
+ </Show>
455
+ <Show when={props.showTools}>
456
+ <div class="help-article-tools">
457
+ {props.sectionMenu}
458
+ {props.copyButton}
459
+ </div>
460
+ </Show>
461
+ </div>
462
+ {/* The embedded readers sit in the app's own chrome and keep the flat head. */}
463
+ <Show when={props.surface !== "embedded"}>
464
+ <span class="help-article-icon k2b-icon-tile app-accent-text" aria-hidden="true">
465
+ <i class={helpIconClass(props.topic.icon)} />
466
+ </span>
467
+ </Show>
468
+ </header>
469
+ {props.loading}
470
+ {props.failure}
471
+ <div ref={props.contentRef}>
472
+ <Show when={props.parts}>{(parts) => <props.Markdown trustedHtml={parts().body} class="help-document" />}</Show>
473
+ </div>
474
+ <Show when={props.document}>{(document) => <ArticleEnd document={document()} appId={props.appId} />}</Show>
475
+ </div>
476
+ <aside class="help-article-aside">
477
+ <div class="help-article-aside-sticky">
478
+ {props.toc}
479
+ {props.copyAction}
480
+ </div>
481
+ </aside>
482
+ </div>
483
+ );
484
+ }
@@ -0,0 +1,29 @@
1
+ import type { Context } from "hono";
2
+ import { readPanelCookie } from "../_internal/panel-cookie";
3
+ import { getLocale } from "../server/locale";
4
+ import { panelContextConsent } from "../services/panel-context-consent";
5
+ import { visiblePanelProviders } from "./app-navigation";
6
+ import { getRuntimeContext } from "./runtime";
7
+
8
+ /** One read, only for a host page with the slot open on a consent provider. Fail closed when the store is unavailable. */
9
+ export async function preloadPanelConsent(c: Context): Promise<void> {
10
+ const user = c.get("user");
11
+ if (!user || !c.get("runtime")) return;
12
+ const providers = visiblePanelProviders(getRuntimeContext(c).apps, user, getLocale(c));
13
+ const state = readPanelCookie(
14
+ c.req.header("Cookie"),
15
+ providers.map((provider) => provider.id),
16
+ );
17
+ const provider = state.open && providers.find((provider) => provider.id === state.provider);
18
+ if (!provider || !provider.consent) return;
19
+ // The provider's own page below its panel path renders in PanelLayout, which has no slot and never asks.
20
+ const scope = new URL(provider.href, "https://panel.invalid").pathname.replace(/\/$/, "");
21
+ if (c.req.path === scope || c.req.path.startsWith(`${scope}/`)) return;
22
+ let answer: { allowed: boolean; revision: number } | null = null;
23
+ try {
24
+ answer = await panelContextConsent.get(user.id, provider.id);
25
+ } catch {
26
+ // The shell shows the unanswered question, without context in the URL or messages.
27
+ }
28
+ c.set("panelConsent", { provider: provider.id, allowed: answer?.allowed ?? null, revision: answer?.revision ?? 0 });
29
+ }
@@ -0,0 +1,10 @@
1
+ import { PANEL_CONTEXT_PARAM, type PanelPageContext, parsePanelContextParam } from "../contracts/panel-context";
2
+
3
+ /** Untrusted URL input: a selector only. Read through your server using the session's actor/accessSubject. */
4
+ export function readPanelContext(c: { req: { raw: { url: string } } }): PanelPageContext | null {
5
+ try {
6
+ return parsePanelContextParam(new URL(c.req.raw.url).searchParams.get(PANEL_CONTEXT_PARAM));
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
@@ -0,0 +1,9 @@
1
+ /** Warm action code after mounting; WebKit has no requestIdleCallback. */
2
+ export function panelIdle(work: () => void): () => void {
3
+ if (typeof requestIdleCallback === "function") {
4
+ const id = requestIdleCallback(work);
5
+ return () => cancelIdleCallback(id);
6
+ }
7
+ const id = setTimeout(work, 200);
8
+ return () => clearTimeout(id);
9
+ }
@@ -2,12 +2,35 @@ import { AppWorkspace } from "@k2b/ui";
2
2
  import { Hono } from "hono";
3
3
  import { defineApp } from "../_internal/define-app";
4
4
  import { person } from "../_internal/define-app.test-fixture";
5
+ import { createMePanelConsentRoutes } from "../api/me-panel-consent";
5
6
  import type { RuntimeAppMeta } from "../contracts/app";
6
7
  import type { User } from "../contracts/shared";
7
8
  import Layout from "./Layout";
8
9
  import PanelLayout from "./PanelLayout";
10
+ import { readPanelContext } from "./panel-context";
9
11
  import type { RuntimeContext } from "./runtime";
10
12
 
13
+ type ConsentAnswer = { allowed: boolean; revision: number };
14
+ export const consentAnswers = new Map<string, Map<string, ConsentAnswer>>();
15
+ export const fixtureConsent = {
16
+ get: async (userId: string, provider: string) => consentAnswers.get(userId)?.get(provider) ?? null,
17
+ list: async (userId: string, providers: string[]) =>
18
+ Object.fromEntries(
19
+ providers.flatMap((id) => {
20
+ const answer = consentAnswers.get(userId)?.get(id);
21
+ return answer === undefined ? [] : [[id, answer]];
22
+ }),
23
+ ),
24
+ set: async (userId: string, provider: string, allowed: boolean, revision: number) => {
25
+ const answers = consentAnswers.get(userId) ?? new Map<string, ConsentAnswer>();
26
+ if ((answers.get(provider)?.revision ?? 0) !== revision) return null;
27
+ const saved = { allowed, revision: revision + 1 };
28
+ answers.set(provider, saved);
29
+ consentAnswers.set(userId, answers);
30
+ return saved;
31
+ },
32
+ };
33
+
11
34
  const apps: RuntimeAppMeta[] = ["fixture", "second"].map((id) => ({
12
35
  id,
13
36
  name: id === "fixture" ? "Fixture" : "Second",
@@ -29,13 +52,21 @@ type Environment = { Variables: { runtime: RuntimeContext; user: User } };
29
52
  export const server = new Hono<Environment>()
30
53
  .use(async (c, next) => {
31
54
  c.set("runtime", { apps });
32
- c.set("user", person);
55
+ const id = c.req
56
+ .header("cookie")
57
+ ?.split(";")
58
+ .map((value) => value.trim())
59
+ .find((value) => value.startsWith("fixture_person="))
60
+ ?.slice("fixture_person=".length);
61
+ c.set("user", { ...person, ...(id ? { id } : {}) });
33
62
  await next();
34
63
  })
64
+ .route("/api/me", createMePanelConsentRoutes(fixtureConsent))
35
65
  .get(
36
66
  "/app/:provider/panel",
37
67
  ...probe.ssr<Environment>((c) => () => (
38
68
  <PanelLayout c={c} title="Fixture panel">
69
+ <output id="panel-context-probe">{JSON.stringify(readPanelContext(c))}</output>
39
70
  <label>
40
71
  Panel input
41
72
  <input aria-label="Panel input" />
@@ -52,7 +83,7 @@ export const server = new Hono<Environment>()
52
83
  .get(
53
84
  "/app/fixture/long",
54
85
  ...probe.ssr<Environment>((c) => () => (
55
- <Layout c={c} title="Long fixture">
86
+ <Layout context={{ scope: { type: "fixture.place", id: "place1" } }} c={c} title="Long fixture">
56
87
  <button type="button" id="open-panel">
57
88
  Open fixture
58
89
  </button>
@@ -67,7 +98,7 @@ export const server = new Hono<Environment>()
67
98
  .get(
68
99
  "/app/fixture/:page?",
69
100
  ...probe.ssr<Environment>((c) => () => (
70
- <Layout c={c} fullWidth fullPage title="Panel fixture">
101
+ <Layout context={{ scope: { type: "fixture.place", id: "place1" } }} c={c} fullWidth fullPage title="Panel fixture">
71
102
  <AppWorkspace mobileSurface="flush" class="min-h-0 flex-1">
72
103
  <AppWorkspace.Sidebar id="panel-navigation" label="Navigation">
73
104
  <AppWorkspace.SidebarDesktop>
@@ -87,6 +118,9 @@ export const server = new Hono<Environment>()
87
118
  <a href="/app/fixture/next" id="next-page">
88
119
  Next page
89
120
  </a>
121
+ <button type="button" id="change-context">
122
+ Change place
123
+ </button>
90
124
  <textarea aria-label="App editor" />
91
125
  </AppWorkspace.Main>
92
126
  <AppWorkspace.Detail id="panel-fixture-detail" width="lg" open>
@@ -103,7 +137,22 @@ export const server = new Hono<Environment>()
103
137
 
104
138
  /** Runs inline during parsing and again immediately after mounting the real runtime. */
105
139
  export function panelGeometry() {
106
- return [".layout-shell-content", "#cloud-panel", ".k2b-app-workspace"].map((selector) => {
140
+ return [
141
+ ".layout-shell-content",
142
+ "#cloud-panel",
143
+ ".k2b-app-workspace",
144
+ ".k2b-app-workspace__main",
145
+ ".k2b-app-workspace__detail",
146
+ "[data-app-workspace-detail-toggle]",
147
+ ].map((selector) => {
148
+ const rect = document.querySelector(selector)?.getBoundingClientRect();
149
+ return rect && { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
150
+ });
151
+ }
152
+
153
+ /** The question, provider frame and app area stay in the same reserved rectangles through hydration. */
154
+ export function consentGeometry() {
155
+ return ["[data-cloud-panel-consent]", "[data-cloud-panel-frame]", "[data-cloud-app-area]"].map((selector) => {
107
156
  const rect = document.querySelector(selector)?.getBoundingClientRect();
108
157
  return rect && { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
109
158
  });