@k2b/cloud 0.38.0 → 0.39.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 (99) hide show
  1. package/package.json +6 -3
  2. package/scripts/build-canvas-workers.ts +109 -29
  3. package/scripts/build.ts +3 -5
  4. package/src/_internal/app-path.ts +25 -0
  5. package/src/_internal/app-presentation.ts +4 -0
  6. package/src/_internal/canvas-worker.ts +5 -3
  7. package/src/_internal/define-app.ts +28 -2
  8. package/src/_internal/panel-cookie.ts +39 -0
  9. package/src/_internal/registry-validation.ts +21 -26
  10. package/src/_internal/runtime-context.ts +1 -0
  11. package/src/access/PermissionEditor.browser-harness.tsx +57 -38
  12. package/src/access/PermissionEditor.tsx +30 -5
  13. package/src/access/ResourceApiKeys.tsx +5 -1
  14. package/src/ai/approval-routes.ts +5 -2
  15. package/src/ai/approvals.ts +139 -28
  16. package/src/ai/browser.ts +10 -0
  17. package/src/ai/chat/action-sentences.ts +1 -44
  18. package/src/ai/chat/turn-view.tsx +35 -3
  19. package/src/ai/chat/view-block.tsx +7 -2
  20. package/src/ai/chat-task-contracts.ts +28 -3
  21. package/src/ai/chat-task-reasons-migrate.ts +68 -0
  22. package/src/ai/chat-task-reasons.ts +92 -0
  23. package/src/ai/chat-tasks.ts +396 -66
  24. package/src/ai/index.ts +12 -0
  25. package/src/ai/migrate.ts +28 -2
  26. package/src/ai/request-context.ts +7 -1
  27. package/src/ai/routes.ts +27 -1
  28. package/src/ai/runtime.ts +12 -27
  29. package/src/ai/store.ts +5 -3
  30. package/src/ai/timeline.ts +9 -1
  31. package/src/ai/tool-wording.ts +45 -0
  32. package/src/ai/turn-abort.ts +33 -0
  33. package/src/ai/types.ts +12 -1
  34. package/src/api/admin-core-settings.ts +190 -139
  35. package/src/api/admin-outgoing-mail.ts +4 -2
  36. package/src/api/app-approval.ts +9 -2
  37. package/src/api/approvals.ts +1 -1
  38. package/src/api/index.ts +2 -1
  39. package/src/browser/CloudResourceSearch.tsx +10 -6
  40. package/src/browser/app-approval.ts +7 -2
  41. package/src/browser/composer-catalog.ts +103 -0
  42. package/src/browser/composer-insert-messages.ts +31 -0
  43. package/src/browser/composer-insert.ts +150 -0
  44. package/src/browser/panel-messages.ts +24 -0
  45. package/src/browser/panel-slot.ts +354 -0
  46. package/src/browser/panel.ts +28 -0
  47. package/src/browser/resource-picker.tsx +12 -1
  48. package/src/contracts/app-approval.ts +49 -9
  49. package/src/contracts/app.ts +14 -0
  50. package/src/contracts/registry.ts +3 -1
  51. package/src/contracts/shared.ts +2 -0
  52. package/src/server/services/access.ts +5 -1
  53. package/src/services/accounts/posix.ts +2 -1
  54. package/src/services/app-approval.ts +158 -38
  55. package/src/services/approvals/device.ts +2 -0
  56. package/src/services/index.ts +21 -3
  57. package/src/services/notifications/browser.ts +16 -5
  58. package/src/services/pdf/image-protocol.ts +135 -0
  59. package/src/services/pdf/image-variants.ts +124 -0
  60. package/src/services/pdf/image-worker.ts +31 -0
  61. package/src/services/pdf/image.ts +109 -0
  62. package/src/services/pdf/index.ts +9 -0
  63. package/src/services/settings/api.ts +11 -6
  64. package/src/services/settings/app.ts +5 -4
  65. package/src/services/settings/core-settings.ts +4 -3
  66. package/src/services/settings/index.ts +19 -17
  67. package/src/services/settings/namespace.ts +2 -1
  68. package/src/services/settings/store.ts +83 -11
  69. package/src/shared/ai-platform-prompt.ts +1 -1
  70. package/src/shared/app-presentation.ts +1 -0
  71. package/src/shared/redirect.ts +12 -0
  72. package/src/ssr/CloudShell.island.tsx +86 -9
  73. package/src/ssr/Layout.tsx +42 -9
  74. package/src/ssr/LayoutHeader.tsx +15 -9
  75. package/src/ssr/LayoutHelp.tsx +1 -1
  76. package/src/ssr/LayoutRail.tsx +2 -2
  77. package/src/ssr/LayoutTabBar.tsx +29 -0
  78. package/src/ssr/PanelLayout.tsx +27 -0
  79. package/src/ssr/PanelRuntime.island.tsx +87 -0
  80. package/src/ssr/PanelSlot.tsx +108 -0
  81. package/src/ssr/PanelTrigger.tsx +49 -0
  82. package/src/ssr/ShellRuntime.island.tsx +60 -5
  83. package/src/ssr/app-appearance.ts +3 -2
  84. package/src/ssr/app-color.ts +100 -0
  85. package/src/ssr/app-launchpad.tsx +18 -1
  86. package/src/ssr/app-navigation.ts +15 -0
  87. package/src/ssr/compact-shell.baseline.json +5270 -0
  88. package/src/ssr/compact-shell.browser-harness.ts +431 -0
  89. package/src/ssr/index.ts +3 -0
  90. package/src/ssr/panel-slot.browser-harness.tsx +110 -0
  91. package/src/ssr/platform-messages.ts +30 -0
  92. package/src/ssr/rail-navigation.ts +44 -0
  93. package/src/ssr/shell-data.tsx +4 -0
  94. package/src/ssr/shell-shortcuts.ts +13 -2
  95. package/src/styles/effects.css +3 -2
  96. package/src/styles/global.css +269 -55
  97. package/src/styles/panel.css +373 -0
  98. package/src/styles/tokens.css +23 -6
  99. package/src/styles/utilities-navigation.css +5 -21
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Settings store — Redis cache-aside read/write primitives.
3
3
  *
4
- * Pattern: each key is cached in Redis with a 5-minute TTL. Reads try Redis
4
+ * Pattern: each key is cached in Redis with a 5-minute TTL. Value reads try Redis
5
5
  * first, fall back to DB on miss, and populate Redis. Writes update DB and
6
6
  * delete the Redis key (next reader repopulates with fresh DB state).
7
7
  *
@@ -27,6 +27,19 @@ const REDIS_KEY = (k: string) => `settings:${k}`;
27
27
 
28
28
  type SqlClient = typeof sql;
29
29
 
30
+ export type StoredSettingState = "none" | "readable" | "unreadable";
31
+ export type SettingRead = { value: unknown; stored: StoredSettingState };
32
+
33
+ export class SettingUnreadableError extends HTTPException {
34
+ readonly code = "setting_unreadable" as const;
35
+
36
+ constructor(readonly key: string) {
37
+ const message = `The stored value of setting "${key}" cannot be read by this application. Replace it with a new value or reset it to the default.`;
38
+ super(409, { message, res: Response.json({ code: "setting_unreadable", message }, { status: 409 }) });
39
+ this.name = "SettingUnreadableError";
40
+ }
41
+ }
42
+
30
43
  /**
31
44
  * Forget cached values so every container re-reads on next access.
32
45
  *
@@ -70,6 +83,12 @@ const resolveFallback = (def: SettingDef | undefined): unknown => {
70
83
  */
71
84
  export const readKey = async (key: string): Promise<unknown> => (await bulkRead([key])).get(key);
72
85
 
86
+ export const readKeyState = async (key: string): Promise<SettingRead> => {
87
+ const state = (await readStates([key])).get(key);
88
+ if (!state) throw new Error(`Missing read state for setting "${key}"`);
89
+ return state;
90
+ };
91
+
73
92
  /** One warm MGET; missing rows are cached independently of local env/defaults. */
74
93
  export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unknown>> => {
75
94
  const result = new Map<string, unknown>();
@@ -131,6 +150,32 @@ export const bulkRead = async (keys: readonly string[]): Promise<Map<string, unk
131
150
  return result;
132
151
  };
133
152
 
153
+ /**
154
+ * Stored state is per process (APP_SECRET), but the plaintext cache is shared.
155
+ * State reads go directly to Postgres without reading or filling the cache;
156
+ * value reads (`get`, snapshot) keep using the cache.
157
+ */
158
+ export const readStates = async (keys: readonly string[]): Promise<Map<string, SettingRead>> => {
159
+ const result = new Map<string, SettingRead>();
160
+ if (keys.length === 0) return result;
161
+ const rows = await sql<StoredRow[]>`SELECT key, value FROM settings.entries WHERE key = ANY(${toPgTextArray([...keys])}::text[])`;
162
+ const byKey = new Map(rows.map((row) => [row.key, row]));
163
+ for (const key of keys) {
164
+ const row = byKey.get(key);
165
+ const def = SETTINGS_MAP.get(key);
166
+ result.set(key, { stored: row ? "unreadable" : "none", value: resolveFallback(def) });
167
+ if (!row) continue;
168
+ try {
169
+ const value = await decryptValue(row.value);
170
+ const validated = def ? validateSettingValue(def, value) : { ok: true as const, value };
171
+ if (validated.ok) result.set(key, { stored: "readable", value: validated.value });
172
+ } catch {
173
+ // Keep this process's fallback without changing the stored row or cache.
174
+ }
175
+ }
176
+ return result;
177
+ };
178
+
134
179
  /**
135
180
  * Get every known setting key (across all registered defs).
136
181
  * Used by snapshot loader to determine what to bulk-read.
@@ -184,11 +229,12 @@ export const deleteLegacyKeys = async (extraKnownKeys: readonly string[] = []):
184
229
  /**
185
230
  * Encrypt the value, upsert the DB row, invalidate the Redis key.
186
231
  *
187
- * Validation is the caller's responsibility — the typed wrapper API
188
- * (createSettingsAPI) validates against the declared SettingDef before reaching
189
- * here. Direct callers must ensure the value matches the setting's kind.
232
+ * Validate new values and refuse to overwrite unreadable stored rows unless
233
+ * the caller explicitly replaces them. The row check and write share a transaction.
234
+ * Lock with a no-op UPDATE so ROW EXCLUSIVE is acquired before the row lock,
235
+ * preserving lock order with table-level SHARE locks (#1303 review).
190
236
  */
191
- export const writeKey = async (key: string, value: unknown, db?: SqlClient): Promise<void> => {
237
+ export const writeKey = async (key: string, value: unknown, db?: SqlClient, options?: { replaceUnreadable?: boolean }): Promise<void> => {
192
238
  const def = SETTINGS_MAP.get(key);
193
239
  if (!def) throw new Error(`Unknown setting: ${key}`);
194
240
  const validated = validateSettingValue(def, value);
@@ -197,12 +243,38 @@ export const writeKey = async (key: string, value: unknown, db?: SqlClient): Pro
197
243
  const storedValue =
198
244
  def.kind === "image" && typeof validated.value === "string" ? stripImageDataUrlMetadata(validated.value) : validated.value;
199
245
  const encrypted = await encryptValue(storedValue);
200
- await (db ?? sql)`
201
- INSERT INTO settings.entries (key, value, updated_at)
202
- VALUES (${key}, ${encrypted}, now())
203
- ON CONFLICT (key)
204
- DO UPDATE SET value = ${encrypted}, updated_at = now()
205
- `;
246
+ const write = async (tx: SqlClient) => {
247
+ for (let attempt = 0; attempt < 3; attempt++) {
248
+ let [row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
249
+ if (!row) {
250
+ const inserted = await tx<{ key: string }[]>`
251
+ INSERT INTO settings.entries (key, value, updated_at)
252
+ VALUES (${key}, ${encrypted}, now())
253
+ ON CONFLICT (key) DO NOTHING RETURNING key
254
+ `;
255
+ if (inserted.length > 0) return;
256
+ // A concurrent insert can win after the missing-row read; lock and check its value before updating.
257
+ [row] = await tx<{ value: string }[]>`UPDATE settings.entries SET value = value WHERE key = ${key} RETURNING value`;
258
+ if (!row) continue;
259
+ }
260
+ if (options?.replaceUnreadable !== true) {
261
+ let readable = false;
262
+ try {
263
+ readable = validateSettingValue(def, await decryptValue(row.value)).ok;
264
+ } catch {
265
+ // Only the key is exposed by the recovery error.
266
+ }
267
+ if (!readable) throw new SettingUnreadableError(key);
268
+ }
269
+ await tx`
270
+ UPDATE settings.entries SET value = ${encrypted}, updated_at = now() WHERE key = ${key}
271
+ `;
272
+ return;
273
+ }
274
+ throw new Error(`Could not write setting "${key}" after 3 attempts`);
275
+ };
276
+ if (db) await write(db);
277
+ else await sql.begin(write);
206
278
 
207
279
  // Passing a transaction means the caller owns invalidation, because the row
208
280
  // is not visible to anyone else until they commit.
@@ -59,7 +59,7 @@ Most replies need no offer. Make at most one offer, in the last sentence of the
59
59
  {%- if memoryToolEnabled %}
60
60
  - The user corrected the tone of a result, such as a mail that is too formal, without stating a lasting rule: offer to remember it as their preference. Memory keeps only the user's own words, so a plain yes cannot be saved: suggest a one-line rule they can send back, such as "Always write mails casually and briefly".
61
61
  {%- endif %}
62
- - The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}scheduled task.
62
+ - The user says the request recurs (again, every week, like last time, always), pastes a long reusable instruction, or a workflow default covers it: offer to save it as a {% if skillOffers %}Skill or {% endif %}routine.
63
63
  - A concrete next step clearly saves the user real work they may not know you can do: offer it, such as a manual follow-up the user mentioned, drafting the reply, or turning findings into Space tasks.
64
64
  Offer only what the Skills, tool hints, and apps in this prompt support. Do not search mail, chats, or other data only to justify an offer, and do not claim how often something happened unless the user said it or this chat shows it. Do not start the offered work until the user agrees.
65
65
  {%- if skillOffers %}
@@ -65,6 +65,7 @@ export const resolveAppPresentation = <T extends AppMeta>(app: T, requestedLocal
65
65
  return {
66
66
  ...app,
67
67
  ...resolveAppIdentityPresentation(app, requestedLocale),
68
+ ...(app.panel ? { panel: { ...app.panel, label: translation.panelLabel ?? app.panel.label } } : {}),
68
69
  adminNav: app.adminNav?.map((group) => ({
69
70
  ...group,
70
71
  label: (group.id && translation.adminGroups?.[group.id]) || group.label,
@@ -17,6 +17,18 @@ export const normalizeRedirectTo = (value: string | null | undefined): string |
17
17
  }
18
18
  };
19
19
 
20
+ /** Default landing page when no valid home path is configured. */
21
+ export const DEFAULT_HOME_PATH = "/app/dashboard";
22
+
23
+ /** Resolve a local home path, falling back for root, auth, admin, or invalid targets. */
24
+ export const resolveHomePath = (value: unknown): string => {
25
+ const path = typeof value === "string" ? normalizeRedirectTo(value) : undefined;
26
+ if (!path || path === "/" || path === "/auth" || path.startsWith("/auth/") || path === "/admin" || path.startsWith("/admin/")) {
27
+ return DEFAULT_HOME_PATH;
28
+ }
29
+ return path;
30
+ };
31
+
20
32
  /** Extract a safe post-login redirect target from an incoming request URL. */
21
33
  export const redirectPathFromRequestUrl = (requestUrl: string): string => {
22
34
  const url = new URL(requestUrl);
@@ -1,30 +1,85 @@
1
- import { useLocale } from "@k2b/ui";
2
- import { createEffect } from "solid-js";
1
+ import { TabBar, useLocale } from "@k2b/ui";
2
+ import { createEffect, createMemo, createSignal, onCleanup, onMount, Show } from "solid-js";
3
+ import { isServer, Portal } from "solid-js/web";
3
4
  import { AppLaunchpadButton, setAppLaunchpadContext } from "./app-launchpad";
4
5
  import GlobalSearchTrigger from "./GlobalSearchTrigger";
5
6
  import LayoutBreadcrumbs from "./LayoutBreadcrumbs";
6
7
  import LayoutHelpTrigger from "./LayoutHelpTrigger";
7
8
  import LayoutPreferences from "./LayoutPreferences";
8
9
  import type { LayoutBreadcrumb } from "./layout-runtime";
10
+ import PanelTrigger from "./PanelTrigger";
9
11
  import ProfilePreferences from "./ProfilePreferences";
10
12
  import { platformMessages } from "./platform-messages";
11
13
  import RailApps from "./RailApps";
14
+ import { RAIL_PREFERENCES_EVENT, readRailContext } from "./rail-context";
15
+ import { phoneBarItems, projectRailNavigation } from "./rail-navigation";
12
16
  import ShellFocusToggle from "./ShellFocusToggle";
13
17
  import { useShellData } from "./shell-data";
14
18
 
15
19
  type CloudShellProps =
16
- | { slot: "rail"; currentPath: string }
17
- | { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] };
20
+ | { slot: "rail"; currentPath: string; panelOpen?: boolean }
21
+ | { slot: "header"; authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[]; panelOpen?: boolean };
18
22
 
19
23
  /**
20
24
  * The interactive part of the shell chrome. `LayoutRail` and `LayoutHeader`
21
25
  * own the frame and the plain links around it as server markup.
22
26
  */
23
27
  export default function CloudShell(props: CloudShellProps) {
24
- return props.slot === "rail" ? <RailNavigation currentPath={props.currentPath} /> : <HeaderControls {...props} />;
28
+ return props.slot === "rail" ? (
29
+ <RailNavigation currentPath={props.currentPath} panelOpen={props.panelOpen} />
30
+ ) : (
31
+ <HeaderControls {...props} />
32
+ );
33
+ }
34
+
35
+ /**
36
+ * The phone bar's live copy, mounted with the rail's island so the shell adds no island instance. It renders nothing
37
+ * while the server's bar is current, so mounting moves and refocuses nothing. When the saved rail preferences or the
38
+ * URL (a fragment, Back and Forward, an in-page navigation) change what the bar shows, it renders the new bar into the
39
+ * bar's row, and CSS hides the server's.
40
+ */
41
+ function BarNavigation(props: { currentPath: string }) {
42
+ if (isServer) return null;
43
+ const data = useShellData();
44
+ const locale = useLocale();
45
+ const t = () => platformMessages.resolve([locale()]).t;
46
+ const initial = data?.rail;
47
+ if (!data || !initial) return null;
48
+ const [settings, setSettings] = createSignal(initial);
49
+ const [currentPath, setCurrentPath] = createSignal(props.currentPath);
50
+ const itemsFor = (rail: typeof initial, path: string) =>
51
+ phoneBarItems(projectRailNavigation(data.apps, rail, locale()), path, { label: t().home, path: data.home ?? "/" });
52
+ const served = JSON.stringify(itemsFor(initial, props.currentPath));
53
+ const items = createMemo(() => itemsFor(settings(), currentPath()));
54
+ onMount(() => {
55
+ const sync = () => setSettings(readRailContext()?.settings ?? initial);
56
+ const syncLocation = () => setCurrentPath(`${window.location.pathname}${window.location.search}${window.location.hash}`);
57
+ syncLocation();
58
+ window.addEventListener(RAIL_PREFERENCES_EVENT, sync);
59
+ window.addEventListener("popstate", syncLocation);
60
+ window.addEventListener("hashchange", syncLocation);
61
+ // In-page navigation (`@k2b/ssr/nav`) commits with pushState or replaceState, which only the Navigation API
62
+ // reports; where a browser lacks it, the bar follows at the next page load.
63
+ window.navigation?.addEventListener("currententrychange", syncLocation);
64
+ onCleanup(() => {
65
+ window.removeEventListener(RAIL_PREFERENCES_EVENT, sync);
66
+ window.removeEventListener("popstate", syncLocation);
67
+ window.removeEventListener("hashchange", syncLocation);
68
+ window.navigation?.removeEventListener("currententrychange", syncLocation);
69
+ });
70
+ });
71
+ const row = document.querySelector<HTMLElement>(".cloud-tab-bar__row");
72
+ if (!row) return null;
73
+ return (
74
+ <Show when={JSON.stringify(items()) !== served}>
75
+ <Portal mount={row}>
76
+ <TabBar variant="floating" class="cloud-tab-bar__live" label={t().apps} items={items()} />
77
+ </Portal>
78
+ </Show>
79
+ );
25
80
  }
26
81
 
27
- function RailNavigation(props: { currentPath: string }) {
82
+ function RailNavigation(props: { currentPath: string; panelOpen?: boolean }) {
28
83
  const data = useShellData();
29
84
  const locale = useLocale();
30
85
  const t = () => platformMessages.resolve([locale()]).t;
@@ -36,7 +91,9 @@ function RailNavigation(props: { currentPath: string }) {
36
91
  <ShellFocusToggle variant="rail" />
37
92
  <RailApps apps={data.apps} settings={data.rail} currentPath={props.currentPath} />
38
93
  <AppLaunchpadButton profile={profile} apps={data.apps} legalLinks={data.legalLinks} variant="rail" label={t().openApps} />
94
+ <BarNavigation currentPath={props.currentPath} />
39
95
  <div class="mt-auto shrink-0 flex flex-col items-center gap-1">
96
+ {(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="rail" open={props.panelOpen} />}
40
97
  <GlobalSearchTrigger variant="rail" />
41
98
  <LayoutHelpTrigger variant="rail" />
42
99
  <ProfilePreferences
@@ -51,7 +108,13 @@ function RailNavigation(props: { currentPath: string }) {
51
108
  }
52
109
 
53
110
  /** The header's title and controls, between the server-rendered Home and Sign in. */
54
- function HeaderControls(props: { authenticated: boolean; title?: string; launchpad: boolean; breadcrumbs?: LayoutBreadcrumb[] }) {
111
+ function HeaderControls(props: {
112
+ authenticated: boolean;
113
+ title?: string;
114
+ launchpad: boolean;
115
+ breadcrumbs?: LayoutBreadcrumb[];
116
+ panelOpen?: boolean;
117
+ }) {
55
118
  const data = useShellData();
56
119
  const locale = useLocale();
57
120
  const t = () => platformMessages.resolve([locale()]).t;
@@ -65,13 +128,27 @@ function HeaderControls(props: { authenticated: boolean; title?: string; launchp
65
128
  </div>
66
129
  )}
67
130
  <div class="flex min-w-0 items-center lg:hidden">
68
- <span class="truncate font-semibold">{props.title}</span>
131
+ <span class="layout-header-title truncate font-semibold">{props.title}</span>
132
+ {props.launchpad && (
133
+ <AppLaunchpadButton
134
+ profile={
135
+ data.profile
136
+ ? { name: data.profile.name, theme: data.theme, ...(data.profile.appHref ? { appHref: data.profile.appHref } : {}) }
137
+ : undefined
138
+ }
139
+ apps={data.apps}
140
+ legalLinks={data.legalLinks}
141
+ variant="title"
142
+ label={props.title}
143
+ />
144
+ )}
69
145
  </div>
70
146
  </div>
71
147
  <div class="flex shrink-0 items-center gap-1">
72
148
  <div class="flex items-center gap-1 lg:hidden">
73
- <LayoutHelpTrigger variant="header" />
149
+ {(data.panel?.providers.length ?? 0) > 1 && <PanelTrigger variant="header" open={props.panelOpen} />}
74
150
  {props.authenticated && <GlobalSearchTrigger variant="header" />}
151
+ <LayoutHelpTrigger variant="header" />
75
152
  {props.launchpad && (
76
153
  <AppLaunchpadButton
77
154
  profile={
@@ -2,6 +2,7 @@ import type { DateContext } from "@k2b/stdlib";
2
2
  import { AppWorkspace, appWorkspaceLayoutStyle, keyboardPlatform, LocaleProvider, NoticeCard, PageTrailProvider } from "@k2b/ui";
3
3
  import type { JSX } from "solid-js/jsx-runtime";
4
4
  import { readAppWorkspaceLayoutCookie, resolveAppWorkspaceLayoutForSidebar } from "../_internal/app-workspace-state";
5
+ import { readPanelCookie } from "../_internal/panel-cookie";
5
6
  import { readShellFocusCookie } from "../_internal/shell-focus";
6
7
  import { resolveNavMatch } from "../contracts/app"; // ==========================
7
8
  import { isPwaShellAvailable } from "../contracts/pwa";
@@ -9,19 +10,21 @@ import { defaultRailPreferences } from "../contracts/rail-preferences";
9
10
  import { hasRole, type User } from "../contracts/shared";
10
11
  import { getLocale } from "../server/locale";
11
12
  import { getDateConfig } from "../server/time";
12
- import { dates } from "../shared";
13
+ import { dates, resolveHomePath } from "../shared";
13
14
  import { readThemePreferenceFromCookieHeader } from "../shared/theme";
14
15
  import AnnouncementBanners from "./AnnouncementBanners";
15
16
  import { appAppearanceStyle, resolveCurrentApp } from "./app-appearance";
16
- import { resolveRuntimeRoute, visibleNavigationApps } from "./app-navigation";
17
+ import { resolveRuntimeRoute, visibleNavigationApps, visiblePanelProviders } from "./app-navigation";
17
18
  import { getLayoutHelp, layoutHelpFailed } from "./help";
18
19
  import LayoutFooter from "./LayoutFooter";
19
20
  import LayoutHeader from "./LayoutHeader";
20
21
  import LayoutRail, { type LayoutAppLink } from "./LayoutRail";
22
+ import LayoutTabBar from "./LayoutTabBar";
21
23
  import type { LayoutContext } from "./layout-context";
22
24
  import { HELP_VIEW_LIMIT } from "./layout-help-start";
23
25
  import type { LayoutBreadcrumb } from "./layout-runtime";
24
26
  import PageTrailRow from "./PageTrailRow";
27
+ import PanelSlot from "./PanelSlot";
25
28
  import { derivePageTrail } from "./page-trail";
26
29
  import { platformMessages } from "./platform-messages";
27
30
  import { sortRailApps } from "./rail-navigation";
@@ -41,6 +44,8 @@ type LayoutProps = {
41
44
  help?: readonly string[];
42
45
  fullPage?: boolean /** Keep the shell viewport-bound and suppress its footer. */;
43
46
  fullWidth?: boolean /** Delegate scrolling and clipping to the page's work surface. */;
47
+ /** Hide the panel slot on this page (sign-in, print, presentation, public pages). */
48
+ panel?: false;
44
49
  focusMode?: boolean /** Render only the app canvas and content for dedicated editors or pop-out windows. */;
45
50
  flushCanvas?: boolean /** Remove app-canvas spacing for edge-to-edge focus surfaces such as pop-out windows. */;
46
51
  /** Final localized app-specific export or printing guidance, shown when no data-print-region exists. */
@@ -155,6 +160,12 @@ export default function Layout(props: LayoutProps) {
155
160
  const lang = getLocale(c);
156
161
  const t = platformMessages.resolve([lang]).t;
157
162
  const user = c.get("user");
163
+ const providers = user && props.panel !== false ? visiblePanelProviders(runtime.apps, user, lang) : [];
164
+ const panelState = readPanelCookie(
165
+ providers.length ? cookie : undefined,
166
+ providers.map((provider) => provider.id),
167
+ );
168
+ const panel = providers.length ? { providers, provider: panelState.provider ?? providers[0]?.id } : undefined;
158
169
  const pathname = new URL(c.req.raw.url).pathname;
159
170
  const currentRoute = resolveRuntimeRoute(runtime.apps, pathname);
160
171
  const currentApp = resolveCurrentApp(runtime.apps, pathname);
@@ -207,19 +218,29 @@ export default function Layout(props: LayoutProps) {
207
218
  // keeps short and long pages at the same width where scrollbars take space.
208
219
  const mainLayoutClass = fullPage || fullWidth ? "flex flex-col" : "md:overflow-auto md:[scrollbar-gutter:stable]";
209
220
  const canvasStyle =
210
- [appAppearanceStyle(currentApp?.appearance), appWorkspaceLayoutStyle(workspaceLayout), focusMode && flushCanvas ? "padding:0" : ""]
221
+ [
222
+ appAppearanceStyle(currentApp?.appearance),
223
+ appWorkspaceLayoutStyle(workspaceLayout),
224
+ panelState.widthRem !== undefined ? `--cloud-panel-width-set:${panelState.widthRem}rem` : "",
225
+ focusMode && flushCanvas ? "padding:0" : "",
226
+ ]
211
227
  .filter(Boolean)
212
228
  .join(";") || undefined;
213
229
  const appLabel = currentApp?.name ?? (pathname.startsWith("/admin") ? t.admin : undefined);
214
230
  const helpIds = new Set(registeredHelp?.documents.map((topic) => topic.id));
215
231
  const viewHelp = [...new Set(props.help)].filter((id) => helpIds.has(id)).slice(0, HELP_VIEW_LIMIT);
216
232
  const shellData: ShellData = {
233
+ ...(panel ? { panel } : {}),
217
234
  platform: keyboardPlatform(c.req.raw.headers.get("User-Agent") ?? ""),
218
235
  ...(viewHelp.length ? { help: viewHelp } : {}),
219
236
  ...(layoutHelpFailed(c) ? { helpFailed: true as const } : {}),
220
237
  apps: allApps,
221
238
  ...(user
222
- ? { rail: c.get("railPreferences") ?? defaultRailPreferences(), profile: { name: profileName, avatarSrc: profileAvatarSrc, appHref } }
239
+ ? {
240
+ rail: c.get("railPreferences") ?? defaultRailPreferences(),
241
+ home: resolveHomePath(settings?.app?.home_path),
242
+ profile: { name: profileName, avatarSrc: profileAvatarSrc, appHref },
243
+ }
223
244
  : {}),
224
245
  legalLinks,
225
246
  theme,
@@ -231,20 +252,27 @@ export default function Layout(props: LayoutProps) {
231
252
  return (
232
253
  <LocaleProvider locale={lang}>
233
254
  <div
234
- class="cloud-app-canvas flex h-dvh w-full overflow-hidden"
255
+ class="cloud-app-canvas relative flex h-dvh w-full overflow-hidden"
235
256
  style={canvasStyle}
257
+ data-panel-transient=""
236
258
  data-app-id={currentApp?.id}
237
259
  data-app-base={currentRoute?.prefix}
238
260
  data-page-trail-title-only=""
239
261
  data-layout-full-page="true"
240
262
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
241
263
  >
242
- <ShellRuntime pageTrail help={runtimeHelp} />
264
+ <ShellRuntime pageTrail help={runtimeHelp} panel={panel ? true : undefined} />
243
265
  <PageTrailProvider trail={pageTrail}>
244
- <main class="min-h-0 min-w-0 flex-1">
266
+ <main class="min-h-0 min-w-0 flex-1" data-cloud-app-area="">
245
267
  <AppWorkspace.LayoutStateProvider state={workspaceLayout}>{props.children}</AppWorkspace.LayoutStateProvider>
246
268
  </main>
247
269
  </PageTrailProvider>
270
+ {/* Focus pages carry shell data only for their panel providers. */}
271
+ {panel && (
272
+ <ShellDataProvider data={{ panel, platform: shellData.platform, theme, apps: [], legalLinks: [], searchLinks: {} }}>
273
+ <PanelSlot panel={panel} state={{ ...panelState, open: false }} platform={shellData.platform} />
274
+ </ShellDataProvider>
275
+ )}
248
276
  <div data-print-fallback="">
249
277
  <p>{t.printFallbackTitle}</p>
250
278
  <p>{props.printHint ?? t.printFallbackHint}</p>
@@ -261,6 +289,7 @@ export default function Layout(props: LayoutProps) {
261
289
  <div
262
290
  class={`cloud-app-canvas relative flex w-full ${fullPage ? "h-dvh overflow-hidden" : "min-h-dvh md:h-dvh md:overflow-hidden"}`}
263
291
  style={canvasStyle}
292
+ data-panel={panelState.open ? "open" : undefined}
264
293
  data-app-id={currentApp?.id}
265
294
  data-app-base={currentRoute?.prefix}
266
295
  data-page-trail-title-only={titleOnly ? "" : undefined}
@@ -270,6 +299,7 @@ export default function Layout(props: LayoutProps) {
270
299
  data-workspace-sidebar-collapsed={workspaceLayout?.sidebarCollapsed ? "true" : undefined}
271
300
  >
272
301
  <ShellRuntime
302
+ panel={panel ? true : undefined}
273
303
  workspace={{ appId: currentApp?.id }}
274
304
  pageTrail
275
305
  focus={showRail}
@@ -284,11 +314,12 @@ export default function Layout(props: LayoutProps) {
284
314
  banners={user && announcements?.banners.length ? true : undefined}
285
315
  />
286
316
  <ShellDataProvider data={shellData}>
287
- {showRail && <LayoutRail currentPath={requestPath(c)} />}
317
+ {showRail && <LayoutRail currentPath={requestPath(c)} panelOpen={panel ? panelState.open : undefined} />}
288
318
  {showRail && <ShellFocusToggle variant="float" />}
289
319
  {showRail && <div class="cloud-shell-reveal" data-cloud-shell-reveal="" aria-hidden="true" />}
290
- <div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col">
320
+ <div class="layout-shell-content flex min-h-0 min-w-0 flex-1 flex-col" data-cloud-app-area="">
291
321
  <LayoutHeader
322
+ panelOpen={panel ? panelState.open : undefined}
292
323
  authenticated={showRail}
293
324
  title={appLabel ?? breadcrumbs.at(-1)?.title}
294
325
  launchpad={!!(user || appLabel)}
@@ -305,6 +336,8 @@ export default function Layout(props: LayoutProps) {
305
336
  </PageTrailProvider>
306
337
  {!fullPage && !showRail && <LayoutFooter appName={settings?.app?.copyright || appName} legalLinks={legalLinks} />}
307
338
  </div>
339
+ {panel && <PanelSlot panel={panel} state={panelState} platform={shellData.platform} />}
340
+ {showRail && <LayoutTabBar currentPath={requestPath(c)} />}
308
341
  </ShellDataProvider>
309
342
  <div data-print-fallback="">
310
343
  <p>{t.printFallbackTitle}</p>
@@ -5,6 +5,7 @@ import { platformMessages } from "./platform-messages";
5
5
 
6
6
  type LayoutHeaderProps = {
7
7
  authenticated: boolean;
8
+ panelOpen?: boolean;
8
9
  title?: string;
9
10
  launchpad: boolean;
10
11
  breadcrumbs?: LayoutBreadcrumb[];
@@ -14,10 +15,12 @@ type LayoutHeaderProps = {
14
15
  * Phones (below `md`) always get this header. From `md` it remains only on
15
16
  * anonymous pages; signed-in people reach Home, search, help, the app grid,
16
17
  * and their profile through the rail, so the app area starts at the top edge.
17
- * Anonymous pages have no rail: up to `lg` their header keeps the app name,
18
- * help, search, and the app grid, and from `lg` it shows the breadcrumbs.
19
- * Home and Sign in stay server markup, so focus on them survives the shell's
20
- * mount; the shell renders the title and the controls between them.
18
+ * On a signed-in phone it is the title row on the app gradient: Home moves to
19
+ * the bottom bar's Start, so the row holds the title, search, help, and the
20
+ * app menu. Anonymous pages have no rail: up to `lg` their header keeps Home,
21
+ * the app name, help, search, and the app grid, and from `lg` it shows the
22
+ * breadcrumbs. Home and Sign in stay server markup, so focus on them survives
23
+ * the shell's mount; the shell renders the title and the controls between them.
21
24
  */
22
25
  export default function LayoutHeader(props: LayoutHeaderProps) {
23
26
  const locale = useLocale();
@@ -29,13 +32,16 @@ export default function LayoutHeader(props: LayoutHeaderProps) {
29
32
  >
30
33
  {/* A long title must not squeeze the button; `shrink-0` sits on a wrapper because the button's own
31
34
  styles would win over a utility on the link. */}
32
- <div class="flex shrink-0">
33
- <IconButtonLink href="/" label={t().home}>
34
- <img src="/branding/logo" alt="" class={props.authenticated ? "h-4 w-4" : "h-6 w-6"} />
35
- </IconButtonLink>
36
- </div>
35
+ {!props.authenticated && (
36
+ <div class="flex shrink-0">
37
+ <IconButtonLink href="/" label={t().home}>
38
+ <img src="/branding/logo" alt="" class="h-6 w-6" />
39
+ </IconButtonLink>
40
+ </div>
41
+ )}
37
42
  <CloudShell
38
43
  slot="header"
44
+ panelOpen={props.panelOpen}
39
45
  authenticated={props.authenticated}
40
46
  title={props.title}
41
47
  launchpad={props.launchpad}
@@ -101,7 +101,7 @@ const StartShortcuts = (props: { appName?: string }) => {
101
101
  const locale = useLocale();
102
102
  const t = () => helpMessages.resolve([locale()]).t;
103
103
  const shell = useShellData();
104
- const seed: HelpShortcutCommand[] = shell ? shellShortcuts(platformMessages.resolve([locale()]).t) : [];
104
+ const seed: HelpShortcutCommand[] = shell ? shellShortcuts(platformMessages.resolve([locale()]).t, { panel: !!shell.panel }) : [];
105
105
  const read = (): HelpShortcutCommand[] => {
106
106
  const commands = contextCommandsWithShortcuts();
107
107
  return [...seed.filter((command) => !commands.some((other) => other.id === command.id)), ...commands];
@@ -5,7 +5,7 @@ import type { RailApp } from "./rail-navigation";
5
5
  export type LayoutAppLink = RailApp;
6
6
 
7
7
  /** The rail frame and Home stay server markup; only the navigation is interactive. */
8
- export default function LayoutRail(props: { currentPath: string }) {
8
+ export default function LayoutRail(props: { currentPath: string; panelOpen?: boolean }) {
9
9
  const locale = useLocale();
10
10
  const t = () => platformMessages.resolve([locale()]).t;
11
11
  return (
@@ -15,7 +15,7 @@ export default function LayoutRail(props: { currentPath: string }) {
15
15
  <img src="/branding/logo" alt="Logo" class="h-5 w-5" />
16
16
  </a>
17
17
  </div>
18
- <CloudShell slot="rail" currentPath={props.currentPath} />
18
+ <CloudShell slot="rail" currentPath={props.currentPath} panelOpen={props.panelOpen} />
19
19
  </aside>
20
20
  );
21
21
  }
@@ -0,0 +1,29 @@
1
+ import { TabBar, useLocale } from "@k2b/ui";
2
+ import { platformMessages } from "./platform-messages";
3
+ import { phoneBarItems, projectRailNavigation } from "./rail-navigation";
4
+ import { useShellData } from "./shell-data";
5
+
6
+ /**
7
+ * The signed-in phone's bottom bar: Home and the person's first apps in their rail order, floating on the app
8
+ * gradient above the content card. It is server markup, so it stands in its final place before any island mounts and
9
+ * a link focused early keeps its focus. The rail's island takes over only when the bar's content changes in the
10
+ * browser: after the person saves their rail preferences, or when the URL changes in the page. The
11
+ * place of the app's primary action beside it stays free. From 768 px the rail takes over and the bar is hidden.
12
+ */
13
+ export default function LayoutTabBar(props: { currentPath: string }) {
14
+ const data = useShellData();
15
+ const locale = useLocale();
16
+ if (!data?.rail) return null;
17
+ const t = platformMessages.resolve([locale()]).t;
18
+ const items = phoneBarItems(projectRailNavigation(data.apps, data.rail, locale()), props.currentPath, {
19
+ label: t.home,
20
+ path: data.home ?? "/",
21
+ });
22
+ return (
23
+ <div class="cloud-tab-bar">
24
+ <div class="cloud-tab-bar__row">
25
+ <TabBar variant="floating" label={t.apps} items={items} />
26
+ </div>
27
+ </div>
28
+ );
29
+ }
@@ -0,0 +1,27 @@
1
+ import { LocaleProvider } from "@k2b/ui";
2
+ import type { JSX } from "solid-js";
3
+ import { canonicalRoute } from "../_internal/app-path";
4
+ import { getLocale } from "../server/locale";
5
+ import { resolveRuntimeRoute } from "./app-navigation";
6
+ import type { MinimalLayoutContext } from "./layout-context";
7
+ import PanelRuntime from "./PanelRuntime.island";
8
+ import { getRuntimeContext } from "./runtime";
9
+
10
+ export type PanelLayoutProps = { c: MinimalLayoutContext; title: string; children: JSX.Element };
11
+
12
+ /** A provider page embedded in the shell's same-origin panel. */
13
+ export default function PanelLayout(props: PanelLayoutProps) {
14
+ const runtime = getRuntimeContext(props.c);
15
+ // The provider owns this path by the canonical prefixes its panel declaration was checked against.
16
+ const apps = runtime.apps.map((entry) => ({ ...entry, routes: entry.routes.map(canonicalRoute) }));
17
+ const app = resolveRuntimeRoute(apps, new URL(props.c.req.raw.url).pathname)?.app;
18
+ props.c.get("page").title = props.title;
19
+ return (
20
+ <LocaleProvider locale={getLocale(props.c)}>
21
+ <div class="cloud-panel-page" data-cloud-panel-page="" data-panel-provider={app?.id}>
22
+ <PanelRuntime provider={app?.id} scope={app?.panel ? new URL(app.panel.href, props.c.req.raw.url).pathname : undefined} />
23
+ {props.children}
24
+ </div>
25
+ </LocaleProvider>
26
+ );
27
+ }