@k2b/cloud 0.40.0 → 0.42.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 (279) hide show
  1. package/package.json +9 -5
  2. package/src/_internal/bounded-json.ts +14 -2
  3. package/src/_internal/capabilities.ts +87 -8
  4. package/src/_internal/capability-reviews.ts +6 -0
  5. package/src/_internal/capability-sentences.ts +2 -1
  6. package/src/_internal/capability-timeout.ts +71 -0
  7. package/src/_internal/define-app.ts +158 -92
  8. package/src/_internal/heartbeat.ts +203 -14
  9. package/src/_internal/http-drain.ts +56 -0
  10. package/src/_internal/registry-validation.ts +3 -0
  11. package/src/_internal/registry.ts +28 -1
  12. package/src/_internal/runtime-context.ts +1 -0
  13. package/src/_internal/setup-lock.ts +103 -0
  14. package/src/_internal/status-preserving-ssr.ts +2 -0
  15. package/src/access/PermissionEditor.tsx +16 -17
  16. package/src/access/PrincipalPicker.tsx +45 -10
  17. package/src/access/messages.ts +8 -0
  18. package/src/access/principal-label.ts +18 -0
  19. package/src/access/ui.ts +1 -0
  20. package/src/account/EntitySearch.tsx +5 -4
  21. package/src/ai/action-review.ts +2 -1
  22. package/src/ai/app-skill-store.ts +1 -1
  23. package/src/ai/capabilities.ts +21 -3
  24. package/src/ai/capability-execution.ts +14 -12
  25. package/src/ai/chat/action-card.ts +76 -4
  26. package/src/ai/chat/action-messages.ts +14 -0
  27. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  28. package/src/ai/chat/action-question.tsx +214 -23
  29. package/src/ai/chat/action-text.ts +288 -0
  30. package/src/ai/chat/tool-action-words.ts +31 -0
  31. package/src/ai/chat/tool-action.tsx +1 -26
  32. package/src/ai/chat-tasks.ts +2 -1
  33. package/src/ai/client/controller.ts +150 -32
  34. package/src/ai/client/transport.ts +14 -2
  35. package/src/ai/code-capability-routes.ts +27 -23
  36. package/src/ai/code-runtime-tools.ts +14 -16
  37. package/src/ai/code-source-tools.ts +16 -18
  38. package/src/ai/executor.ts +64 -21
  39. package/src/ai/fetch-file-tool.ts +67 -76
  40. package/src/ai/firecrawl-tools.ts +15 -2
  41. package/src/ai/http.ts +1 -0
  42. package/src/ai/index.ts +1 -0
  43. package/src/ai/migrate.ts +28 -0
  44. package/src/ai/open-questions.ts +168 -0
  45. package/src/ai/projects.ts +1 -2
  46. package/src/ai/protocol.ts +2 -2
  47. package/src/ai/question-text.ts +8 -0
  48. package/src/ai/recent-resources.ts +12 -5
  49. package/src/ai/review-content.ts +77 -0
  50. package/src/ai/routes.ts +119 -35
  51. package/src/ai/runtime.ts +1 -0
  52. package/src/ai/skill-tool.ts +17 -6
  53. package/src/ai/skills-routes.ts +72 -27
  54. package/src/ai/skills.ts +139 -60
  55. package/src/ai/store.ts +184 -31
  56. package/src/ai/testing.ts +36 -0
  57. package/src/ai/tool-sentences.ts +152 -34
  58. package/src/ai/tool-wording.ts +12 -6
  59. package/src/ai/tools.ts +8 -3
  60. package/src/ai/types.ts +25 -4
  61. package/src/ai/usage.ts +2 -2
  62. package/src/api/admin-core-settings.ts +15 -2
  63. package/src/api/admin-geo.ts +54 -0
  64. package/src/api/admin-linux-identities.ts +6 -3
  65. package/src/api/admin-outgoing-mail.ts +29 -15
  66. package/src/api/app-approval-actions.ts +31 -7
  67. package/src/api/approvals-loader.ts +3 -3
  68. package/src/api/approvals.ts +51 -0
  69. package/src/api/apps.ts +4 -2
  70. package/src/api/auth/schemas.ts +3 -0
  71. package/src/api/auth.ts +54 -18
  72. package/src/api/capabilities.ts +258 -181
  73. package/src/api/capability-streams.ts +21 -13
  74. package/src/api/cli-plugins.ts +4 -1
  75. package/src/api/geo-glyphs.ts +11 -0
  76. package/src/api/geo.ts +128 -0
  77. package/src/api/help.ts +12 -4
  78. package/src/api/index.ts +5 -0
  79. package/src/api/mcp.ts +73 -41
  80. package/src/api/me-panel-consent.ts +8 -5
  81. package/src/api/me.ts +79 -3
  82. package/src/api/search.ts +21 -14
  83. package/src/api/sync-ops.ts +19 -13
  84. package/src/api/widgets.ts +42 -18
  85. package/src/approvals/Approvals.tsx +30 -66
  86. package/src/approvals/approval-card.ts +68 -0
  87. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  88. package/src/browser/CloudResourceSearch.tsx +3 -13
  89. package/src/browser/PanelLeaveNotice.tsx +18 -0
  90. package/src/browser/navigation-search.ts +78 -25
  91. package/src/browser/panel-leave.ts +69 -0
  92. package/src/browser/panel-messages.ts +12 -0
  93. package/src/browser/panel-slot.ts +173 -24
  94. package/src/browser/panel.ts +3 -0
  95. package/src/browser/resource-search-messages.ts +2 -0
  96. package/src/capabilities/client.ts +28 -1
  97. package/src/capabilities/executions.ts +22 -3
  98. package/src/capabilities/geo-client.ts +57 -0
  99. package/src/capabilities/response.ts +2 -1
  100. package/src/capabilities/review-storage.ts +24 -0
  101. package/src/capabilities/reviews.ts +17 -3
  102. package/src/capabilities/server.ts +36 -16
  103. package/src/cli/access.ts +15 -23
  104. package/src/cli/admin/linux.ts +13 -2
  105. package/src/cli/admin/outgoing-mail.ts +26 -4
  106. package/src/cli/capabilities.ts +19 -0
  107. package/src/config/env.ts +0 -6
  108. package/src/contracts/app-access.ts +48 -0
  109. package/src/contracts/app-approval.ts +3 -3
  110. package/src/contracts/app.ts +4 -1
  111. package/src/contracts/approvals.ts +2 -2
  112. package/src/contracts/avatar.ts +186 -0
  113. package/src/contracts/capabilities.ts +149 -55
  114. package/src/contracts/geo-admin.ts +127 -0
  115. package/src/contracts/geo-map.ts +19 -0
  116. package/src/contracts/geo-route.ts +168 -0
  117. package/src/contracts/geo.ts +248 -0
  118. package/src/contracts/index.ts +4 -0
  119. package/src/contracts/outgoing-mail.ts +17 -4
  120. package/src/contracts/registry.ts +2 -0
  121. package/src/contracts/shared.ts +3 -0
  122. package/src/events/app-access.ts +68 -0
  123. package/src/events/live-delivery.ts +128 -0
  124. package/src/events/live.ts +14 -130
  125. package/src/geo/CloudMap.tsx +95 -0
  126. package/src/geo/map-messages.ts +17 -0
  127. package/src/geo/ui.ts +1 -0
  128. package/src/index.ts +2 -0
  129. package/src/server/client-ip.ts +36 -0
  130. package/src/server/index.ts +3 -4
  131. package/src/server/middleware/app-access.ts +312 -0
  132. package/src/server/middleware/auth.ts +5 -3
  133. package/src/server/middleware/invocation.ts +11 -0
  134. package/src/server/middleware/rate-limit.ts +4 -4
  135. package/src/server/services/access.ts +13 -9
  136. package/src/server/services/index.ts +0 -2
  137. package/src/server/services/services.ts +0 -2
  138. package/src/services/accounts/app.ts +23 -3
  139. package/src/services/accounts/posix.ts +21 -7
  140. package/src/services/accounts/users.ts +12 -1
  141. package/src/services/allowed-network-fetch.ts +16 -10
  142. package/src/services/app-access-metrics.ts +24 -0
  143. package/src/services/app-access.ts +123 -0
  144. package/src/services/app-discovery.ts +27 -0
  145. package/src/services/approvals/content.ts +40 -0
  146. package/src/services/approvals/device.ts +49 -33
  147. package/src/services/approvals/index.ts +15 -6
  148. package/src/services/approvals/list.ts +6 -11
  149. package/src/services/approvals/question.ts +72 -0
  150. package/src/services/audit/index.ts +1 -0
  151. package/src/services/auth-flows/ipa.ts +39 -2
  152. package/src/services/auth-flows/password-reset.ts +32 -6
  153. package/src/services/downstream-budget.ts +87 -0
  154. package/src/services/geo/admin.ts +262 -0
  155. package/src/services/geo/bucket.ts +67 -0
  156. package/src/services/geo/cache.ts +57 -0
  157. package/src/services/geo/costs.ts +5 -0
  158. package/src/services/geo/errors.ts +41 -0
  159. package/src/services/geo/fixtures/README.md +17 -0
  160. package/src/services/geo/fixtures/along.json +355 -0
  161. package/src/services/geo/fixtures/autocomplete.json +218 -0
  162. package/src/services/geo/fixtures/geocode.json +129 -0
  163. package/src/services/geo/fixtures/places.json +355 -0
  164. package/src/services/geo/fixtures/reverse.json +61 -0
  165. package/src/services/geo/fixtures/routing.json +57 -0
  166. package/src/services/geo/geoapify-route.ts +93 -0
  167. package/src/services/geo/geoapify.ts +281 -0
  168. package/src/services/geo/ledger.ts +122 -0
  169. package/src/services/geo/map.ts +160 -0
  170. package/src/services/geo/migrate.ts +25 -0
  171. package/src/services/geo/polyline.ts +184 -0
  172. package/src/services/geo/route-chain.ts +145 -0
  173. package/src/services/geo/route-costs.ts +9 -0
  174. package/src/services/geo/route-geometry.ts +39 -0
  175. package/src/services/geo/route-plan.ts +172 -0
  176. package/src/services/geo/route-service.ts +433 -0
  177. package/src/services/geo/service.ts +300 -0
  178. package/src/services/geo/settings.ts +49 -0
  179. package/src/services/help/index.ts +16 -7
  180. package/src/services/help/types.ts +6 -1
  181. package/src/services/identity/invocation-actor.ts +20 -2
  182. package/src/services/identity/issue-invocation.ts +67 -0
  183. package/src/services/identity/key-ring.ts +5 -3
  184. package/src/services/identity/runtime-config.ts +23 -0
  185. package/src/services/index.ts +11 -0
  186. package/src/services/mandates/index.ts +40 -3
  187. package/src/services/network-security.ts +6 -3
  188. package/src/services/network-transport.ts +99 -0
  189. package/src/services/notifications/catalog.ts +3 -2
  190. package/src/services/notifications/runtime.ts +1 -0
  191. package/src/services/notifications/web-push-transport.ts +45 -38
  192. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  193. package/src/services/outgoing-mail/index.ts +55 -1
  194. package/src/services/outgoing-mail/messages.ts +82 -3
  195. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  196. package/src/services/outgoing-mail/retention.ts +13 -1
  197. package/src/services/outgoing-mail/store.ts +36 -6
  198. package/src/services/outgoing-mail/test-send.ts +34 -4
  199. package/src/services/outgoing-mail/transport.ts +1 -1
  200. package/src/services/pdf/gotenberg.ts +11 -38
  201. package/src/services/pdf/markdown.ts +160 -2
  202. package/src/services/pdf/template-preview.ts +1 -1
  203. package/src/services/public-http.ts +28 -40
  204. package/src/services/session/index.ts +203 -29
  205. package/src/services/settings/app.ts +4 -1
  206. package/src/services/settings/core-settings.ts +88 -0
  207. package/src/services/settings/defaults.ts +8 -2
  208. package/src/services/settings/errors.ts +11 -0
  209. package/src/services/settings/geo-budget.ts +40 -0
  210. package/src/services/settings/index.ts +4 -3
  211. package/src/services/settings/store.ts +25 -17
  212. package/src/services/weather/geo.ts +29 -111
  213. package/src/services/weather/index.ts +4 -3
  214. package/src/services/weather/locations.ts +8 -5
  215. package/src/services/weather/migrate.ts +1 -0
  216. package/src/services/weather/settings.ts +0 -16
  217. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  218. package/src/shared/avatar-art.ts +400 -0
  219. package/src/shared/index.ts +2 -0
  220. package/src/shared/network-address.ts +22 -3
  221. package/src/shared/template-rendering.ts +36 -4
  222. package/src/shared-text/documents.ts +1 -1
  223. package/src/shared-text/index.ts +2 -0
  224. package/src/shared-text/log.ts +51 -3
  225. package/src/shared-text/snapshots.ts +340 -0
  226. package/src/ssr/AdminLayout.tsx +4 -2
  227. package/src/ssr/CloudShell.island.tsx +6 -0
  228. package/src/ssr/HelpPanelArticle.island.tsx +308 -0
  229. package/src/ssr/HelpPanelBack.island.tsx +32 -0
  230. package/src/ssr/HelpPanelMenu.island.tsx +133 -0
  231. package/src/ssr/HelpPanelSearch.island.tsx +123 -0
  232. package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
  233. package/src/ssr/HelpPanelView.island.tsx +27 -0
  234. package/src/ssr/Layout.tsx +65 -15
  235. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  236. package/src/ssr/LayoutHelp.tsx +185 -542
  237. package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
  238. package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
  239. package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
  240. package/src/ssr/PanelRuntime.island.tsx +11 -7
  241. package/src/ssr/PwaLayout.tsx +2 -2
  242. package/src/ssr/RailApps.tsx +6 -0
  243. package/src/ssr/ShellRuntime.island.tsx +23 -7
  244. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  245. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
  246. package/src/ssr/admin-navigation.ts +1 -0
  247. package/src/ssr/app-badges.ts +6 -1
  248. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  249. package/src/ssr/help.ts +7 -5
  250. package/src/ssr/index.ts +2 -1
  251. package/src/ssr/islands/index.ts +3 -4
  252. package/src/ssr/layout-context.ts +2 -0
  253. package/src/ssr/layout-help-panel-controls.tsx +43 -0
  254. package/src/ssr/layout-help-panel-document.ts +67 -0
  255. package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
  256. package/src/ssr/layout-help-panel-menu.tsx +113 -0
  257. package/src/ssr/layout-help-panel-modules.ts +7 -0
  258. package/src/ssr/layout-help-panel-reader.tsx +159 -0
  259. package/src/ssr/layout-help-panel-search.tsx +69 -0
  260. package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
  261. package/src/ssr/layout-help-panel-state.ts +141 -0
  262. package/src/ssr/layout-help-panel-types.ts +19 -0
  263. package/src/ssr/layout-help-views.tsx +484 -0
  264. package/src/ssr/mobile-menu-history.ts +4 -0
  265. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  266. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  267. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  268. package/src/ssr/page-swap.ts +28 -0
  269. package/src/ssr/page-trail-runtime.ts +32 -13
  270. package/src/ssr/panel-consent.ts +2 -2
  271. package/src/ssr/panel-idle.ts +9 -0
  272. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  273. package/src/ssr/platform-messages.ts +8 -0
  274. package/src/ssr/runtime.ts +17 -0
  275. package/src/ssr/shell-data.tsx +10 -2
  276. package/src/styles/approvals.css +6 -0
  277. package/src/styles/effects.css +15 -0
  278. package/src/styles/global.css +70 -6
  279. package/src/server/services/geo.ts +0 -215
@@ -3,49 +3,102 @@ import type { SearchItem } from "../api/search/schemas";
3
3
  import type { AppMeta } from "../contracts/app";
4
4
 
5
5
  /** UI-only rows. The synthetic ref is a row key, never a readable resource. */
6
- export type NavigationSearchItem = SearchItem & { keywords?: readonly string[] };
6
+ export type NavigationSearchItem = SearchItem & { keywords?: readonly string[]; kind?: "app" };
7
7
 
8
8
  export const navigationSearchItems = (
9
- apps: readonly Pick<AppMeta, "id" | "name" | "icon" | "searchLinks">[],
9
+ apps: readonly (Pick<AppMeta, "id" | "name" | "icon" | "searchLinks"> & { nav?: Pick<NonNullable<AppMeta["nav"]>, "href"> })[],
10
10
  visibleAppIds: readonly string[],
11
11
  ): NavigationSearchItem[] => {
12
12
  const visible = new Set(visibleAppIds);
13
13
  return apps
14
14
  .filter((app) => visible.has(app.id))
15
- .flatMap((app) =>
16
- (app.searchLinks ?? []).map((link) => ({
17
- appId: app.id,
18
- appName: app.name,
19
- appIcon: app.icon,
20
- ref: { type: "cloud.navigation", id: `${app.id}:${link.href}` },
21
- readable: false,
22
- title: link.label,
23
- preview: link.description,
24
- href: link.href,
25
- icon: link.icon ?? app.icon,
26
- priority: 0,
27
- keywords: link.keywords,
28
- })),
29
- );
15
+ .flatMap((app) => {
16
+ const entryLinks = app.searchLinks?.filter((link) => link.href === app.nav?.href) ?? [];
17
+ const links = [
18
+ ...(app.nav
19
+ ? [
20
+ {
21
+ label: app.name,
22
+ href: app.nav.href,
23
+ icon: app.icon,
24
+ kind: "app" as const,
25
+ keywords: entryLinks.flatMap((link) => [link.label, ...(link.keywords ?? [])]),
26
+ },
27
+ ]
28
+ : []),
29
+ ...(app.searchLinks ?? []),
30
+ ];
31
+ return links.map(
32
+ (link): NavigationSearchItem => ({
33
+ appId: app.id,
34
+ appName: app.name,
35
+ appIcon: app.icon,
36
+ ref: { type: "cloud.navigation", id: `${app.id}:${link.href}` },
37
+ readable: false,
38
+ title: link.label,
39
+ preview: "description" in link ? link.description : undefined,
40
+ href: link.href,
41
+ icon: link.icon ?? app.icon,
42
+ priority: 0,
43
+ keywords: link.keywords,
44
+ kind: "kind" in link ? link.kind : undefined,
45
+ }),
46
+ );
47
+ });
48
+ };
49
+
50
+ const normalize = (text: string) => text.trim().toLowerCase().replace(/\s+/g, " ");
51
+
52
+ /** Compare individual names/aliases, never their combined length or number. */
53
+ const appMatchRank = (item: NavigationSearchItem, query: string): number => {
54
+ const names = [item.title, ...(item.keywords ?? [])].map(normalize);
55
+ if (names.includes(query)) return 0;
56
+ const words = query.split(" ");
57
+ if (names.some((name) => words.every((word) => name.split(/[^\p{L}\p{N}]+/u).some((part) => part.startsWith(word))))) return 1;
58
+ if (names.some((name) => words.every((word) => name.includes(word)))) return 2;
59
+ // Whole-name typos only, at least 80% similarity. No short abbreviations or description fuzzing globally.
60
+ if (query.length < 4) return Infinity;
61
+ const closest = fuzzy.closest(query, names, { maxDistance: query.length >= 8 ? 2 : 1 });
62
+ return closest && closest.similarity >= 0.8 ? 3 + closest.distance / 10 : Infinity;
30
63
  };
31
64
 
32
65
  export const matchNavigationSearchItems = (
33
66
  items: readonly NavigationSearchItem[],
34
67
  input: { query: string; tags: readonly string[]; appId?: string | null; requireReader?: boolean },
35
68
  ): NavigationSearchItem[] => {
36
- const query = input.query.trim().toLowerCase();
69
+ const query = normalize(input.query);
37
70
  if (input.requireReader || input.tags.length > 0 || (!input.appId && query.length < 2)) return [];
71
+ const seen = new Set<string>();
72
+ const uniqueTarget = (item: NavigationSearchItem) => {
73
+ if (seen.has(item.href)) return false;
74
+ seen.add(item.href);
75
+ return true;
76
+ };
38
77
  if (input.appId) {
39
- const scoped = items.filter((item) => item.appId === input.appId);
78
+ // App contexts keep browsing their static pages; app launchers belong to global navigation only.
79
+ const scoped = items.filter((item) => item.appId === input.appId && item.kind !== "app");
40
80
  return query
41
81
  ? fuzzy
42
82
  .filter(query, scoped, { key: (item) => [item.title, item.preview ?? "", ...(item.keywords ?? [])].join(" ") })
43
83
  .map((hit) => hit.item)
44
- : scoped;
84
+ .filter(uniqueTarget)
85
+ : scoped.filter(uniqueTarget);
45
86
  }
46
- const words = query.split(/\s+/);
47
- return items.filter((item) => {
48
- const text = [item.title, item.preview ?? "", ...(item.keywords ?? [])].join(" ").toLowerCase();
49
- return words.every((word) => text.includes(word));
50
- });
87
+ const words = query.split(" ");
88
+ // Deduplicate only this global view. A cross-app page still belongs to its declaring app's own scope.
89
+ const appTargets = new Set(items.filter((item) => item.kind === "app").map((item) => item.href));
90
+ return items
91
+ .filter((item) => item.kind === "app" || !appTargets.has(item.href))
92
+ .flatMap((item) => {
93
+ const rank =
94
+ item.kind === "app"
95
+ ? appMatchRank(item, query)
96
+ : words.every((word) => [item.title, item.preview ?? "", ...(item.keywords ?? [])].join(" ").toLowerCase().includes(word))
97
+ ? 2
98
+ : Infinity;
99
+ return Number.isFinite(rank) ? [{ item, rank }] : [];
100
+ })
101
+ .sort((a, b) => a.rank - b.rank)
102
+ .map(({ item }) => item)
103
+ .filter(uniqueTarget);
51
104
  };
@@ -0,0 +1,69 @@
1
+ /** Internal same-origin collection; no composer content crosses the provider boundary. */
2
+ const LEAVE = "cloud.panel.before-leave";
3
+ const CHECK = "cloud.panel.has-leave-guard";
4
+ type LeaveDecision = { allowed: boolean };
5
+
6
+ /** Ordinary same-origin links handled by the panel host; explicit browser actions stay native. */
7
+ export function panelLinkDestination(event: MouseEvent): URL | undefined {
8
+ if (event.defaultPrevented || event.button !== 0 || event.metaKey || event.ctrlKey || event.altKey || event.shiftKey) return;
9
+ const anchor = event.target instanceof Element ? event.target.closest<HTMLAnchorElement>("a[href]") : null;
10
+ if (!anchor || (anchor.target && anchor.target !== "_self") || anchor.hasAttribute("download")) return;
11
+ const url = new URL(anchor.getAttribute("href")!, document.baseURI);
12
+ if (url.origin !== location.origin) return;
13
+ if (url.pathname === location.pathname && url.search === location.search && url.hash) return;
14
+ return url;
15
+ }
16
+
17
+ /**
18
+ * Register before enabling editing. Return true only when all provider work is recoverable.
19
+ * A veto keeps the current frame. Saving and retry belong to the provider; no transition is queued.
20
+ */
21
+ export function onPanelBeforeLeave(guard: () => boolean): () => void {
22
+ const collect = (event: Event) => {
23
+ const decision = (event as CustomEvent<LeaveDecision>).detail;
24
+ try {
25
+ // Only explicit synchronous true permits leaving; throws and accidental Promises fail closed.
26
+ if (guard() !== true) decision.allowed = false;
27
+ } catch {
28
+ decision.allowed = false;
29
+ }
30
+ };
31
+ const registered = (event: Event) => event.preventDefault();
32
+ // Islands start independently: editing and its links must be protected before PanelRuntime loads too.
33
+ const click = (event: MouseEvent) => {
34
+ if (window.parent === window || !document.querySelector("[data-cloud-panel-page]")) return;
35
+ const url = panelLinkDestination(event);
36
+ if (!url) return;
37
+ event.preventDefault();
38
+ window.parent.postMessage({ type: "cloud.panel.navigate", v: 1, href: url.href }, location.origin);
39
+ };
40
+ window.addEventListener(LEAVE, collect);
41
+ window.addEventListener(CHECK, registered);
42
+ document.addEventListener("click", click);
43
+ return () => {
44
+ window.removeEventListener(LEAVE, collect);
45
+ window.removeEventListener(CHECK, registered);
46
+ document.removeEventListener("click", click);
47
+ };
48
+ }
49
+
50
+ export function hasPanelLeaveGuard(): boolean {
51
+ return !window.dispatchEvent(new Event(CHECK, { cancelable: true }));
52
+ }
53
+
54
+ export function panelCanLeave(target: Window): boolean {
55
+ // A cross-origin navigation already destroyed the provider document and its guards.
56
+ // It cannot participate in this same-origin contract or keep the host's slot trapped.
57
+ try {
58
+ void target.document;
59
+ } catch {
60
+ return true;
61
+ }
62
+ const decision: LeaveDecision = { allowed: true };
63
+ try {
64
+ target.dispatchEvent(new CustomEvent<LeaveDecision>(LEAVE, { detail: decision }));
65
+ return decision.allowed;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
@@ -1,5 +1,17 @@
1
1
  const isRecord = (value: unknown): value is Record<string, unknown> => typeof value === "object" && value !== null && !Array.isArray(value);
2
2
 
3
+ export function isPanelNavigateMessage(value: unknown): value is { type: "cloud.panel.navigate"; v: 1; href: string } {
4
+ return (
5
+ isRecord(value) &&
6
+ Reflect.ownKeys(value).length === 3 &&
7
+ value.type === "cloud.panel.navigate" &&
8
+ value.v === 1 &&
9
+ typeof value.href === "string" &&
10
+ value.href.length <= 8192 &&
11
+ /^https?:\/\//.test(value.href)
12
+ );
13
+ }
14
+
3
15
  export function isPanelReadyMessage(value: unknown, provider: string): value is { type: "cloud.panel.ready"; v: 1; provider: string } {
4
16
  return (
5
17
  isRecord(value) &&
@@ -1,14 +1,21 @@
1
1
  import { attachResizeSeparator, LocaleProvider, parkWorkspaceDetailDrawers } from "@k2b/ui";
2
- import { createComponent } from "solid-js";
2
+ import { createComponent, createSignal } from "solid-js";
3
3
  import { render } from "solid-js/web";
4
4
  import { PANEL_MAX_WIDTH_REM, PANEL_MIN_WIDTH_REM, readPanelCookie, serializePanelCookie } from "../_internal/panel-cookie";
5
5
  import { CloudResourceRefSchema } from "../contracts/capabilities";
6
6
  import type { PanelPageContext } from "../contracts/panel-context";
7
- import { isPanelShortcut, PANEL_CONTEXT_PARAM, panelProviderNeedsConsent, withPanelContext } from "../contracts/panel-context-plain";
7
+ import {
8
+ isPanelPageContext,
9
+ isPanelShortcut,
10
+ PANEL_CONTEXT_PARAM,
11
+ panelProviderNeedsConsent,
12
+ withPanelContext,
13
+ } from "../contracts/panel-context-plain";
8
14
  import { helpShortcutGroups } from "../ssr/layout-help-start";
9
15
  import { platformMessages } from "../ssr/platform-messages";
10
16
  import { readShellData } from "../ssr/shell-data";
11
17
  import { COMMANDS_CHANGED, contextCommandsWithShortcuts } from "./command-shortcuts";
18
+ import PanelLeaveNotice from "./PanelLeaveNotice";
12
19
  import type { PanelRequest } from "./panel";
13
20
  import {
14
21
  PAGE_CONTEXT_CHANGED,
@@ -17,8 +24,9 @@ import {
17
24
  type PanelConsentRequest,
18
25
  type PanelState,
19
26
  } from "./panel-context-bridge";
27
+ import { panelCanLeave } from "./panel-leave";
20
28
  import PanelMenu from "./panel-menu";
21
- import { isPanelCloseMessage, isPanelReadyMessage, isPanelSearchMessage } from "./panel-messages";
29
+ import { isPanelCloseMessage, isPanelNavigateMessage, isPanelReadyMessage, isPanelSearchMessage } from "./panel-messages";
22
30
  import { importOnDemand } from "./reload";
23
31
 
24
32
  export const PANEL_READY_TIMEOUT_MS = 10_000;
@@ -53,13 +61,100 @@ export function installPanelSlot(): () => void {
53
61
  let provider = panel.providers.find((item) => item.id === slot.dataset.provider) ?? panel.providers[0]!;
54
62
  let frame = slot.querySelector<HTMLIFrameElement>("[data-cloud-panel-frame]");
55
63
  let disposed = false;
56
- let context = panel.context ?? null;
64
+ const [leaveBlocked, setLeaveBlocked] = createSignal(false);
65
+ const transitionTo = (action: () => void) => {
66
+ const target = frame?.contentWindow;
67
+ if (target && !panelCanLeave(target)) {
68
+ setLeaveBlocked(true);
69
+ return;
70
+ }
71
+ setLeaveBlocked(false);
72
+ action();
73
+ };
74
+ const leaveHost = document.createElement("div");
75
+ body.before(leaveHost);
76
+ const disposeLeave = render(
77
+ () =>
78
+ createComponent(LocaleProvider, {
79
+ locale: lang,
80
+ get children() {
81
+ return createComponent(PanelLeaveNotice, {
82
+ get blocked() {
83
+ return leaveBlocked();
84
+ },
85
+ locale: lang,
86
+ resume: () => {
87
+ setLeaveBlocked(false);
88
+ frame?.focus();
89
+ },
90
+ });
91
+ },
92
+ }),
93
+ leaveHost,
94
+ );
95
+ let base = panel.context ?? null;
96
+ let context = base;
97
+ let pageRoot = document.querySelector("[data-nav-root]");
98
+ let documentPageId = context?.pageId;
99
+ let lastSeq = context?.seq ?? 0;
100
+ // The retained provider may have locked onto this document before a swap and this lazy installer.
101
+ try {
102
+ const raw = frame && new URL(frame.src, location.href).searchParams.get(PANEL_CONTEXT_PARAM);
103
+ const initial: unknown = raw ? JSON.parse(raw) : null;
104
+ if (isPanelPageContext(initial)) {
105
+ documentPageId = initial.pageId;
106
+ lastSeq = context?.pageId === documentPageId ? Math.max(lastSeq, initial.seq) : initial.seq;
107
+ if (context && context.pageId !== documentPageId) context = { ...context, pageId: documentPageId, seq: ++lastSeq };
108
+ }
109
+ } catch {
110
+ // A malformed or absent URL context leaves the shown page's context in charge.
111
+ }
112
+ // Set when a swapped page's context has not been delivered yet; cleared by the next delivery.
113
+ let pageUnsent = false;
114
+ const followPage = () => {
115
+ const nextRoot = document.querySelector("[data-nav-root]");
116
+ if (nextRoot === pageRoot) return;
117
+ pageRoot = nextRoot;
118
+ pageUnsent = true;
119
+ const arriving = readShellData()?.panel;
120
+ // The arriving page carries the server's current consent answer, as a reload would; a newer revision wins.
121
+ const consent = arriving?.consent;
122
+ if (consent) {
123
+ // Page observations fence pending choices even when a read already learned this revision or the provider changed.
124
+ if (consent.revision > (observedRevisions.get(consent.provider) ?? 0)) observedRevisions.set(consent.provider, consent.revision);
125
+ if (consent.revision > (revisions.get(consent.provider) ?? 0)) {
126
+ answers.set(consent.provider, consent.allowed);
127
+ revisions.set(consent.provider, consent.revision);
128
+ }
129
+ }
130
+ if (consent && consent.provider === provider.id) {
131
+ if (consent.allowed !== answers.get(consent.provider) || consent.revision !== revisions.get(consent.provider)) {
132
+ // An older or unreadable answer (`allowed: null`) is what a reload would show: stop and read again.
133
+ void revalidate();
134
+ }
135
+ } else if (provider.consent && isOpen()) {
136
+ // The server sends an answer only while the shared cookie shows this provider open; without it, read again.
137
+ void revalidate();
138
+ } else if (consentPending === 0) {
139
+ // A reload would read these answers again when a consent provider opens; so does the next open here.
140
+ for (const item of panel.providers) if (item.consent) answers.delete(item.id);
141
+ answersRead = undefined;
142
+ }
143
+ base = arriving?.context ?? null;
144
+ if (base) {
145
+ documentPageId ??= base.pageId;
146
+ context = { ...base, pageId: documentPageId, seq: ++lastSeq };
147
+ } else context = null;
148
+ };
57
149
  let loadVersion = 0;
58
150
  const answers = new Map<string, boolean | null>();
59
151
  const revisions = new Map<string, number>();
152
+ // A newer server page supersedes choices made before it, including queued saves and conflict retries.
153
+ const observedRevisions = new Map<string, number>();
60
154
  if (panel.consent) {
61
155
  answers.set(panel.consent.provider, panel.consent.allowed);
62
156
  revisions.set(panel.consent.provider, panel.consent.revision);
157
+ observedRevisions.set(panel.consent.provider, panel.consent.revision);
63
158
  }
64
159
  const saving = new Set<string>();
65
160
  const errors = new Set<string>();
@@ -78,13 +173,13 @@ export function installPanelSlot(): () => void {
78
173
  // Whether the current frame has seen a context, in its URL or by message; only such a frame needs a clearing null.
79
174
  let disclosed = !!frame && new URL(frame.src, location.origin).searchParams.has(PANEL_CONTEXT_PARAM);
80
175
  // The frame may have navigated on its own; only the selected provider's page below its panel path may receive context.
81
- const showsProvider = () => {
176
+ const showsProvider = (requireReady = true) => {
82
177
  try {
83
178
  const shown = frame?.contentWindow?.location;
84
179
  const scope = new URL(provider.href, location.origin).pathname.replace(/\/$/, "");
85
180
  return (
86
181
  !!shown &&
87
- frame?.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id &&
182
+ (!requireReady || frame?.contentDocument?.documentElement.dataset.cloudPanelReady === provider.id) &&
88
183
  (shown.pathname === scope || shown.pathname.startsWith(`${scope}/`))
89
184
  );
90
185
  } catch {
@@ -106,6 +201,7 @@ export function installPanelSlot(): () => void {
106
201
  let lastShortcutsJson: string | undefined;
107
202
  const canSend = () => isOpen() && allowed() && !!context && slot.dataset.state === "ready" && !!frame?.contentWindow && showsProvider();
108
203
  const sendContext = () => {
204
+ pageUnsent = false;
109
205
  if (!canSend()) return;
110
206
  const list = !panelProviderNeedsConsent(provider.id) ? shortcuts() : undefined;
111
207
  frame?.contentWindow?.postMessage(
@@ -158,15 +254,16 @@ export function installPanelSlot(): () => void {
158
254
  if (forgetUrlContext()) disclosed = false;
159
255
  };
160
256
  const collectContext = () => {
257
+ followPage();
161
258
  if (!context) return;
162
259
  const collection: PageContextCollection = { sources: [] };
163
260
  window.dispatchEvent(new CustomEvent(PAGE_CONTEXT_COLLECT, { detail: collection }));
164
261
  const source = collection.sources.findLast((source) => source.scope !== undefined);
165
262
  const parsed = source ? CloudResourceRefSchema.safeParse(source.scope) : undefined;
166
- const scope = source ? (parsed?.success ? parsed.data : undefined) : panel.context?.scope;
263
+ const scope = source ? (parsed?.success ? parsed.data : undefined) : base?.scope;
167
264
  if (scope?.type === context.scope?.type && scope?.id === context.scope?.id) return;
168
- const { scope: _previous, ...base } = context;
169
- context = { ...base, seq: context.seq + 1, ...(scope ? { scope } : {}) };
265
+ const { scope: _previous, ...previous } = context;
266
+ context = { ...previous, seq: ++lastSeq, ...(scope ? { scope } : {}) };
170
267
  sendContext();
171
268
  };
172
269
  // Share or stop sharing after every consent step; an open question or its absence follows in update().
@@ -220,8 +317,13 @@ export function installPanelSlot(): () => void {
220
317
  }
221
318
  for (const item of panel.providers) {
222
319
  if (!item.consent) continue;
320
+ const knownRevision = revisions.get(item.id) ?? 0;
321
+ const readRevision = nextRevisions.get(item.id);
322
+ // A page swap can observe a newer answer while this request is in flight. Never rewind that answer.
323
+ if (readRevision !== undefined && readRevision < knownRevision) continue;
223
324
  answers.set(item.id, next.get(item.id) ?? null);
224
- revisions.set(item.id, nextRevisions.get(item.id) ?? 0);
325
+ // An unreadable answer still stops sharing, but must not forget the newest observed revision.
326
+ revisions.set(item.id, readRevision ?? knownRevision);
225
327
  }
226
328
  answersStale = false;
227
329
  };
@@ -330,7 +432,7 @@ export function installPanelSlot(): () => void {
330
432
  clearTimer();
331
433
  slot.dataset.state = "ready";
332
434
  if (status) status.textContent = "";
333
- if (allowed()) {
435
+ if (allowed() && context) {
334
436
  sendContext();
335
437
  return;
336
438
  }
@@ -427,6 +529,8 @@ export function installPanelSlot(): () => void {
427
529
  if (!item || typeof request.allowed !== "boolean" || saving.has(item.id)) return;
428
530
  saving.add(item.id);
429
531
  errors.delete(item.id);
532
+ const observedRevision = observedRevisions.get(item.id);
533
+ const current = () => observedRevisions.get(item.id) === observedRevision;
430
534
  const questionHadFocus = slot.querySelector("[data-cloud-panel-consent]")?.contains(document.activeElement);
431
535
  // Enqueueing blocks sharing at once, so a withdrawal stops disclosure before its save even starts.
432
536
  // keepalive: leaving the page does not cancel the save. A save the browser gives up on (timeout) is rolled back
@@ -441,11 +545,14 @@ export function installPanelSlot(): () => void {
441
545
  });
442
546
  void enqueueConsent(async () => {
443
547
  try {
548
+ if (!current()) return;
444
549
  let response = await put();
550
+ if (!current()) return;
445
551
  // Changed meanwhile (another tab, or a read that failed): the person's choice now is the newest one, so read
446
552
  // the current revision and save it once more. A second conflict shows the question again.
447
553
  if (response.status === 409) {
448
554
  await readAnswersNow();
555
+ if (!current()) return;
449
556
  response = await put();
450
557
  }
451
558
  if (!response.ok) throw new Error("Panel consent was not saved");
@@ -463,13 +570,17 @@ export function installPanelSlot(): () => void {
463
570
  saved.revision <= 0
464
571
  )
465
572
  throw new Error("Invalid panel consent answer");
466
- answers.set(item.id, request.allowed);
467
- revisions.set(item.id, saved.revision);
573
+ if (current() && saved.revision >= (revisions.get(item.id) ?? 0)) {
574
+ answers.set(item.id, request.allowed);
575
+ revisions.set(item.id, saved.revision);
576
+ }
468
577
  } catch {
578
+ // A superseded request cannot invalidate the newer page's answer either.
579
+ if (!current()) return;
469
580
  // Not saved: nothing is shared until the person answers again; the read learns the current revision.
470
581
  errors.add(item.id);
471
582
  await readAnswersNow();
472
- answers.set(item.id, null);
583
+ if (current()) answers.set(item.id, null);
473
584
  } finally {
474
585
  saving.delete(item.id);
475
586
  }
@@ -509,9 +620,10 @@ export function installPanelSlot(): () => void {
509
620
  }
510
621
  main.focus();
511
622
  };
512
- const close = () => {
623
+ const closeNow = () => {
513
624
  if (!isOpen()) return;
514
625
  canvas.removeAttribute(attribute());
626
+ if (!isPhone()) canvas.removeAttribute("data-panel-sheet");
515
627
  disposeResize();
516
628
  disposeResize = attachResize();
517
629
  unload();
@@ -520,7 +632,8 @@ export function installPanelSlot(): () => void {
520
632
  update();
521
633
  returnFocus();
522
634
  };
523
- const open = (id: string, nextSearch?: string) => {
635
+ const close = () => transitionTo(closeNow);
636
+ const openNow = (id: string, nextSearch?: string) => {
524
637
  const next = panel.providers.find((item) => item.id === id);
525
638
  if (!next) return;
526
639
  parkWorkspaceDetailDrawers(document);
@@ -542,6 +655,7 @@ export function installPanelSlot(): () => void {
542
655
  if (frame) focusPanel();
543
656
  else focusPending = true;
544
657
  };
658
+ const open = (id: string, nextSearch?: string) => transitionTo(() => openNow(id, nextSearch));
545
659
  const request = (request: PanelRequest) => {
546
660
  if (request.action === "open") open(request.provider, request.search);
547
661
  else if (request.action === "close" || isOpen()) close();
@@ -551,7 +665,17 @@ export function installPanelSlot(): () => void {
551
665
  if (!frame || event.source !== frame.contentWindow || event.origin !== location.origin) return;
552
666
  if (isPanelReadyMessage(event.data, provider.id)) markReady();
553
667
  else if (isPanelCloseMessage(event.data)) close();
554
- else if (isPanelSearchMessage(event.data)) {
668
+ else if (isPanelNavigateMessage(event.data)) {
669
+ if (!URL.canParse(event.data.href)) return;
670
+ const url = new URL(event.data.href, location.origin);
671
+ if (url.origin !== location.origin || !showsProvider(false)) return;
672
+ const scope = new URL(provider.href, location.origin).pathname.replace(/\/$/, "");
673
+ const internal = url.pathname === scope || url.pathname.startsWith(`${scope}/`);
674
+ transitionTo(() => {
675
+ if (internal) frame?.contentWindow?.location.assign(url.href);
676
+ else window.location.assign(url.href);
677
+ });
678
+ } else if (isPanelSearchMessage(event.data)) {
555
679
  const command = contextCommandsWithShortcuts().find((command) => command.id === "cloud.search" && command.shortcut);
556
680
  if (command) void importOnDemand(() => import("./commands")).then((commands) => commands?.runContextAwareCommand(command));
557
681
  }
@@ -562,8 +686,10 @@ export function installPanelSlot(): () => void {
562
686
  if (answer && isOpen()) void saveConsent({ provider: provider.id, allowed: answer.dataset.cloudPanelConsentAnswer === "allow" });
563
687
  else if (event.target.closest("[data-cloud-panel-close]")) close();
564
688
  else if (event.target.closest("[data-cloud-panel-retry]") && isOpen()) {
565
- load(true);
566
- frame?.focus();
689
+ transitionTo(() => {
690
+ load(true);
691
+ frame?.focus();
692
+ });
567
693
  } else if (event.target.closest("[data-cloud-panel-grow]") && isPhone()) {
568
694
  const full = slot.dataset.panelSize !== "full";
569
695
  if (full) slot.dataset.panelSize = "full";
@@ -599,17 +725,18 @@ export function installPanelSlot(): () => void {
599
725
  if (isOpen()) {
600
726
  seedWidth();
601
727
  load();
728
+ } else if (frame) {
729
+ // A viewport change must not hide an editor while its leave decision is blocked.
730
+ canvas.setAttribute(attribute(), "open");
731
+ transitionTo(closeNow);
602
732
  } else unload();
603
733
  disposeResize = attachResize();
604
734
  update();
605
735
  };
606
736
  const closeSheet = () => {
607
737
  const wasOpen = canvas.dataset.panelSheet === "open";
608
- canvas.removeAttribute("data-panel-sheet");
609
- if (wasOpen && isPhone()) {
610
- unload();
611
- update();
612
- }
738
+ if (wasOpen && isPhone()) close();
739
+ else canvas.removeAttribute("data-panel-sheet");
613
740
  };
614
741
  const pageShow = (event: PageTransitionEvent) => {
615
742
  if (!event.persisted) return;
@@ -657,6 +784,12 @@ export function installPanelSlot(): () => void {
657
784
  committedWidth = value;
658
785
  writeCookie();
659
786
  },
787
+ onReset: () => {
788
+ committedWidth = undefined;
789
+ canvas.style.removeProperty("--cloud-panel-width-set");
790
+ writeCookie();
791
+ seedWidth();
792
+ },
660
793
  onCollapseIntent: (armed) => {
661
794
  if (armed) slot.dataset.closing = "true";
662
795
  else delete slot.dataset.closing;
@@ -680,7 +813,18 @@ export function installPanelSlot(): () => void {
680
813
  })
681
814
  : () => {};
682
815
  let disposeResize = attachResize();
816
+ // A column island may already have published its place while it mounted; each page's context goes out once.
817
+ const swapped = () => {
818
+ collectContext();
819
+ if (pageUnsent) {
820
+ pageUnsent = false;
821
+ if (context && allowed()) sendContext();
822
+ else stopSharing();
823
+ } else if (provider.consent && !allowed()) stopSharing();
824
+ update();
825
+ };
683
826
  collectContext();
827
+ window.addEventListener("ssr:nav-swapped", swapped);
684
828
  window.addEventListener(PAGE_CONTEXT_CHANGED, collectContext);
685
829
  window.addEventListener(COMMANDS_CHANGED, commandsChanged);
686
830
  window.addEventListener("cloud.panel.consent", consentRequest);
@@ -693,6 +837,7 @@ export function installPanelSlot(): () => void {
693
837
  // Restored from the back/forward cache before this lazy module arrived: the server's answers may be outdated.
694
838
  if (document.documentElement.hasAttribute("data-cloud-page-restored")) void revalidate();
695
839
  window.addEventListener("popstate", closeSheet);
840
+ window.addEventListener("ssr:nav-popstate", closeSheet);
696
841
  window.navigation?.addEventListener("currententrychange", entryChange);
697
842
  window.addEventListener("resize", seedWidth);
698
843
  // An SSR iframe can have posted its one-shot ready before this lazy module
@@ -730,6 +875,8 @@ export function installPanelSlot(): () => void {
730
875
  const dispose = () => {
731
876
  if (disposed) return;
732
877
  disposed = true;
878
+ disposeLeave();
879
+ leaveHost.remove();
733
880
  disposeMenu();
734
881
  requests.abort();
735
882
  ++loadVersion;
@@ -737,6 +884,7 @@ export function installPanelSlot(): () => void {
737
884
  clearTimer();
738
885
  disposeResize();
739
886
  removeFallbackFocus();
887
+ window.removeEventListener("ssr:nav-swapped", swapped);
740
888
  window.removeEventListener(PAGE_CONTEXT_CHANGED, collectContext);
741
889
  window.removeEventListener(COMMANDS_CHANGED, commandsChanged);
742
890
  window.removeEventListener("cloud.panel.consent", consentRequest);
@@ -747,6 +895,7 @@ export function installPanelSlot(): () => void {
747
895
  column.removeEventListener("change", modeChange);
748
896
  window.removeEventListener("pageshow", pageShow);
749
897
  window.removeEventListener("popstate", closeSheet);
898
+ window.removeEventListener("ssr:nav-popstate", closeSheet);
750
899
  window.navigation?.removeEventListener("currententrychange", entryChange);
751
900
  window.removeEventListener("resize", seedWidth);
752
901
  installed = undefined;
@@ -1,4 +1,7 @@
1
1
  import type { PanelPageContext } from "../contracts/panel-context";
2
+
3
+ export { onPanelBeforeLeave } from "./panel-leave";
4
+
2
5
  import {
3
6
  PAGE_CONTEXT_CHANGED,
4
7
  PAGE_CONTEXT_COLLECT,
@@ -4,6 +4,7 @@ export const resourceSearchMessages = i18n.define({
4
4
  baseLocale: "en",
5
5
  messages: {
6
6
  en: {
7
+ app: "App",
7
8
  actions: "Actions",
8
9
  contextActions: "On this page",
9
10
  commandPlaceholder: "Find an action…",
@@ -54,6 +55,7 @@ export const resourceSearchMessages = i18n.define({
54
55
  loadingTags: "Loading filters…",
55
56
  },
56
57
  de: {
58
+ app: "App",
57
59
  actions: "Aktionen",
58
60
  contextActions: "Auf dieser Seite",
59
61
  commandPlaceholder: "Aktion finden…",