@k2b/cloud 0.41.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 (227) hide show
  1. package/package.json +6 -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-timeout.ts +71 -0
  6. package/src/_internal/define-app.ts +145 -89
  7. package/src/_internal/http-drain.ts +56 -0
  8. package/src/_internal/registry-validation.ts +3 -0
  9. package/src/_internal/runtime-context.ts +1 -0
  10. package/src/_internal/status-preserving-ssr.ts +2 -0
  11. package/src/access/PermissionEditor.tsx +16 -17
  12. package/src/access/PrincipalPicker.tsx +45 -10
  13. package/src/access/messages.ts +8 -0
  14. package/src/access/principal-label.ts +18 -0
  15. package/src/access/ui.ts +1 -0
  16. package/src/account/EntitySearch.tsx +5 -4
  17. package/src/ai/action-review.ts +2 -1
  18. package/src/ai/app-skill-store.ts +1 -1
  19. package/src/ai/capabilities.ts +21 -3
  20. package/src/ai/capability-execution.ts +14 -12
  21. package/src/ai/chat/action-card.ts +7 -1
  22. package/src/ai/chat/action-messages.ts +14 -0
  23. package/src/ai/chat/action-question.browser-harness.tsx +12 -0
  24. package/src/ai/chat/action-question.tsx +202 -22
  25. package/src/ai/chat/action-text.ts +1 -1
  26. package/src/ai/chat-tasks.ts +2 -1
  27. package/src/ai/client/controller.ts +150 -32
  28. package/src/ai/client/transport.ts +14 -2
  29. package/src/ai/code-capability-routes.ts +27 -23
  30. package/src/ai/code-runtime-tools.ts +14 -16
  31. package/src/ai/code-source-tools.ts +16 -18
  32. package/src/ai/executor.ts +56 -21
  33. package/src/ai/fetch-file-tool.ts +67 -76
  34. package/src/ai/http.ts +1 -0
  35. package/src/ai/migrate.ts +13 -0
  36. package/src/ai/open-questions.ts +4 -8
  37. package/src/ai/projects.ts +1 -2
  38. package/src/ai/protocol.ts +2 -2
  39. package/src/ai/recent-resources.ts +12 -5
  40. package/src/ai/review-content.ts +77 -0
  41. package/src/ai/routes.ts +69 -31
  42. package/src/ai/runtime.ts +1 -0
  43. package/src/ai/skill-tool.ts +17 -6
  44. package/src/ai/skills-routes.ts +72 -27
  45. package/src/ai/skills.ts +139 -60
  46. package/src/ai/store.ts +168 -33
  47. package/src/ai/types.ts +10 -3
  48. package/src/ai/usage.ts +2 -2
  49. package/src/api/admin-core-settings.ts +15 -2
  50. package/src/api/admin-geo.ts +54 -0
  51. package/src/api/admin-linux-identities.ts +6 -3
  52. package/src/api/admin-outgoing-mail.ts +29 -15
  53. package/src/api/approvals-loader.ts +3 -3
  54. package/src/api/approvals.ts +51 -0
  55. package/src/api/apps.ts +4 -2
  56. package/src/api/capabilities.ts +258 -181
  57. package/src/api/capability-streams.ts +21 -13
  58. package/src/api/cli-plugins.ts +4 -1
  59. package/src/api/geo-glyphs.ts +11 -0
  60. package/src/api/geo.ts +128 -0
  61. package/src/api/help.ts +12 -4
  62. package/src/api/index.ts +5 -0
  63. package/src/api/mcp.ts +46 -38
  64. package/src/api/me-panel-consent.ts +8 -5
  65. package/src/api/search.ts +21 -14
  66. package/src/api/sync-ops.ts +19 -13
  67. package/src/api/widgets.ts +42 -18
  68. package/src/approvals/Approvals.tsx +5 -3
  69. package/src/browser/CloudResourceSearch.browser-harness.tsx +53 -1
  70. package/src/browser/CloudResourceSearch.tsx +3 -13
  71. package/src/browser/PanelLeaveNotice.tsx +18 -0
  72. package/src/browser/navigation-search.ts +78 -25
  73. package/src/browser/panel-leave.ts +69 -0
  74. package/src/browser/panel-messages.ts +12 -0
  75. package/src/browser/panel-slot.ts +173 -24
  76. package/src/browser/panel.ts +3 -0
  77. package/src/browser/resource-search-messages.ts +2 -0
  78. package/src/capabilities/client.ts +28 -1
  79. package/src/capabilities/executions.ts +22 -3
  80. package/src/capabilities/geo-client.ts +57 -0
  81. package/src/capabilities/response.ts +2 -1
  82. package/src/capabilities/review-storage.ts +24 -0
  83. package/src/capabilities/reviews.ts +17 -3
  84. package/src/capabilities/server.ts +36 -16
  85. package/src/cli/access.ts +15 -23
  86. package/src/cli/admin/linux.ts +13 -2
  87. package/src/cli/admin/outgoing-mail.ts +26 -4
  88. package/src/cli/capabilities.ts +19 -0
  89. package/src/config/env.ts +0 -6
  90. package/src/contracts/app-access.ts +48 -0
  91. package/src/contracts/app.ts +4 -1
  92. package/src/contracts/approvals.ts +2 -2
  93. package/src/contracts/avatar.ts +186 -0
  94. package/src/contracts/capabilities.ts +148 -54
  95. package/src/contracts/geo-admin.ts +127 -0
  96. package/src/contracts/geo-map.ts +19 -0
  97. package/src/contracts/geo-route.ts +168 -0
  98. package/src/contracts/geo.ts +248 -0
  99. package/src/contracts/index.ts +4 -0
  100. package/src/contracts/outgoing-mail.ts +17 -4
  101. package/src/contracts/registry.ts +2 -0
  102. package/src/events/app-access.ts +68 -0
  103. package/src/events/live-delivery.ts +128 -0
  104. package/src/events/live.ts +14 -130
  105. package/src/geo/CloudMap.tsx +95 -0
  106. package/src/geo/map-messages.ts +17 -0
  107. package/src/geo/ui.ts +1 -0
  108. package/src/index.ts +2 -0
  109. package/src/server/client-ip.ts +36 -0
  110. package/src/server/index.ts +3 -4
  111. package/src/server/middleware/app-access.ts +312 -0
  112. package/src/server/middleware/auth.ts +1 -1
  113. package/src/server/middleware/invocation.ts +11 -0
  114. package/src/server/middleware/rate-limit.ts +4 -4
  115. package/src/server/services/access.ts +13 -9
  116. package/src/server/services/index.ts +0 -2
  117. package/src/server/services/services.ts +0 -2
  118. package/src/services/accounts/posix.ts +21 -7
  119. package/src/services/allowed-network-fetch.ts +16 -10
  120. package/src/services/app-access-metrics.ts +24 -0
  121. package/src/services/app-access.ts +123 -0
  122. package/src/services/app-discovery.ts +27 -0
  123. package/src/services/approvals/content.ts +40 -0
  124. package/src/services/approvals/index.ts +14 -6
  125. package/src/services/approvals/list.ts +6 -11
  126. package/src/services/approvals/question.ts +11 -7
  127. package/src/services/downstream-budget.ts +87 -0
  128. package/src/services/geo/admin.ts +262 -0
  129. package/src/services/geo/bucket.ts +67 -0
  130. package/src/services/geo/cache.ts +57 -0
  131. package/src/services/geo/costs.ts +5 -0
  132. package/src/services/geo/errors.ts +41 -0
  133. package/src/services/geo/fixtures/README.md +17 -0
  134. package/src/services/geo/fixtures/along.json +355 -0
  135. package/src/services/geo/fixtures/autocomplete.json +218 -0
  136. package/src/services/geo/fixtures/geocode.json +129 -0
  137. package/src/services/geo/fixtures/places.json +355 -0
  138. package/src/services/geo/fixtures/reverse.json +61 -0
  139. package/src/services/geo/fixtures/routing.json +57 -0
  140. package/src/services/geo/geoapify-route.ts +93 -0
  141. package/src/services/geo/geoapify.ts +281 -0
  142. package/src/services/geo/ledger.ts +122 -0
  143. package/src/services/geo/map.ts +160 -0
  144. package/src/services/geo/migrate.ts +25 -0
  145. package/src/services/geo/polyline.ts +184 -0
  146. package/src/services/geo/route-chain.ts +145 -0
  147. package/src/services/geo/route-costs.ts +9 -0
  148. package/src/services/geo/route-geometry.ts +39 -0
  149. package/src/services/geo/route-plan.ts +172 -0
  150. package/src/services/geo/route-service.ts +433 -0
  151. package/src/services/geo/service.ts +300 -0
  152. package/src/services/geo/settings.ts +49 -0
  153. package/src/services/help/index.ts +16 -7
  154. package/src/services/help/types.ts +6 -1
  155. package/src/services/identity/invocation-actor.ts +20 -2
  156. package/src/services/identity/issue-invocation.ts +67 -0
  157. package/src/services/identity/key-ring.ts +5 -3
  158. package/src/services/identity/runtime-config.ts +23 -0
  159. package/src/services/index.ts +9 -0
  160. package/src/services/mandates/index.ts +40 -3
  161. package/src/services/network-security.ts +6 -3
  162. package/src/services/network-transport.ts +99 -0
  163. package/src/services/notifications/catalog.ts +3 -2
  164. package/src/services/notifications/runtime.ts +1 -0
  165. package/src/services/notifications/web-push-transport.ts +45 -38
  166. package/src/services/outgoing-mail/dispatcher.ts +6 -1
  167. package/src/services/outgoing-mail/index.ts +55 -1
  168. package/src/services/outgoing-mail/messages.ts +82 -3
  169. package/src/services/outgoing-mail/resolve-host.ts +2 -2
  170. package/src/services/outgoing-mail/retention.ts +13 -1
  171. package/src/services/outgoing-mail/store.ts +36 -6
  172. package/src/services/outgoing-mail/test-send.ts +34 -4
  173. package/src/services/outgoing-mail/transport.ts +1 -1
  174. package/src/services/pdf/gotenberg.ts +11 -38
  175. package/src/services/pdf/template-preview.ts +1 -1
  176. package/src/services/public-http.ts +28 -40
  177. package/src/services/settings/app.ts +4 -1
  178. package/src/services/settings/core-settings.ts +88 -0
  179. package/src/services/settings/defaults.ts +8 -2
  180. package/src/services/settings/errors.ts +11 -0
  181. package/src/services/settings/geo-budget.ts +40 -0
  182. package/src/services/settings/index.ts +4 -3
  183. package/src/services/settings/store.ts +25 -17
  184. package/src/services/weather/geo.ts +29 -111
  185. package/src/services/weather/index.ts +4 -3
  186. package/src/services/weather/locations.ts +8 -5
  187. package/src/services/weather/migrate.ts +1 -0
  188. package/src/services/weather/settings.ts +0 -16
  189. package/src/shared/__snapshots__/avatar-art.test.ts.snap +5 -0
  190. package/src/shared/avatar-art.ts +400 -0
  191. package/src/shared/index.ts +2 -0
  192. package/src/shared/network-address.ts +22 -3
  193. package/src/shared/template-rendering.ts +36 -4
  194. package/src/shared-text/documents.ts +1 -1
  195. package/src/shared-text/index.ts +2 -0
  196. package/src/shared-text/log.ts +51 -3
  197. package/src/shared-text/snapshots.ts +340 -0
  198. package/src/ssr/AdminLayout.tsx +4 -2
  199. package/src/ssr/CloudShell.island.tsx +6 -0
  200. package/src/ssr/Layout.tsx +65 -15
  201. package/src/ssr/LayoutHelp.browser-harness.tsx +1 -1
  202. package/src/ssr/LayoutHelp.tsx +3 -2
  203. package/src/ssr/PanelRuntime.island.tsx +11 -7
  204. package/src/ssr/PwaLayout.tsx +2 -2
  205. package/src/ssr/RailApps.tsx +6 -0
  206. package/src/ssr/ShellRuntime.island.tsx +23 -7
  207. package/src/ssr/__snapshots__/Layout.unchanged.render.test.tsx.snap +151 -0
  208. package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +6 -6
  209. package/src/ssr/admin-navigation.ts +1 -0
  210. package/src/ssr/app-badges.ts +6 -1
  211. package/src/ssr/compact-shell.browser-harness.ts +44 -5
  212. package/src/ssr/help.ts +3 -2
  213. package/src/ssr/index.ts +1 -1
  214. package/src/ssr/layout-context.ts +2 -0
  215. package/src/ssr/mobile-menu-history.ts +4 -0
  216. package/src/ssr/page-swap-fixture/Column.island.tsx +15 -0
  217. package/src/ssr/page-swap-fixture/ProviderProbe.island.tsx +31 -0
  218. package/src/ssr/page-swap.browser-harness.tsx +177 -0
  219. package/src/ssr/page-swap.ts +28 -0
  220. package/src/ssr/page-trail-runtime.ts +32 -13
  221. package/src/ssr/panel-consent.ts +2 -2
  222. package/src/ssr/panel-slot.browser-harness.tsx +3 -0
  223. package/src/ssr/platform-messages.ts +8 -0
  224. package/src/ssr/runtime.ts +17 -0
  225. package/src/ssr/shell-data.tsx +10 -2
  226. package/src/styles/global.css +70 -6
  227. package/src/server/services/geo.ts +0 -215
@@ -30,6 +30,7 @@ export function openMobileMenu(view: (beforeSelect: () => Promise<boolean>) => D
30
30
  abort.abort();
31
31
  };
32
32
  window.addEventListener("popstate", pop);
33
+ window.addEventListener("ssr:nav-popstate", pop);
33
34
  window.addEventListener("pagehide", leaving);
34
35
  mobile.addEventListener("change", resized);
35
36
  try {
@@ -44,6 +45,7 @@ export function openMobileMenu(view: (beforeSelect: () => Promise<boolean>) => D
44
45
  );
45
46
  } finally {
46
47
  window.removeEventListener("popstate", pop);
48
+ window.removeEventListener("ssr:nav-popstate", pop);
47
49
  window.removeEventListener("pagehide", leaving);
48
50
  mobile.removeEventListener("change", resized);
49
51
  if (!leavingPage && history.state?.[marker] === id) {
@@ -51,9 +53,11 @@ export function openMobileMenu(view: (beforeSelect: () => Promise<boolean>) => D
51
53
  const returned = () => {
52
54
  window.removeEventListener("pagehide", returned);
53
55
  window.removeEventListener("popstate", returned);
56
+ window.removeEventListener("ssr:nav-popstate", returned);
54
57
  resolve();
55
58
  };
56
59
  window.addEventListener("popstate", returned, { once: true });
60
+ window.addEventListener("ssr:nav-popstate", returned, { once: true });
57
61
  window.addEventListener("pagehide", returned, { once: true });
58
62
  history.back();
59
63
  });
@@ -0,0 +1,15 @@
1
+ import { onCleanup, onMount } from "solid-js";
2
+
3
+ /** A real column island deliberately using raw history, to exercise install order. */
4
+ export default function Column(props: { page: string; historyProbe?: boolean }) {
5
+ onMount(() => {
6
+ const raw = () => {
7
+ document.documentElement.dataset.rawPops = String(Number(document.documentElement.dataset.rawPops ?? 0) + 1);
8
+ };
9
+ window.addEventListener("popstate", raw);
10
+ onCleanup(() => window.removeEventListener("popstate", raw));
11
+ if (props.historyProbe) history.pushState({ fixtureColumn: true }, "", `${location.pathname}?entry=column`);
12
+ document.documentElement.dataset.columnMounted = props.page;
13
+ });
14
+ return <output data-column-mounted>{props.page}</output>;
15
+ }
@@ -0,0 +1,31 @@
1
+ import { onCleanup, onMount } from "solid-js";
2
+ import { getContext, onContextChange } from "../../browser/panel";
3
+
4
+ export default function ProviderProbe() {
5
+ onMount(() => {
6
+ const show = () => {
7
+ const output = document.getElementById("provider-context");
8
+ if (output) output.textContent = JSON.stringify(getContext());
9
+ };
10
+ show();
11
+ onCleanup(onContextChange(show));
12
+ // This token belongs to the provider window, independently of the host DOM.
13
+ Reflect.set(window, "fixtureProviderToken", crypto.randomUUID());
14
+ document.documentElement.dataset.providerMounted = "true";
15
+ const abort = new AbortController();
16
+ void fetch("/fixture/pending", { signal: abort.signal })
17
+ .then((response) => response.text())
18
+ .then((text) => {
19
+ const output = document.getElementById("pending-result");
20
+ if (output) output.textContent = text;
21
+ })
22
+ .catch((error: unknown) => {
23
+ if (!abort.signal.aborted) {
24
+ const output = document.getElementById("pending-result");
25
+ if (output) output.textContent = String(error);
26
+ }
27
+ });
28
+ onCleanup(() => abort.abort());
29
+ });
30
+ return <output data-provider-probe />;
31
+ }
@@ -0,0 +1,177 @@
1
+ import { Hono } from "hono";
2
+ import { defineApp } from "../_internal/define-app";
3
+ import { person } from "../_internal/define-app.test-fixture";
4
+ import type { RuntimeAppMeta } from "../contracts/app";
5
+ import type { User } from "../contracts/shared";
6
+ import { type AuthContext, auth } from "../server/middleware/auth";
7
+ import Layout from "./Layout";
8
+ import PanelLayout from "./PanelLayout";
9
+ import Column from "./page-swap-fixture/Column.island";
10
+ import ProviderProbe from "./page-swap-fixture/ProviderProbe.island";
11
+ import type { RuntimeContext } from "./runtime";
12
+
13
+ const probe = defineApp({
14
+ id: "fixture",
15
+ name: "Fixture",
16
+ icon: "ti ti-box",
17
+ description: "Page swap browser fixture",
18
+ baseUrl: "http://fixture:3000",
19
+ routes: ["/app/fixture"],
20
+ pageSwap: true,
21
+ });
22
+ const apps: RuntimeAppMeta[] = [
23
+ {
24
+ id: "fixture",
25
+ name: "Fixture",
26
+ icon: "ti ti-box",
27
+ description: "Fixture",
28
+ routes: ["/app/fixture"],
29
+ nav: { href: "/app/fixture", section: "primary" },
30
+ },
31
+ {
32
+ id: "core",
33
+ name: "Provider",
34
+ icon: "ti ti-box",
35
+ description: "Provider",
36
+ routes: ["/app/provider"],
37
+ panel: { href: "/app/provider/panel", label: "Provider", icon: "ti ti-box" },
38
+ },
39
+ {
40
+ id: "other",
41
+ name: "Other",
42
+ icon: "ti ti-box",
43
+ description: "Other",
44
+ routes: ["/app/other"],
45
+ nav: { href: "/app/other", section: "primary" },
46
+ },
47
+ ];
48
+ // The route policy decides alone; these pages never render their content for the fixture person.
49
+ const policies = new Hono<AuthContext>()
50
+ .get("/protected-login", auth.requireRole("user", probe.ssr.access), ...probe.ssr(() => () => <p>Unreachable</p>))
51
+ .get("/forbidden", auth.requireRole("admin", probe.ssr.access), ...probe.ssr(() => () => <p>Unreachable</p>))
52
+ .get("/revoked", auth.requireRole("admin", probe.ssr.access), ...probe.ssr(() => () => <p>Unreachable</p>));
53
+ export type FixtureEnvironment = { Variables: AuthContext["Variables"] & { runtime: RuntimeContext } };
54
+ export const server = new Hono<FixtureEnvironment>()
55
+ .use(async (c, next) => {
56
+ c.set("runtime", { apps });
57
+ if (!c.req.path.endsWith("/protected-login")) {
58
+ const id = c.req.header("cookie")?.match(/(?:^|;\s*)fixture_person=([^;]+)/)?.[1];
59
+ const user: User = { ...person, ...(id ? { id } : {}) };
60
+ c.set("user", user);
61
+ }
62
+ await next();
63
+ })
64
+ .get(
65
+ "/app/provider/panel",
66
+ ...probe.ssr<FixtureEnvironment>((c) => () => (
67
+ <PanelLayout c={c} title="Provider">
68
+ <label>
69
+ Draft
70
+ <textarea aria-label="Provider draft" />
71
+ </label>
72
+ <output id="provider-context" />
73
+ <output id="pending-result" />
74
+ <ProviderProbe />
75
+ </PanelLayout>
76
+ )),
77
+ )
78
+ .get("/app/fixture/file", (c) => c.body("Fixture file", 200, { "content-type": "text/plain" }))
79
+ .get("/api/fixture", (c) => c.json({ fixture: true }))
80
+ .get("/app/fixture/download", (c) =>
81
+ c.body("Download", 200, { "content-type": "application/octet-stream", "content-disposition": 'attachment; filename="fixture.txt"' }),
82
+ )
83
+ .route("/app/fixture", policies)
84
+ .get(
85
+ "/auth/login",
86
+ ...probe.ssr<FixtureEnvironment>((c) => () => (
87
+ <Layout c={c} title="Sign in" panel={false}>
88
+ <h1>Sign in to continue</h1>
89
+ </Layout>
90
+ )),
91
+ )
92
+ .get(
93
+ "/app/other",
94
+ ...probe.ssr<FixtureEnvironment>((c) => () => (
95
+ <Layout c={c} title="Other">
96
+ <h1>Other app</h1>
97
+ </Layout>
98
+ )),
99
+ )
100
+ .get(
101
+ "/app/fixture/:page?",
102
+ ...probe.ssr<FixtureEnvironment>((c) => {
103
+ const page = c.req.param("page") ?? "A";
104
+ const label =
105
+ page === "B"
106
+ ? "Full page B"
107
+ : page === "focus"
108
+ ? "Focus editor"
109
+ : page === "order"
110
+ ? "Order probe"
111
+ : page === "C"
112
+ ? "Regular page C"
113
+ : "Regular page A";
114
+ return () => (
115
+ <Layout
116
+ c={c}
117
+ title={page === "A" ? label : [{ title: "Regular page A", href: "/app/fixture/A" }, { title: label }]}
118
+ fullPage={page === "B"}
119
+ fullWidth={page === "B"}
120
+ focusMode={page === "focus"}
121
+ help={[`help-${page.toLowerCase()}`]}
122
+ context={{ scope: { type: "fixture.page", id: page } }}
123
+ >
124
+ <h1>{label}</h1>
125
+ <Column page={page} historyProbe={page === "order"} />
126
+ <a id="to-A" href="/app/fixture/A">
127
+ A
128
+ </a>
129
+ <a id="to-B" href="/app/fixture/B">
130
+ B
131
+ </a>
132
+ <a id="to-C" href="/app/fixture/C">
133
+ C
134
+ </a>
135
+ <a id="to-order" href="/app/fixture/order">
136
+ Order
137
+ </a>
138
+ <a id="to-focus" href="/app/fixture/focus">
139
+ Focus
140
+ </a>
141
+ <a id="to-other" href="/app/other">
142
+ Other app
143
+ </a>
144
+ <a id="to-api" href="/api/fixture">
145
+ API
146
+ </a>
147
+ <a id="to-file" href="/app/fixture/file">
148
+ File
149
+ </a>
150
+ <a id="to-download" href="/app/fixture/download" download="">
151
+ Download
152
+ </a>
153
+ <a id="to-protected-login" href="/app/fixture/protected-login">
154
+ Sign-in policy
155
+ </a>
156
+ <a id="to-forbidden" href="/app/fixture/forbidden">
157
+ Forbidden policy
158
+ </a>
159
+ <a id="to-revoked" href="/app/fixture/revoked">
160
+ Revoked access
161
+ </a>
162
+ <form action="/app/fixture/A" method="get">
163
+ <input name="query" value="submitted" />
164
+ <button id="submit" type="submit">
165
+ Submit
166
+ </button>
167
+ </form>
168
+ <div style="height:1600px" />
169
+ <ul>
170
+ <li id="row-B">
171
+ <a href="/app/fixture/B">B row</a>
172
+ </li>
173
+ </ul>
174
+ </Layout>
175
+ );
176
+ }),
177
+ );
@@ -0,0 +1,28 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ const builds = new WeakMap<object, string>();
4
+
5
+ /** Register only requests of applications that opted into page swaps. */
6
+ export const registerPageSwap = (context: object, build: string): void => {
7
+ builds.set(context, build);
8
+ };
9
+ export const getPageSwapBuild = (context: object): string | undefined => builds.get(context);
10
+
11
+ export type PageSwapParts = {
12
+ app: string | undefined;
13
+ kind: "app" | "anonymous";
14
+ user: string | null;
15
+ appSession: boolean;
16
+ locale: string;
17
+ theme: "light" | "dark" | undefined;
18
+ build: string;
19
+ /** The request-scoped inputs of the shell that stays: navigation catalogs, profile, panel providers, announcements and Help. */
20
+ shell: unknown;
21
+ };
22
+
23
+ /** Opaque identity of the document that stays around the replaced content column. */
24
+ export const pageSwapSignature = (parts: PageSwapParts): string =>
25
+ createHash("sha256")
26
+ .update(JSON.stringify([parts.app, parts.kind, parts.user, parts.appSession, parts.locale, parts.theme, parts.build, parts.shell]))
27
+ .digest("hex")
28
+ .slice(0, 32);
@@ -7,6 +7,7 @@
7
7
  * to the page itself. After returning, the row that led down is marked.
8
8
  */
9
9
 
10
+ import { swapPage } from "@k2b/ssr/nav";
10
11
  import { PAGE_TRAIL_EVENT, readPageTrail } from "@k2b/ui";
11
12
  import { registerContextAwareCommand } from "../browser/command-bridge";
12
13
  import { PAGE_UP_EVENT, type PageUpDetail } from "./layout-runtime";
@@ -168,7 +169,10 @@ export const goUp = (href: string): boolean => {
168
169
  const memory = read();
169
170
  memory.returning = { url: destination, from: here() };
170
171
  write(memory);
171
- location.assign(url.href);
172
+ const canvas = document.querySelector<HTMLElement>(".cloud-app-canvas[data-nav-shell]");
173
+ const scope = canvas?.dataset.navScope;
174
+ if (scope && url.origin === location.origin && (url.pathname === scope || url.pathname.startsWith(`${scope}/`))) void swapPage(url.href);
175
+ else location.assign(url.href);
172
176
  return true;
173
177
  };
174
178
 
@@ -208,7 +212,10 @@ const restoreReturn = (visit: Visit | undefined, from: URL) => {
208
212
  const pending = new Map((visit?.regions ?? []).map((region) => [region.key, region]));
209
213
  let marked: HTMLElement | null = null;
210
214
  let frames = 0;
215
+ // A page swap that replaces this column ends its restore; the next page is not the one returned to.
216
+ const column = document.querySelector("[data-nav-root]");
211
217
  const step = () => {
218
+ if (column && !column.isConnected) return;
212
219
  for (const [key, region] of pending) {
213
220
  // A region is restored once both axes hold; content that renders late can still widen or lengthen it.
214
221
  if (key === "@window") {
@@ -263,8 +270,10 @@ const offerPageUp = (labels: PageUpLabels): (() => void) => {
263
270
  };
264
271
  update();
265
272
  window.addEventListener(PAGE_TRAIL_EVENT, update);
273
+ window.addEventListener("ssr:nav-swapped", update);
266
274
  return () => {
267
275
  window.removeEventListener(PAGE_TRAIL_EVENT, update);
276
+ window.removeEventListener("ssr:nav-swapped", update);
268
277
  unregister();
269
278
  };
270
279
  };
@@ -277,19 +286,26 @@ const offerPageUp = (labels: PageUpLabels): (() => void) => {
277
286
  */
278
287
  export const installPageTrailRuntime = (labels: PageUpLabels): (() => void) => {
279
288
  const stopPageUp = offerPageUp(labels);
280
- const memory = read();
281
- const returning = memory.returning;
282
- if (returning) {
283
- delete memory.returning;
284
- write(memory);
285
- const current = resolve(here());
286
- if (pathOf(resolve(returning.url)) === pathOf(current) && resolve(returning.url).search === current.search)
287
- restoreReturn(
288
- memory.visits.find((visit) => visit.url === returning.url),
289
- resolve(returning.from),
290
- );
291
- }
289
+ const restoreArrivingPage = () => {
290
+ const memory = read();
291
+ const returning = memory.returning;
292
+ if (returning) {
293
+ delete memory.returning;
294
+ write(memory);
295
+ const current = resolve(here());
296
+ if (pathOf(resolve(returning.url)) === pathOf(current) && resolve(returning.url).search === current.search)
297
+ restoreReturn(
298
+ memory.visits.find((visit) => visit.url === returning.url),
299
+ resolve(returning.from),
300
+ );
301
+ }
302
+ };
303
+ restoreArrivingPage();
304
+ window.addEventListener("ssr:nav-swapped", restoreArrivingPage);
292
305
  const onCapture = () => rememberPage();
306
+ const onNavigate = (event: NavigateEvent) => {
307
+ if (event.navigationType === "traverse") rememberPage();
308
+ };
293
309
  const onKey = (event: KeyboardEvent) => {
294
310
  if (event.key === "Enter") rememberPage();
295
311
  };
@@ -303,11 +319,14 @@ export const installPageTrailRuntime = (labels: PageUpLabels): (() => void) => {
303
319
  document.addEventListener("keydown", onKey, true);
304
320
  document.addEventListener("click", onClick);
305
321
  window.addEventListener("pagehide", onCapture);
322
+ window.navigation?.addEventListener("navigate", onNavigate);
306
323
  return () => {
324
+ window.removeEventListener("ssr:nav-swapped", restoreArrivingPage);
307
325
  stopPageUp();
308
326
  document.removeEventListener("click", onCapture, true);
309
327
  document.removeEventListener("keydown", onKey, true);
310
328
  document.removeEventListener("click", onClick);
311
329
  window.removeEventListener("pagehide", onCapture);
330
+ window.navigation?.removeEventListener("navigate", onNavigate);
312
331
  };
313
332
  };
@@ -3,13 +3,13 @@ import { readPanelCookie } from "../_internal/panel-cookie";
3
3
  import { getLocale } from "../server/locale";
4
4
  import { panelContextConsent } from "../services/panel-context-consent";
5
5
  import { visiblePanelProviders } from "./app-navigation";
6
- import { getRuntimeContext } from "./runtime";
6
+ import { getDisplayRuntimeContext } from "./runtime";
7
7
 
8
8
  /** One read, only for a host page with the slot open on a consent provider. Fail closed when the store is unavailable. */
9
9
  export async function preloadPanelConsent(c: Context): Promise<void> {
10
10
  const user = c.get("user");
11
11
  if (!user || !c.get("runtime")) return;
12
- const providers = visiblePanelProviders(getRuntimeContext(c).apps, user, getLocale(c));
12
+ const providers = visiblePanelProviders(getDisplayRuntimeContext(c).apps, user, getLocale(c));
13
13
  const state = readPanelCookie(
14
14
  c.req.header("Cookie"),
15
15
  providers.map((provider) => provider.id),
@@ -77,6 +77,9 @@ export const server = new Hono<Environment>()
77
77
  <a href="/app/fixture/next" id="leave-panel">
78
78
  Open in the app
79
79
  </a>
80
+ <a href="/app/fixture/panel?guard=multiple&mode=manual&step=2" id="internal-panel">
81
+ Another panel document
82
+ </a>
80
83
  </PanelLayout>
81
84
  )),
82
85
  )
@@ -16,6 +16,9 @@ export const platformMessages = i18n.define({
16
16
  panelLoadFailed: "The panel did not load",
17
17
  panelNotResponding: ({ label }: { label: string }) => `${label} is not responding.`,
18
18
  retry: "Try again",
19
+ panelLeaveResume: "Back to the panel",
20
+ panelLeaveBlocked: "The panel is still open",
21
+ panelLeaveDetail: "Finish or save your work in the panel, then try switching or closing again.",
19
22
  panelLoading: ({ label }: { label: string }) => `${label} is loading`,
20
23
  enlargePanel: "Enlarge panel",
21
24
  shrinkPanel: "Shrink panel",
@@ -47,6 +50,7 @@ export const platformMessages = i18n.define({
47
50
  accountOperations: "Operations",
48
51
  linuxAccess: "Linux identities",
49
52
  pdfRendering: "PDF rendering",
53
+ placesAndMaps: "Places and maps",
50
54
  emailTemplates: "Email templates",
51
55
  outgoingMail: "Outgoing mail",
52
56
  security: "Security",
@@ -114,6 +118,9 @@ export const platformMessages = i18n.define({
114
118
  panelLoadFailed: "Das Panel lädt nicht",
115
119
  panelNotResponding: ({ label }: { label: string }) => `${label} antwortet nicht.`,
116
120
  retry: "Erneut versuchen",
121
+ panelLeaveResume: "Zurück zum Panel",
122
+ panelLeaveBlocked: "Das Panel bleibt geöffnet",
123
+ panelLeaveDetail: "Beende oder speichere deine Arbeit im Panel. Danach kannst du es erneut wechseln oder schließen.",
117
124
  panelLoading: ({ label }: { label: string }) => `${label} lädt`,
118
125
  enlargePanel: "Panel vergrößern",
119
126
  shrinkPanel: "Panel verkleinern",
@@ -145,6 +152,7 @@ export const platformMessages = i18n.define({
145
152
  accountOperations: "Betrieb",
146
153
  linuxAccess: "Linux-Identitäten",
147
154
  pdfRendering: "PDF-Erstellung",
155
+ placesAndMaps: "Orte und Karten",
148
156
  emailTemplates: "E-Mail-Vorlagen",
149
157
  outgoingMail: "Ausgehende Mail",
150
158
  security: "Sicherheit",
@@ -2,8 +2,11 @@
2
2
  * Runtime context helpers for SSR components.
3
3
  * Extracted from _core/runtime-helpers.ts — only the generic parts.
4
4
  */
5
+ import type { Context } from "hono";
5
6
  import type { CloudRuntime } from "../contracts/app";
6
7
  import { getLocale } from "../server/locale";
8
+ import { appAccessAuthority } from "../services/app-access";
9
+ import { discoverableApps } from "../services/app-discovery";
7
10
  import { resolveAppPresentation, resolveAppPresentations } from "../shared/app-presentation";
8
11
 
9
12
  export type RuntimeContext = CloudRuntime;
@@ -47,3 +50,17 @@ export const appNameIn = (carrier: RuntimeCarrier, appId: string, locale: string
47
50
  const app = apps.find((entry) => entry.id === appId);
48
51
  return app ? resolveAppPresentation(app, locale).name : fallback;
49
52
  };
53
+
54
+ const displayApps = new WeakMap<object, RuntimeContext["apps"]>();
55
+
56
+ /** Called after route authentication, before page data and synchronous layouts render. Routing keeps the full runtime. */
57
+ export const preloadDisplayApps = async (c: Context): Promise<void> => {
58
+ if (!c.get("runtime") || displayApps.has(c)) return;
59
+ displayApps.set(c, await discoverableApps(appAccessAuthority(c), getRuntimeContext(c).apps));
60
+ };
61
+
62
+ /** Display-only projection. A caller outside the SSR adapter must preload declared app access first. */
63
+ export const getDisplayRuntimeContext = (carrier: RuntimeCarrier): RuntimeContext => {
64
+ const runtime = getRuntimeContext(carrier);
65
+ return { ...runtime, apps: displayApps.get(carrier) ?? runtime.apps.filter((app) => app.access === undefined) };
66
+ };
@@ -36,15 +36,23 @@ export type ShellData = {
36
36
 
37
37
  const ShellDataContext = createContext<() => ShellData>();
38
38
 
39
- export function ShellDataProvider(props: { data: ShellData; children: JSX.Element }) {
39
+ export function ShellDataProvider(props: { data: ShellData; skipScript?: boolean; children: JSX.Element }) {
40
40
  return (
41
41
  <ShellDataContext.Provider value={() => props.data}>
42
- <script id="cloud-shell-data" type="application/json" innerHTML={JSON.stringify(props.data).replace(/</g, "\\u003c")} />
42
+ {/* Inline, not a component: an extra component would shift the hydration keys of every page. */}
43
+ {!props.skipScript && <script id="cloud-shell-data" type="application/json" innerHTML={shellDataJson(props.data)} />}
43
44
  {props.children}
44
45
  </ShellDataContext.Provider>
45
46
  );
46
47
  }
47
48
 
49
+ const shellDataJson = (data: ShellData) => JSON.stringify(data).replace(/</g, "\\u003c");
50
+
51
+ /** The shell data inside a swapped column, so each page brings its own with it. */
52
+ export function ShellDataScript(props: { data: ShellData }) {
53
+ return <script id="cloud-shell-data" type="application/json" innerHTML={shellDataJson(props.data)} />;
54
+ }
55
+
48
56
  export function readShellData(): ShellData | undefined {
49
57
  if (typeof document === "undefined") return undefined;
50
58
  const text = document.getElementById("cloud-shell-data")?.textContent;
@@ -75,6 +75,14 @@ html:has(.cloud-app-canvas[data-layout-full-page="true"]) {
75
75
  overscroll-behavior: none;
76
76
  }
77
77
 
78
+ /* A full page binds the canvas to the window at every width. The attribute
79
+ carries it, not a class: a page swap copies the attribute and keeps the
80
+ canvas element, so a regular page and a full page can follow each other. */
81
+ .cloud-app-canvas[data-layout-full-page="true"] {
82
+ height: 100dvh;
83
+ overflow: hidden;
84
+ }
85
+
78
86
  .cloud-app-canvas > :is(.layout-rail, .layout-shell-content) {
79
87
  gap: var(--ui-space-shell);
80
88
  }
@@ -428,6 +436,62 @@ solid-island {
428
436
  }
429
437
  }
430
438
 
439
+ /* Phone overviews: object navigation sits on the shell before the card.
440
+ Only an outer flush workspace with visible stacked navigation hands the
441
+ host's paint to its head/content tracks. The pseudo-element is the first
442
+ grid item, behind the existing regions, without a new stacking context:
443
+ the fixed primary action can still sit above the app bar. */
444
+ /* Negated min-width queries keep the exact boundary after CSS compilation;
445
+ fractional max-width approximations can round back up to 768 px. */
446
+ @media not all and (min-width: 768px) {
447
+ @media not all and (min-width: 48rem) {
448
+ .cloud-app-canvas[data-layout-authenticated] .layout-content-main:has(
449
+ .k2b-app-workspace[data-mobile-surface="flush"]:not(.k2b-app-workspace *, [hidden], [hidden] *)
450
+ > .k2b-app-workspace__sidebar[data-mobile="stacked"]:not([hidden]),
451
+ .k2b-app-workspace[data-mobile-surface="flush"]:not(.k2b-app-workspace *, [hidden], [hidden] *)
452
+ > :is(solid-island, solid-client) > .k2b-app-workspace__sidebar[data-mobile="stacked"]:not([hidden])
453
+ ) {
454
+ padding-bottom: 0;
455
+ border: 0;
456
+ border-radius: 0;
457
+ background: transparent;
458
+ box-shadow: none;
459
+
460
+ & .k2b-app-workspace[data-mobile-surface="flush"]:not(.k2b-app-workspace *) {
461
+ --k2b-workspace-inset-bottom: var(--cloud-tab-bar-space);
462
+
463
+ &::before {
464
+ content: "";
465
+ grid-column: 1 / -1;
466
+ grid-row: head-start / -1;
467
+ min-height: 0;
468
+ pointer-events: none;
469
+ border-top: 1px solid transparent;
470
+ border-radius: 1.75rem 1.75rem 0 0;
471
+ background: var(--k2b-surface);
472
+ box-shadow: 0 -10px 30px -24px rgb(15 23 19 / 0.3);
473
+ }
474
+
475
+ & > .k2b-app-workspace__head {
476
+ border-radius: 1.75rem 1.75rem 0 0;
477
+ background: transparent;
478
+ }
479
+
480
+ /* Cloud's page title hides the automatic head on phones. With no
481
+ explicit head, Content itself owns the card's rounded clip. */
482
+ &:not(:has(> .k2b-app-workspace__head[data-row])) > .k2b-app-workspace__content {
483
+ border-radius: 1.75rem 1.75rem 0 0;
484
+ }
485
+ }
486
+ }
487
+
488
+ .dark .cloud-app-canvas[data-layout-authenticated] .layout-content-main
489
+ .k2b-app-workspace[data-mobile-surface="flush"]:not(.k2b-app-workspace *)::before {
490
+ border-top-color: var(--k2b-border);
491
+ }
492
+ }
493
+ }
494
+
431
495
  /* The bar and the room it takes switch with `md`, as the header and the rail
432
496
  do, so Home stays reachable also where a larger default font size moves
433
497
  that switch above 768 px. */
@@ -732,14 +796,14 @@ solid-island {
732
796
  }
733
797
 
734
798
  /* The navigation leaves like a hidden sidebar: its track, its resize handle,
735
- and navigation panes go, and the work area rounds its start corners with
736
- the card. An island may wrap the sidebar or Main, as in Grids. This follows
737
- the app's host, as the sidebar column does: in a host below 64 rem, such as
738
- the app beside the panel column, the navigation is an overlay behind the
739
- workspace head's button and stays there. */
799
+ and navigation panes go. With the sidebar space at 0, the workspace rounds
800
+ the work area's start corners with the card itself. An island may wrap the
801
+ sidebar or Main, as in Grids. This follows the app's host, as the sidebar
802
+ column does: in a host below 64 rem, such as the app beside the panel
803
+ column, the navigation is an overlay behind the workspace head's button and
804
+ stays there. */
740
805
  @container k2b-app-area (min-width: 64rem) {
741
806
  .cloud-app-canvas[data-shell-focus] .k2b-app-workspace:not(.k2b-app-workspace *) {
742
- --k2b-workspace-start-radius: var(--k2b-workspace-inner-radius);
743
807
  --k2b-workspace-sidebar-space: 0px;
744
808
  }
745
809
  .cloud-app-canvas[data-shell-focus]