@k2b/cloud 0.43.0 → 0.45.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 (308) hide show
  1. package/package.json +11 -2
  2. package/src/_internal/app-access-request.ts +13 -0
  3. package/src/_internal/app-access-resources.ts +141 -0
  4. package/src/_internal/app-event-catalog.ts +40 -0
  5. package/src/_internal/app-event-schemas.ts +127 -0
  6. package/src/_internal/application-package.ts +40 -0
  7. package/src/_internal/built-in-app-ids.json +27 -0
  8. package/src/_internal/capabilities.ts +158 -12
  9. package/src/_internal/capability-origin.ts +4 -0
  10. package/src/_internal/capability-streams.ts +7 -5
  11. package/src/_internal/define-app.ts +57 -6
  12. package/src/_internal/object-store-area.ts +9 -0
  13. package/src/_internal/object-store-config.ts +101 -0
  14. package/src/_internal/object-store-key.ts +14 -0
  15. package/src/_internal/process-identity.ts +7 -2
  16. package/src/_internal/registry-validation.ts +11 -1
  17. package/src/_internal/registry.ts +8 -1
  18. package/src/_internal/runtime-context.ts +8 -1
  19. package/src/access/AppAccessEditor.tsx +2 -0
  20. package/src/access/GroupCoverage.tsx +20 -5
  21. package/src/access/PermissionEditor.tsx +20 -5
  22. package/src/access/PrincipalAvatar.tsx +30 -21
  23. package/src/access/PrincipalPicker.tsx +14 -4
  24. package/src/access/ResourceApiKeys.tsx +3 -4
  25. package/src/access/app-access-messages.ts +6 -0
  26. package/src/access/messages.ts +4 -4
  27. package/src/account/EntitySearch.tsx +12 -0
  28. package/src/account/ProfilePictureDialog.tsx +699 -0
  29. package/src/account/profile-picture-messages.ts +269 -0
  30. package/src/account/ui.ts +7 -0
  31. package/src/ai/admin.ts +3 -0
  32. package/src/ai/app-frame.ts +12 -0
  33. package/src/ai/application-tasks.ts +101 -0
  34. package/src/ai/assistant-models.ts +126 -64
  35. package/src/ai/capabilities.ts +19 -4
  36. package/src/ai/chart-block.ts +63 -30
  37. package/src/ai/chat/chart-block.tsx +12 -3
  38. package/src/ai/chat/composer-adapter.ts +8 -7
  39. package/src/ai/chat/message-actions.tsx +1 -1
  40. package/src/ai/chat/message-utils.ts +2 -2
  41. package/src/ai/chat/presentation.tsx +22 -1
  42. package/src/ai/chat-quotas.ts +5 -4
  43. package/src/ai/chat-tasks.ts +2 -2
  44. package/src/ai/client/controller.ts +11 -10
  45. package/src/ai/client/projection.ts +3 -3
  46. package/src/ai/compaction.ts +3 -3
  47. package/src/ai/credentials.ts +13 -114
  48. package/src/ai/default-tool-contracts.ts +4 -3
  49. package/src/ai/dictation-runtime.ts +10 -6
  50. package/src/ai/enrich.ts +2 -2
  51. package/src/ai/executor.ts +40 -25
  52. package/src/ai/http.ts +17 -29
  53. package/src/ai/index.ts +12 -5
  54. package/src/ai/inference-calls.ts +5 -4
  55. package/src/ai/maintenance.ts +1 -1
  56. package/src/ai/memory-learning-runs.ts +5 -5
  57. package/src/ai/memory-learning.ts +3 -3
  58. package/src/ai/message-queue.ts +1 -2
  59. package/src/ai/migrate.ts +75 -42
  60. package/src/ai/model-access-migrate.ts +3 -17
  61. package/src/ai/model-access.ts +44 -85
  62. package/src/ai/model-card.tsx +134 -0
  63. package/src/ai/model-catalog.ts +359 -0
  64. package/src/ai/model-chooser-messages.ts +88 -0
  65. package/src/ai/model-chooser.tsx +577 -0
  66. package/src/ai/model-delete.ts +234 -0
  67. package/src/ai/model-fixture.ts +39 -0
  68. package/src/ai/model-icon.tsx +36 -0
  69. package/src/ai/model-overview.ts +53 -0
  70. package/src/ai/model-usage.ts +45 -0
  71. package/src/ai/openai-api-messages.ts +0 -4
  72. package/src/ai/openai-api.ts +26 -9
  73. package/src/ai/personal-agent.ts +1 -1
  74. package/src/ai/prefs.ts +12 -10
  75. package/src/ai/project-tool.ts +4 -4
  76. package/src/ai/projects-authorization.ts +53 -0
  77. package/src/ai/projects-routes.ts +58 -47
  78. package/src/ai/projects.ts +110 -88
  79. package/src/ai/protocol.ts +2 -2
  80. package/src/ai/provider-connections.ts +161 -0
  81. package/src/ai/provider-errors.ts +55 -6
  82. package/src/ai/provider-fetch.ts +4 -0
  83. package/src/ai/provider.ts +1 -1
  84. package/src/ai/quota-limits.ts +36 -0
  85. package/src/ai/quota-provider.ts +4 -2
  86. package/src/ai/quota-report.ts +7 -4
  87. package/src/ai/quotas.ts +19 -8
  88. package/src/ai/recent-resources.ts +9 -3
  89. package/src/ai/request-headers.ts +14 -87
  90. package/src/ai/resource-refs.ts +6 -2
  91. package/src/ai/routes.ts +47 -48
  92. package/src/ai/runtime.ts +50 -22
  93. package/src/ai/settings.ts +93 -580
  94. package/src/ai/skill-tool.ts +4 -4
  95. package/src/ai/skills-routes.ts +27 -26
  96. package/src/ai/skills.ts +120 -81
  97. package/src/ai/store.ts +45 -23
  98. package/src/ai/stream.ts +1 -1
  99. package/src/ai/structured-runs.ts +2 -2
  100. package/src/ai/structured.ts +21 -44
  101. package/src/ai/task-contracts.ts +25 -5
  102. package/src/ai/task-execution.ts +15 -6
  103. package/src/ai/task-model.ts +9 -0
  104. package/src/ai/transcription.ts +27 -22
  105. package/src/ai/types.ts +50 -62
  106. package/src/ai/ui.tsx +13 -0
  107. package/src/ai/usage.ts +8 -8
  108. package/src/ai/validate.ts +8 -7
  109. package/src/ai/view-block.ts +2 -2
  110. package/src/api/accounts-entities.ts +22 -3
  111. package/src/api/admin-ai-quotas.ts +34 -56
  112. package/src/api/admin-ai-skills.ts +4 -1
  113. package/src/api/admin-ai-usage.ts +1 -1
  114. package/src/api/admin-ai.ts +173 -0
  115. package/src/api/admin-core-settings.ts +68 -244
  116. package/src/api/app-approval.ts +3 -1
  117. package/src/api/app-event-delivery.ts +51 -0
  118. package/src/api/app-events.ts +123 -0
  119. package/src/api/app-resource-access.ts +48 -0
  120. package/src/api/auth/account-mismatch.ts +24 -0
  121. package/src/api/auth.ts +35 -5
  122. package/src/api/capabilities.ts +28 -4
  123. package/src/api/index.ts +7 -1
  124. package/src/api/mcp.ts +175 -2
  125. package/src/api/me.ts +6 -4
  126. package/src/api/pwa-phone.ts +75 -26
  127. package/src/api/pwa.ts +34 -8
  128. package/src/api/resource-links.ts +83 -6
  129. package/src/api/resource-resolve.ts +50 -0
  130. package/src/api/search/schemas.ts +3 -1
  131. package/src/api/search.ts +9 -7
  132. package/src/app-pwa/contracts.ts +4 -0
  133. package/src/app-pwa/core-query-error.ts +20 -0
  134. package/src/app-pwa/index.ts +1 -0
  135. package/src/app-pwa/service.ts +91 -0
  136. package/src/app-pwa/transport.ts +22 -0
  137. package/src/approvals/messages.ts +2 -2
  138. package/src/browser/cloud-id.ts +86 -0
  139. package/src/browser/resource-clipboard.ts +4 -0
  140. package/src/browser/resource-links.ts +52 -0
  141. package/src/browser/session.ts +39 -0
  142. package/src/browser/shared-text-history-validation.ts +151 -0
  143. package/src/browser/shared-text-history.ts +464 -0
  144. package/src/browser/shared-text-offline-authorized.ts +29 -0
  145. package/src/browser/shared-text-offline.ts +351 -0
  146. package/src/browser/shared-text-provider.ts +714 -0
  147. package/src/browser/shared-text-table-contract.ts +150 -0
  148. package/src/browser/shared-text.ts +19 -0
  149. package/src/capabilities/server-invoke.ts +2 -11
  150. package/src/capabilities/testing.ts +5 -2
  151. package/src/cli/access.ts +6 -5
  152. package/src/cli/admin/ai-models.ts +199 -0
  153. package/src/cli/admin/ai-quotas.ts +6 -102
  154. package/src/cli/admin/ai-usage.ts +4 -4
  155. package/src/cli/admin/app-credentials.ts +5 -3
  156. package/src/cli/admin/index.ts +5 -2
  157. package/src/cli/apps.ts +89 -26
  158. package/src/cli/capabilities.ts +22 -1
  159. package/src/cli/events.ts +320 -0
  160. package/src/config/env.ts +40 -0
  161. package/src/contracts/app-access.ts +26 -2
  162. package/src/contracts/app-details.ts +89 -0
  163. package/src/contracts/app-events.ts +51 -0
  164. package/src/contracts/app.ts +21 -5
  165. package/src/contracts/capabilities.ts +143 -3
  166. package/src/contracts/index.ts +2 -0
  167. package/src/contracts/notification-types.ts +36 -9
  168. package/src/contracts/pwa.ts +17 -1
  169. package/src/contracts/registry.ts +6 -0
  170. package/src/contracts/resource-clipboard.ts +35 -0
  171. package/src/contracts/resource-pages.ts +140 -0
  172. package/src/contracts/resource-resolution.ts +13 -0
  173. package/src/contracts/shared.ts +6 -1
  174. package/src/contracts/user-notifications.ts +2 -0
  175. package/src/events/app-event-delivery.ts +100 -0
  176. package/src/events/app-events.testing.ts +65 -0
  177. package/src/events/app-events.ts +134 -0
  178. package/src/events/core-user-created.ts +24 -0
  179. package/src/events/index.ts +9 -0
  180. package/src/events/testing.ts +2 -0
  181. package/src/index.ts +1 -1
  182. package/src/server/api/respond.ts +17 -4
  183. package/src/server/index.ts +2 -0
  184. package/src/server/middleware/app-access.ts +1 -1
  185. package/src/server/middleware/auth.ts +21 -7
  186. package/src/server/middleware/index.ts +1 -0
  187. package/src/server/middleware/invocation.ts +2 -0
  188. package/src/server/public-storage.ts +106 -0
  189. package/src/server/services/access.ts +31 -10
  190. package/src/server/services/index.ts +1 -0
  191. package/src/services/accounts/app.ts +36 -13
  192. package/src/services/accounts/entities.ts +43 -19
  193. package/src/services/accounts/group-sql.ts +23 -0
  194. package/src/services/accounts/groups.ts +19 -14
  195. package/src/services/accounts/identities.ts +70 -11
  196. package/src/services/accounts/identity-reconciliation.ts +3 -1
  197. package/src/services/accounts/local-groups.ts +69 -4
  198. package/src/services/accounts/users.ts +45 -14
  199. package/src/services/app-access.ts +25 -7
  200. package/src/services/app-details.ts +224 -0
  201. package/src/services/app-discovery.ts +2 -1
  202. package/src/services/app-event-shapes.ts +139 -0
  203. package/src/services/app-events/accept.ts +116 -0
  204. package/src/services/app-events/admin.ts +250 -0
  205. package/src/services/app-events/declarations.ts +187 -0
  206. package/src/services/app-events/deliver.ts +218 -0
  207. package/src/services/app-events.ts +92 -0
  208. package/src/services/config-conflict.ts +11 -0
  209. package/src/services/core-user-events.ts +1 -0
  210. package/src/services/freeipa-config.ts +9 -0
  211. package/src/services/geo/ledger.ts +28 -10
  212. package/src/services/geo/service.ts +43 -24
  213. package/src/services/identity/core-origin.ts +12 -0
  214. package/src/services/identity/invocation-actor.ts +20 -2
  215. package/src/services/identity/invocation-authority.ts +4 -0
  216. package/src/services/identity/invocation-token.ts +238 -56
  217. package/src/services/identity/issue-invocation.ts +6 -1
  218. package/src/services/image-metadata.ts +65 -1
  219. package/src/services/index.ts +13 -1
  220. package/src/services/ipa/sync.ts +6 -2
  221. package/src/services/ipa/users.ts +4 -1
  222. package/src/services/mandates/index.ts +2 -2
  223. package/src/services/mandates/policy.ts +20 -12
  224. package/src/services/notifications/browser.ts +4 -0
  225. package/src/services/notifications/catalog.ts +5 -2
  226. package/src/services/notifications/platform.ts +7 -2
  227. package/src/services/notifications/user.ts +18 -7
  228. package/src/services/oauth-tokens.ts +28 -2
  229. package/src/services/object-store.ts +217 -0
  230. package/src/services/providers/local/users.ts +5 -0
  231. package/src/services/pwa-devices.ts +147 -37
  232. package/src/services/session/index.ts +26 -3
  233. package/src/services/settings/core-settings.ts +71 -43
  234. package/src/services/settings/index.ts +8 -0
  235. package/src/services/settings/versions.ts +26 -0
  236. package/src/shared/ai-models.ts +274 -0
  237. package/src/shared/ai-quotas.ts +2 -2
  238. package/src/shared/ai-usage.ts +2 -2
  239. package/src/shared/email-html.ts +1 -0
  240. package/src/shared/index.ts +2 -0
  241. package/src/shared/markdown/extensions/katex.ts +8 -1
  242. package/src/shared/markdown/extensions/tables.ts +14 -1
  243. package/src/shared/markdown/index.ts +22 -11
  244. package/src/shared-text/anchors.ts +29 -0
  245. package/src/shared-text/awareness.ts +87 -0
  246. package/src/shared-text/changes.ts +27 -0
  247. package/src/shared-text/documents.ts +433 -65
  248. package/src/shared-text/events.ts +12 -2
  249. package/src/shared-text/index.ts +16 -1
  250. package/src/shared-text/log.ts +24 -5
  251. package/src/shared-text/snapshots.ts +69 -23
  252. package/src/shared-text/structural-history.ts +377 -0
  253. package/src/shared-text/table-actions.ts +432 -0
  254. package/src/shared-text/text-diff.ts +75 -0
  255. package/src/shared-text/types.ts +24 -5
  256. package/src/ssr/AdminArea.tsx +131 -0
  257. package/src/ssr/AdminLayout.tsx +9 -3
  258. package/src/ssr/AdminSidebar.tsx +7 -7
  259. package/src/ssr/AppAccessDenied.tsx +2 -0
  260. package/src/ssr/AppBadge.tsx +14 -0
  261. package/src/ssr/AppLaunchpadPanel.tsx +2 -2
  262. package/src/ssr/CloudShell.island.tsx +44 -7
  263. package/src/ssr/Layout.tsx +9 -7
  264. package/src/ssr/MobileProfileActions.tsx +2 -2
  265. package/src/ssr/ProfilePreferences.tsx +2 -1
  266. package/src/ssr/PwaLayout.tsx +16 -1
  267. package/src/ssr/PwaPartCount.island.tsx +23 -0
  268. package/src/ssr/SettingsSaveOutcome.tsx +58 -0
  269. package/src/ssr/SettingsSaveStatus.island.tsx +16 -0
  270. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +3 -3
  271. package/src/ssr/admin-active-link.ts +16 -0
  272. package/src/ssr/admin-navigation.ts +26 -31
  273. package/src/ssr/app-badges.ts +31 -7
  274. package/src/ssr/app-launchpad.tsx +6 -1
  275. package/src/ssr/index.ts +8 -0
  276. package/src/ssr/page-trail-runtime.ts +3 -1
  277. package/src/ssr/platform-messages.ts +11 -1
  278. package/src/ssr/profile-actions.ts +3 -6
  279. package/src/ssr/pwa-messages.ts +2 -2
  280. package/src/ssr/rail-navigation.ts +5 -1
  281. package/src/ssr/settings-draft.ts +153 -0
  282. package/src/ssr/shell-data.tsx +3 -1
  283. package/src/styles/global.css +1 -0
  284. package/src/styles/profile-picture.css +270 -0
  285. package/src/styles/utilities-navigation.css +6 -0
  286. package/src/workflows/ai/policy.ts +49 -0
  287. package/src/workflows/ai/runtime.ts +131 -21
  288. package/src/workflows/ai/store.ts +283 -35
  289. package/src/workflows/ai/types.ts +8 -2
  290. package/src/workflows/ai-actions.ts +148 -120
  291. package/src/workflows/authoring.ts +31 -0
  292. package/src/workflows/contracts.ts +1 -0
  293. package/src/workflows/definition.ts +2 -0
  294. package/src/workflows/editor.ts +15 -0
  295. package/src/workflows/index.ts +2 -1
  296. package/src/workflows/language/compiler.ts +34 -3
  297. package/src/workflows/language/expressions.ts +3 -2
  298. package/src/workflows/runtime/executor.ts +134 -34
  299. package/src/workflows/runtime/index.ts +2 -0
  300. package/src/workflows/runtime/ports.ts +5 -1
  301. package/src/workflows/store/actions.ts +1 -1
  302. package/src/workflows/store/definitions.ts +5 -0
  303. package/src/workflows/store/runs.ts +5 -3
  304. package/src/workflows/store/worker.ts +7 -2
  305. package/src/ai/model-pricing.ts +0 -43
  306. package/src/ai/model-request-settings.ts +0 -98
  307. package/src/ai/settings-messages.ts +0 -129
  308. package/src/shared/ai-model-request-settings.ts +0 -21
@@ -0,0 +1,699 @@
1
+ import {
2
+ Avatar,
3
+ announce,
4
+ Button,
5
+ ChoiceTiles,
6
+ ColorChoices,
7
+ dialogCore,
8
+ FileDropzone,
9
+ ImageCropper,
10
+ type ImageCropState,
11
+ PanelDialog,
12
+ panelDialogWorkspaceOptions,
13
+ panelDialogWorkspacePanelClass,
14
+ prompts,
15
+ Tabs,
16
+ useLocale,
17
+ } from "@k2b/ui";
18
+ import { createEffect, createMemo, createResource, createSignal, For, type JSX, Match, on, Show, Switch } from "solid-js";
19
+ import { chooseFiles } from "../browser/files";
20
+ import {
21
+ AVATAR_BACKGROUNDS,
22
+ type AvatarConfig,
23
+ AvatarConfigSchema,
24
+ type AvatarKind,
25
+ PERSON_BEARDS,
26
+ PERSON_BROWS,
27
+ PERSON_EXTRAS,
28
+ PERSON_EYES,
29
+ PERSON_FACES,
30
+ PERSON_GLASSES,
31
+ PERSON_HAIR_COLORS,
32
+ PERSON_HAIRSTYLES,
33
+ PERSON_HEADWEAR,
34
+ PERSON_MOUTHS,
35
+ PERSON_SKIN_TONES,
36
+ type PersonAvatar,
37
+ ROBOT_ANTENNAS,
38
+ ROBOT_BODIES,
39
+ ROBOT_COLORS,
40
+ ROBOT_EYES,
41
+ ROBOT_HEADS,
42
+ ROBOT_MOUTHS,
43
+ type RobotAvatar,
44
+ randomizePersonAvatar,
45
+ randomRobotAvatar,
46
+ startPersonAvatar,
47
+ } from "../contracts/avatar";
48
+ import { avatarSwatches, renderAvatarSvg } from "../shared/avatar-art";
49
+ import { AvatarUploadError, createAvatarDataUrlFromFile, validateAvatarSourceFile } from "./avatar-upload";
50
+ import { profilePictureMessages } from "./profile-picture-messages";
51
+
52
+ /** What the dialog reads and writes: the shape of `GET/PUT /api/me/avatar` and the service-account routes. */
53
+ export type ProfilePictureState = {
54
+ kind: AvatarKind;
55
+ built: AvatarConfig | null;
56
+ hasPhoto: boolean;
57
+ photo: string | null;
58
+ avatarHash: string | null;
59
+ };
60
+
61
+ export type ProfilePictureChange = { dataUrl: string } | { built: AvatarConfig } | { use: AvatarKind };
62
+
63
+ export type ProfilePictureDialogOptions = {
64
+ /** A person picks photo, avatar, or initials; an agent picks photo or robot. */
65
+ subject: "person" | "agent";
66
+ /** The account's name; initials come from it. */
67
+ name: string;
68
+ load: () => Promise<ProfilePictureState>;
69
+ save: (change: ProfilePictureChange) => Promise<ProfilePictureState>;
70
+ deletePhoto: () => Promise<ProfilePictureState>;
71
+ };
72
+
73
+ type Way = "photo" | "built" | "initials";
74
+ type Messages = ReturnType<typeof profilePictureMessages.resolve>["t"];
75
+ type Part = { key: string; label: string; options: readonly string[]; labels: string; color?: Record<string, string> };
76
+ type Category = { id: string; label: string; parts: Part[] };
77
+
78
+ const SIZES = [64, 40, 32, 24, 20] as const;
79
+
80
+ const partValue = (config: AvatarConfig, key: string): string => {
81
+ const record: Record<string, unknown> = config;
82
+ return String(record[key] ?? "");
83
+ };
84
+ /** A config with one part changed, validated against its shape set. */
85
+ const withPart = (config: AvatarConfig, key: string, value: string): AvatarConfig => AvatarConfigSchema.parse({ ...config, [key]: value });
86
+ const PHOTO_TYPES = "image/png,image/jpeg,image/webp";
87
+
88
+ /** A built avatar drawn inline from its config; the markup holds only shapes from fixed ids. */
89
+ function Drawing(props: { config: AvatarConfig; size: number | "fill"; crop?: "full" | "thumbnail"; label?: string }): JSX.Element {
90
+ const sizeStyle = () =>
91
+ props.size === "fill" ? { width: "100%", height: "100%" } : { width: `${props.size}px`, height: `${props.size}px` };
92
+ const markup = () => renderAvatarSvg(props.config, props.crop ?? "full");
93
+ const shape = () => (props.config.kind === "robot" ? "square" : "circle");
94
+ return (
95
+ <Show
96
+ when={props.label}
97
+ fallback={
98
+ <span class="cloud-profile-picture__drawing" data-shape={shape()} style={sizeStyle()} aria-hidden="true" innerHTML={markup()} />
99
+ }
100
+ >
101
+ {(label) => (
102
+ <span
103
+ class="cloud-profile-picture__drawing"
104
+ data-shape={shape()}
105
+ style={sizeStyle()}
106
+ role="img"
107
+ aria-label={label()}
108
+ innerHTML={markup()}
109
+ />
110
+ )}
111
+ </Show>
112
+ );
113
+ }
114
+
115
+ function Initials(props: { name: string; size: number }): JSX.Element {
116
+ return (
117
+ <Avatar
118
+ name={props.name}
119
+ style={{ width: `${props.size}px`, height: `${props.size}px`, "font-size": `${Math.round(props.size * 0.36)}px` }}
120
+ />
121
+ );
122
+ }
123
+
124
+ function Photo(props: { src: string; size: number; square: boolean }): JSX.Element {
125
+ return (
126
+ <img
127
+ class="cloud-profile-picture__photo"
128
+ data-shape={props.square ? "square" : "circle"}
129
+ src={props.src}
130
+ alt=""
131
+ style={{ width: `${props.size}px`, height: `${props.size}px` }}
132
+ />
133
+ );
134
+ }
135
+
136
+ const personCategories = (t: Messages): Category[] => {
137
+ const part = (key: keyof PersonAvatar, label: string, options: readonly string[], color?: Record<string, string>): Part => ({
138
+ key,
139
+ label,
140
+ options,
141
+ labels: key,
142
+ color,
143
+ });
144
+ const colorMap = (list: { id: string; color: string }[]) => Object.fromEntries(list.map((swatch) => [swatch.id, swatch.color]));
145
+ return [
146
+ {
147
+ id: "face",
148
+ label: t.face,
149
+ parts: [part("face", t.faceShape, PERSON_FACES), part("skin", t.skin, PERSON_SKIN_TONES, colorMap(avatarSwatches.skin))],
150
+ },
151
+ {
152
+ id: "hair",
153
+ label: t.hair,
154
+ parts: [
155
+ part("hair", t.hairstyle, PERSON_HAIRSTYLES),
156
+ part("hairColor", t.hairColor, PERSON_HAIR_COLORS, colorMap(avatarSwatches.hairColor)),
157
+ ],
158
+ },
159
+ { id: "eyes", label: t.eyes, parts: [part("eyes", t.eyes, PERSON_EYES)] },
160
+ { id: "brows", label: t.brows, parts: [part("brows", t.eyebrows, PERSON_BROWS)] },
161
+ { id: "mouth", label: t.mouth, parts: [part("mouth", t.mouth, PERSON_MOUTHS)] },
162
+ { id: "beard", label: t.beard, parts: [part("beard", t.beard, PERSON_BEARDS)] },
163
+ { id: "glasses", label: t.glasses, parts: [part("glasses", t.glasses, PERSON_GLASSES)] },
164
+ { id: "head", label: t.head, parts: [part("head", t.headwear, PERSON_HEADWEAR)] },
165
+ { id: "extra", label: t.extra, parts: [part("extra", t.extra, PERSON_EXTRAS)] },
166
+ { id: "bg", label: t.colour, parts: [part("bg", t.backgroundAndClothes, AVATAR_BACKGROUNDS, colorMap(avatarSwatches.bg))] },
167
+ ];
168
+ };
169
+
170
+ const robotCategories = (t: Messages): Category[] => {
171
+ const colorMap = (list: { id: string; color: string }[]) => Object.fromEntries(list.map((swatch) => [swatch.id, swatch.color]));
172
+ const part = (
173
+ key: keyof RobotAvatar,
174
+ label: string,
175
+ options: readonly string[],
176
+ labels: string,
177
+ color?: Record<string, string>,
178
+ ): Part => ({
179
+ key,
180
+ label,
181
+ options,
182
+ labels,
183
+ color,
184
+ });
185
+ return [
186
+ { id: "head", label: t.head, parts: [part("head", t.head, ROBOT_HEADS, "robotHead")] },
187
+ { id: "antenna", label: t.antenna, parts: [part("antenna", t.antenna, ROBOT_ANTENNAS, "antenna")] },
188
+ { id: "eyes", label: t.eyes, parts: [part("eyes", t.eyes, ROBOT_EYES, "robotEyes")] },
189
+ { id: "mouth", label: t.mouth, parts: [part("mouth", t.mouth, ROBOT_MOUTHS, "robotMouth")] },
190
+ { id: "body", label: t.body, parts: [part("body", t.body, ROBOT_BODIES, "body")] },
191
+ {
192
+ id: "colors",
193
+ label: t.colours,
194
+ parts: [
195
+ part("color", t.casing, ROBOT_COLORS, "color", colorMap(avatarSwatches.robotColor)),
196
+ part("bg", t.background, AVATAR_BACKGROUNDS, "bg", colorMap(avatarSwatches.bg)),
197
+ ],
198
+ },
199
+ ];
200
+ };
201
+
202
+ function Builder(props: {
203
+ subject: "person" | "agent";
204
+ draft: AvatarConfig;
205
+ base: AvatarConfig;
206
+ canUndo: boolean;
207
+ category: string;
208
+ onCategory: (id: string) => void;
209
+ onChange: (key: string, value: string, label: string) => void;
210
+ onRandom: () => void;
211
+ onUndo: () => void;
212
+ onReset: () => void;
213
+ t: Messages;
214
+ }): JSX.Element {
215
+ const categories = createMemo(() => (props.subject === "person" ? personCategories(props.t) : robotCategories(props.t)));
216
+ const changed = () => JSON.stringify(props.draft) !== JSON.stringify(props.base);
217
+ const hairHidden = () => props.draft.kind === "person" && (props.draft.head === "hijab" || props.draft.head === "turban");
218
+ const value = (key: string) => partValue(props.draft, key);
219
+ const label = (part: Part, id: string) => props.t.option({ part: part.labels, id });
220
+
221
+ const partControl = (part: Part) =>
222
+ part.color ? (
223
+ <ColorChoices
224
+ label={part.label}
225
+ value={value(part.key)}
226
+ options={part.options.map((id) => ({ value: id, label: label(part, id), color: part.color?.[id] ?? "transparent" }))}
227
+ onValueChange={(next) => props.onChange(part.key, next, label(part, next))}
228
+ />
229
+ ) : (
230
+ <ChoiceTiles
231
+ label={part.label}
232
+ description={
233
+ part.key === "hair" && hairHidden()
234
+ ? props.t.hairHidden({
235
+ cover: props.draft.kind === "person" && props.draft.head === "hijab" ? props.t.hijabCover : props.t.turbanCover,
236
+ })
237
+ : part.key === "beard"
238
+ ? props.t.beardColour
239
+ : undefined
240
+ }
241
+ dimmed={part.key === "hair" && hairHidden()}
242
+ value={value(part.key)}
243
+ options={part.options.map((id) => ({
244
+ value: id,
245
+ label: label(part, id),
246
+ visual: <Drawing config={withPart(props.draft, part.key, id)} size={60} crop="thumbnail" />,
247
+ }))}
248
+ onValueChange={(next) => props.onChange(part.key, next, label(part, next))}
249
+ />
250
+ );
251
+
252
+ return (
253
+ <div class="cloud-profile-picture__builder">
254
+ <div class="cloud-profile-picture__preview">
255
+ <span class="cloud-profile-picture__big">
256
+ <Drawing config={props.draft} size="fill" label={props.t.preview} />
257
+ </span>
258
+ <div class="cloud-profile-picture__stack">
259
+ <div class="cloud-profile-picture__sizes" role="group" aria-label={props.t.previewSizes}>
260
+ <For each={SIZES}>
261
+ {(size) => (
262
+ <figure>
263
+ <Drawing config={props.draft} size={size} />
264
+ <figcaption>{size}</figcaption>
265
+ </figure>
266
+ )}
267
+ </For>
268
+ </div>
269
+ <div
270
+ class="k2b-ui cloud-profile-picture__sizes cloud-profile-picture__sizes--dark"
271
+ data-theme="dark"
272
+ role="group"
273
+ aria-label={props.t.previewDark}
274
+ >
275
+ <For each={SIZES}>
276
+ {(size) => (
277
+ <figure>
278
+ <Drawing config={props.draft} size={size} />
279
+ <figcaption>{size}</figcaption>
280
+ </figure>
281
+ )}
282
+ </For>
283
+ </div>
284
+ <div class="cloud-profile-picture__tools">
285
+ <Button type="button" variant="ghost" size="sm" onClick={props.onRandom}>
286
+ <i class="ti ti-dice-5" aria-hidden="true" />
287
+ {props.t.random}
288
+ </Button>
289
+ <Button
290
+ type="button"
291
+ variant="ghost"
292
+ size="sm"
293
+ onClick={props.onUndo}
294
+ disabled={!props.canUndo}
295
+ aria-label={props.t.undo}
296
+ title={props.t.undo}
297
+ >
298
+ <i class="ti ti-arrow-back-up" aria-hidden="true" />
299
+ <span class="cloud-profile-picture__tool-label">{props.t.undo}</span>
300
+ </Button>
301
+ <Button
302
+ type="button"
303
+ variant="ghost"
304
+ size="sm"
305
+ onClick={props.onReset}
306
+ disabled={!changed()}
307
+ aria-label={props.t.reset}
308
+ title={props.t.reset}
309
+ >
310
+ <i class="ti ti-restore" aria-hidden="true" />
311
+ <span class="cloud-profile-picture__tool-label">{props.t.reset}</span>
312
+ </Button>
313
+ </div>
314
+ </div>
315
+ </div>
316
+ <div class="cloud-profile-picture__parts">
317
+ <Tabs
318
+ ariaLabel={props.t.parts}
319
+ variant="pill"
320
+ value={props.category}
321
+ onValueChange={props.onCategory}
322
+ options={categories().map((category) => ({
323
+ value: category.id,
324
+ label: category.label,
325
+ panel: <div class="cloud-profile-picture__panel">{category.parts.map(partControl)}</div>,
326
+ }))}
327
+ />
328
+ </div>
329
+ </div>
330
+ );
331
+ }
332
+
333
+ function ProfilePictureDialog(
334
+ props: ProfilePictureDialogOptions & {
335
+ close: (state?: ProfilePictureState) => void;
336
+ setDismissHandler: (handler: () => void | Promise<void>) => void;
337
+ },
338
+ ): JSX.Element {
339
+ const locale = useLocale();
340
+ const t = () => profilePictureMessages.resolve([locale()]).t;
341
+ const [state, { refetch, mutate }] = createResource(() => props.load());
342
+ // Reading a failed resource throws; every reader goes through this guard and the error branch shows the retry.
343
+ const loaded = () => (state.state === "ready" || state.state === "refreshing" ? state() : undefined);
344
+ const [way, setWay] = createSignal<Way | null>(null);
345
+ const [openWay, setOpenWay] = createSignal<Way>("built");
346
+ const [draft, setDraft] = createSignal<AvatarConfig | null>(null);
347
+ const [base, setBase] = createSignal<AvatarConfig | null>(null);
348
+ const [history, setHistory] = createSignal<AvatarConfig[]>([]);
349
+ const [category, setCategory] = createSignal(props.subject === "person" ? "face" : "head");
350
+ const [photoSource, setPhotoSource] = createSignal<File | null>(null);
351
+ const [crop, setCrop] = createSignal<ImageCropState | null>(null);
352
+ const [photoError, setPhotoError] = createSignal<string | null>(null);
353
+ const [saving, setSaving] = createSignal(false);
354
+ const [deleting, setDeleting] = createSignal(false);
355
+ const [saveError, setSaveError] = createSignal(false);
356
+ // A deleted photo changed the server state even when the person then closes without saving.
357
+ let changedOnServer: ProfilePictureState | undefined;
358
+ const square = props.subject === "agent";
359
+
360
+ // The first load decides where the dialog opens: a person without a built avatar starts on a random first draft.
361
+ createEffect(
362
+ on(loaded, (current) => {
363
+ if (!current || draft()) return;
364
+ const first = current.built ?? (props.subject === "person" ? startPersonAvatar() : randomRobotAvatar());
365
+ setDraft(first);
366
+ setBase(first);
367
+ const opening: Way = current.kind === "initials" && !current.built ? "built" : current.kind;
368
+ setOpenWay(opening);
369
+ setWay(opening);
370
+ }),
371
+ );
372
+ const ready = () => Boolean(loaded()) && draft() !== null;
373
+
374
+ const current = () => way() ?? openWay();
375
+ const photoReachable = () => current() !== "photo" || Boolean(loaded()?.hasPhoto) || photoSource() !== null;
376
+ const same = (a: AvatarConfig | null | undefined, b: AvatarConfig | null | undefined) => JSON.stringify(a) === JSON.stringify(b);
377
+ // Changed by the person in this dialog: closing asks first, and the footer says it is not saved yet.
378
+ const touched = () => photoSource() !== null || (current() !== openWay() && photoReachable()) || !same(draft(), base());
379
+ // Different from what is stored: only then does Save send anything. A first draft of a new avatar counts.
380
+ const differsFromStored = () => {
381
+ const stored = loaded();
382
+ if (!stored) return false;
383
+ if (photoSource()) return true;
384
+ if (current() === "built") return stored.kind !== "built" || !same(draft(), stored.built);
385
+ return current() !== stored.kind && photoReachable();
386
+ };
387
+ const busy = () => saving() || deleting();
388
+ const canSave = () =>
389
+ ready() &&
390
+ !busy() &&
391
+ // A new photo needs its crop; without a stored photo there is nothing else to show.
392
+ (photoSource() ? crop() !== null : !(current() === "photo" && !loaded()?.hasPhoto));
393
+
394
+ const requestClose = async () => {
395
+ if (busy()) return;
396
+ if (touched()) {
397
+ const discard = await prompts.confirm(t().discardBody, {
398
+ title: t().discardTitle,
399
+ variant: "danger",
400
+ confirmText: t().discardConfirm,
401
+ cancelText: t().discardCancel,
402
+ });
403
+ if (!discard || busy()) return;
404
+ }
405
+ props.close(changedOnServer);
406
+ };
407
+ props.setDismissHandler(requestClose);
408
+
409
+ const change = (key: string, value: string, label: string) => {
410
+ const before = draft();
411
+ if (!before || partValue(before, key) === value) return;
412
+ setHistory((list) => [...list, before]);
413
+ setDraft(withPart(before, key, value));
414
+ setSaveError(false);
415
+ announce(t().chosen({ name: label }));
416
+ };
417
+ const randomize = () => {
418
+ const before = draft();
419
+ if (!before) return;
420
+ setHistory((list) => [...list, before]);
421
+ setDraft(before.kind === "person" ? randomizePersonAvatar(before) : randomRobotAvatar());
422
+ setSaveError(false);
423
+ announce(t().randomized);
424
+ };
425
+ const undo = () => {
426
+ const list = history();
427
+ const previous = list.at(-1);
428
+ if (!previous) return;
429
+ setHistory(list.slice(0, -1));
430
+ setDraft(previous);
431
+ announce(t().undone);
432
+ };
433
+ const reset = () => {
434
+ const before = draft();
435
+ const original = base();
436
+ if (!before || !original) return;
437
+ setHistory((list) => [...list, before]);
438
+ setDraft(original);
439
+ announce(t().resetDone);
440
+ };
441
+
442
+ const choosePhoto = async (files: File[]) => {
443
+ const file = files[0];
444
+ if (!file) return;
445
+ setPhotoError(null);
446
+ try {
447
+ validateAvatarSourceFile(file);
448
+ setPhotoSource(file);
449
+ setCrop(null);
450
+ } catch (error) {
451
+ setPhotoError(uploadErrorMessage(error));
452
+ }
453
+ };
454
+
455
+ const uploadErrorMessage = (error: unknown) => {
456
+ if (!(error instanceof AvatarUploadError)) return t().photoProcessFailed;
457
+ if (error.code === "type_invalid") return t().photoTypeInvalid;
458
+ if (error.code === "too_large") return t().photoTooLarge;
459
+ if (error.code === "empty") return t().photoEmpty;
460
+ if (error.code === "unsupported") return t().photoUnsupported;
461
+ return t().photoProcessFailed;
462
+ };
463
+
464
+ const save = async () => {
465
+ if (!loaded() || !canSave()) return;
466
+ if (!differsFromStored()) {
467
+ props.close(changedOnServer);
468
+ return;
469
+ }
470
+ setSaving(true);
471
+ setSaveError(false);
472
+ try {
473
+ const target = current();
474
+ const file = photoSource();
475
+ const area = crop();
476
+ const built = draft();
477
+ // A new photo is saved only with its crop; it never falls back to showing the stored one.
478
+ if (target === "photo" && file && !area) return;
479
+ const next =
480
+ target === "photo" && file && area
481
+ ? await props.save({ dataUrl: await createAvatarDataUrlFromFile(file, area) })
482
+ : target === "built" && built
483
+ ? await props.save({ built })
484
+ : await props.save({ use: target });
485
+ props.close(next);
486
+ } catch (error) {
487
+ if (error instanceof AvatarUploadError) setPhotoError(uploadErrorMessage(error));
488
+ setSaveError(true);
489
+ } finally {
490
+ setSaving(false);
491
+ }
492
+ };
493
+
494
+ const deletePhoto = async () => {
495
+ if (busy()) return;
496
+ setDeleting(true);
497
+ setPhotoError(null);
498
+ try {
499
+ const next = await props.deletePhoto();
500
+ changedOnServer = next;
501
+ mutate(next);
502
+ setPhotoSource(null);
503
+ if (openWay() === "photo") setOpenWay(next.kind);
504
+ announce(t().photoDeleted);
505
+ } catch {
506
+ setPhotoError(t().photoDeleteFailed);
507
+ } finally {
508
+ setDeleting(false);
509
+ }
510
+ };
511
+
512
+ const wayOptions = () => {
513
+ const current = loaded();
514
+ const visual = (kind: Way) => {
515
+ if (kind === "photo") {
516
+ return current?.photo ? (
517
+ <Photo src={current.photo} size={36} square={square} />
518
+ ) : (
519
+ <span class="cloud-profile-picture__empty" data-shape={square ? "square" : "circle"}>
520
+ <i class="ti ti-photo" />
521
+ </span>
522
+ );
523
+ }
524
+ if (kind === "built") return <Show when={draft()}>{(config) => <Drawing config={config()} size={36} />}</Show>;
525
+ return <Initials name={props.name} size={36} />;
526
+ };
527
+ const ways: Way[] = props.subject === "person" ? ["photo", "built", "initials"] : ["photo", "built"];
528
+ return ways.map((kind) => ({
529
+ value: kind,
530
+ label: kind === "photo" ? t().photo : kind === "built" ? (props.subject === "person" ? t().avatar : t().robot) : t().initials,
531
+ status: current?.kind === kind ? t().active : "",
532
+ visual: visual(kind),
533
+ }));
534
+ };
535
+
536
+ const photoPane = () => {
537
+ const current = loaded();
538
+ const file = photoSource();
539
+ return (
540
+ <div class="cloud-profile-picture__pane">
541
+ <Show
542
+ when={file || current?.photo}
543
+ fallback={
544
+ <FileDropzone
545
+ accept={PHOTO_TYPES}
546
+ multiple={false}
547
+ choose={() => chooseFiles({ accept: PHOTO_TYPES })}
548
+ error={photoError}
549
+ icon="ti ti-photo-up"
550
+ title={t().photoDrop}
551
+ subtitle={t().photoFormats}
552
+ hint={t().photoChoose}
553
+ onDrop={choosePhoto}
554
+ />
555
+ }
556
+ >
557
+ <Show when={file} fallback={<Photo src={current?.photo ?? ""} size={160} square={square} />}>
558
+ <div class="cloud-profile-picture__cropper">
559
+ <ImageCropper
560
+ source={file as File}
561
+ aspect={{ width: 1, height: 1 }}
562
+ previewShape={square ? "rect" : "circle"}
563
+ onValueChange={setCrop}
564
+ />
565
+ </div>
566
+ </Show>
567
+ <Show when={photoError()}>
568
+ <p role="alert">{photoError()}</p>
569
+ </Show>
570
+ <p>{file ? t().photoNew : current?.kind === "photo" ? t().photoActive : t().photoHidden}</p>
571
+ <div class="cloud-profile-picture__row">
572
+ <Button
573
+ type="button"
574
+ variant="secondary"
575
+ size="sm"
576
+ onClick={() => void chooseFiles({ accept: PHOTO_TYPES }).then(choosePhoto)}
577
+ disabled={deleting()}
578
+ >
579
+ <i class="ti ti-replace" aria-hidden="true" />
580
+ {t().photoOther}
581
+ </Button>
582
+ <Show when={current?.hasPhoto}>
583
+ <Button type="button" variant="danger" size="sm" onClick={deletePhoto} disabled={deleting()}>
584
+ <i class="ti ti-trash" aria-hidden="true" />
585
+ {t().photoDelete}
586
+ </Button>
587
+ </Show>
588
+ </div>
589
+ </Show>
590
+ </div>
591
+ );
592
+ };
593
+
594
+ return (
595
+ <PanelDialog>
596
+ <PanelDialog.Header title={t().title} close={requestClose} />
597
+ <PanelDialog.Body>
598
+ {/* While a save or a deletion runs, the choices hold still, so the saved picture is the one on screen. */}
599
+ <div class="cloud-profile-picture" inert={busy() ? true : undefined} aria-busy={busy() ? "true" : undefined}>
600
+ <Switch>
601
+ <Match when={state.error}>
602
+ <div class="cloud-profile-picture__pane" role="alert">
603
+ <p>{t().loadFailed}</p>
604
+ <Button type="button" variant="secondary" size="sm" onClick={() => void refetch()}>
605
+ {t().retry}
606
+ </Button>
607
+ </div>
608
+ </Match>
609
+ <Match when={!ready()}>
610
+ <div class="cloud-profile-picture__pane" role="status" aria-label={t().loading}>
611
+ <span class="cloud-profile-picture__placeholder" />
612
+ </div>
613
+ </Match>
614
+ <Match when={ready()}>
615
+ <ChoiceTiles
616
+ aria-label={t().ways}
617
+ layout="row"
618
+ value={current()}
619
+ options={wayOptions()}
620
+ onValueChange={(next) => {
621
+ setWay(next);
622
+ setSaveError(false);
623
+ }}
624
+ />
625
+ {/* Every kind stays mounted, so a crop or a draft survives a look at another kind. */}
626
+ <div class="cloud-profile-picture__body">
627
+ <div hidden={current() !== "photo"}>{photoPane()}</div>
628
+ <div hidden={current() !== "built"}>
629
+ <Show when={draft()}>
630
+ {(config) => (
631
+ <Builder
632
+ subject={props.subject}
633
+ draft={config()}
634
+ base={base() ?? config()}
635
+ canUndo={history().length > 0}
636
+ category={category()}
637
+ onCategory={setCategory}
638
+ onChange={change}
639
+ onRandom={randomize}
640
+ onUndo={undo}
641
+ onReset={reset}
642
+ t={t()}
643
+ />
644
+ )}
645
+ </Show>
646
+ </div>
647
+ <div hidden={current() !== "initials"}>
648
+ <div class="cloud-profile-picture__pane">
649
+ <Initials name={props.name} size={160} />
650
+ <p>{t().initialsInfo}</p>
651
+ </div>
652
+ </div>
653
+ </div>
654
+ </Match>
655
+ </Switch>
656
+ </div>
657
+ </PanelDialog.Body>
658
+ <PanelDialog.Footer>
659
+ <div class="cloud-profile-picture__status" role={saveError() ? "alert" : "status"} data-error={saveError() ? "true" : undefined}>
660
+ <Show when={saveError()} fallback={touched() ? t().unsaved : props.subject === "person" ? t().visiblePerson : t().visibleAgent}>
661
+ <i class="ti ti-alert-circle" aria-hidden="true" />
662
+ <span>{t().saveFailed}</span>
663
+ <button type="button" class="cloud-profile-picture__retry" onClick={() => void save()}>
664
+ {t().retry}
665
+ </button>
666
+ </Show>
667
+ </div>
668
+ <div class="cloud-profile-picture__actions">
669
+ <Button type="button" variant="secondary" size="sm" onClick={requestClose} disabled={busy()}>
670
+ {t().cancel}
671
+ </Button>
672
+ <Button
673
+ type="button"
674
+ variant="primary"
675
+ size="sm"
676
+ class="cloud-profile-picture__save"
677
+ onClick={() => void save()}
678
+ disabled={!canSave()}
679
+ >
680
+ <Show when={saving()} fallback={t().save}>
681
+ <i class="ti ti-loader-2" aria-hidden="true" />
682
+ {t().saving}
683
+ </Show>
684
+ </Button>
685
+ </div>
686
+ </PanelDialog.Footer>
687
+ </PanelDialog>
688
+ );
689
+ }
690
+
691
+ /**
692
+ * Opens "Change profile picture": a person picks a photo, a built avatar, or initials; an agent picks a photo or its
693
+ * robot. Resolves with the new state when something changed on the server, or `undefined` when nothing did.
694
+ */
695
+ export const openProfilePictureDialog = (options: ProfilePictureDialogOptions): Promise<ProfilePictureState | undefined> =>
696
+ dialogCore.open<ProfilePictureState | undefined>(
697
+ (close, context) => <ProfilePictureDialog {...options} close={close} setDismissHandler={context.setDismissHandler} />,
698
+ { ...panelDialogWorkspaceOptions, panelClassName: `${panelDialogWorkspacePanelClass} cloud-profile-picture-dialog`, history: true },
699
+ );